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 \
86 WxH",
87 s
88 ))
89 }
90 }
91 }
92 }
93
94 pub fn dimensions(&self, src_width: u32, src_height: u32) -> (u32, u32) {
99 const NES_PAR: f64 = 8.0 / 7.0;
101
102 match self {
103 VideoResolution::Native => (src_width, src_height),
104 VideoResolution::IntegerScale(scale) => (src_width * scale, src_height * scale),
105 VideoResolution::Hd720 => fit_to_bounds(src_width, src_height, 1280, 720, NES_PAR),
106 VideoResolution::Hd1080 => fit_to_bounds(src_width, src_height, 1920, 1080, NES_PAR),
107 VideoResolution::Uhd4k => fit_to_bounds(src_width, src_height, 3840, 2160, NES_PAR),
108 VideoResolution::Custom(w, h) => (*w, *h),
109 }
110 }
111}
112
113fn fit_to_bounds(
115 src_width: u32,
116 src_height: u32,
117 max_width: u32,
118 max_height: u32,
119 par: f64,
120) -> (u32, u32) {
121 let scale_x = max_width as f64 / (src_width as f64 * par);
123 let scale_y = max_height as f64 / src_height as f64;
124 let scale = scale_x.min(scale_y);
125
126 let int_scale = scale.floor() as u32;
128 let int_scale = int_scale.max(1); let out_width = (src_width as f64 * par * int_scale as f64).round() as u32;
132 let out_height = src_height * int_scale;
133
134 let out_width = (out_width + 1) & !1;
136 let out_height = (out_height + 1) & !1;
137
138 (out_width, out_height)
139}
140
141use crate::cli::args::VideoExportMode;
146
147pub const NES_NTSC_FPS: f64 = 39375000.0 / 655171.0;
149
150pub const NES_NTSC_FPS_NUM: u64 = 39375000;
152pub const NES_NTSC_FPS_DEN: u64 = 655171;
153
154pub const SMOOTH_FPS: f64 = 60.0;
156
157#[derive(Debug, Clone)]
163pub struct FpsConfig {
164 pub multiplier: u32,
167 pub mode: VideoExportMode,
169}
170
171impl FpsConfig {
172 pub fn parse(s: &str, mode: VideoExportMode) -> Result<Self, String> {
179 let s = s.trim().to_lowercase();
180
181 if let Some(mult_str) = s.strip_suffix('x') {
183 let multiplier: u32 = mult_str
184 .parse()
185 .map_err(|_| format!("Invalid FPS multiplier: '{}'", s))?;
186 if multiplier == 0 {
187 return Err("FPS multiplier must be at least 1".to_string());
188 }
189 return Ok(Self {
190 multiplier,
191 mode,
192 });
193 }
194
195 let fps: f64 = s.parse().map_err(|_| {
197 format!(
198 "Invalid FPS value: '{}'. Use multipliers like '2x' or fixed values like '60.0'",
199 s
200 )
201 })?;
202
203 if fps <= 0.0 {
204 return Err("FPS must be positive".to_string());
205 }
206
207 let base_fps = match mode {
209 VideoExportMode::Accurate => NES_NTSC_FPS,
210 VideoExportMode::Smooth => SMOOTH_FPS,
211 };
212
213 let multiplier = (fps / base_fps).round() as u32;
215 let multiplier = multiplier.max(1);
216
217 Ok(Self {
218 multiplier,
219 mode,
220 })
221 }
222
223 pub fn output_fps(&self) -> f64 {
225 match self.mode {
226 VideoExportMode::Accurate => NES_NTSC_FPS * self.multiplier as f64,
227 VideoExportMode::Smooth => SMOOTH_FPS * self.multiplier as f64,
228 }
229 }
230
231 pub fn output_fps_rational(&self) -> String {
237 match self.mode {
238 VideoExportMode::Accurate => {
239 let numerator = NES_NTSC_FPS_NUM * self.multiplier as u64;
241 format!("{}/{}", numerator, NES_NTSC_FPS_DEN)
242 }
243 VideoExportMode::Smooth => {
244 let fps = 60 * self.multiplier;
246 format!("{}/1", fps)
247 }
248 }
249 }
250
251 pub fn captures_per_frame(&self) -> u32 { self.multiplier }
257
258 pub fn needs_mid_frame_capture(&self) -> bool { self.multiplier > 1 }
260}
261
262impl Default for FpsConfig {
263 fn default() -> Self {
264 Self {
265 multiplier: 1,
266 mode: VideoExportMode::Accurate,
267 }
268 }
269}
270
271#[derive(Debug)]
277pub enum VideoError {
278 FfmpegNotFound,
280 FfmpegFailed(String),
282 IoError(io::Error),
284 ImageError(String),
286 InvalidDimensions {
288 expected: (u32, u32),
289 got: (u32, u32),
290 },
291}
292
293impl std::fmt::Display for VideoError {
294 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295 match self {
296 VideoError::FfmpegNotFound => {
297 write!(
298 f,
299 "FFmpeg not found. Please install FFmpeg for MP4 export, or use PNG/PPM \
300 format."
301 )
302 }
303 VideoError::FfmpegFailed(msg) => write!(f, "FFmpeg encoding failed: {}", msg),
304 VideoError::IoError(e) => write!(f, "I/O error: {}", e),
305 VideoError::ImageError(e) => write!(f, "Image encoding error: {}", e),
306 VideoError::InvalidDimensions {
307 expected,
308 got,
309 } => {
310 write!(
311 f,
312 "Invalid frame dimensions: expected {}x{}, got {}x{}",
313 expected.0, expected.1, got.0, got.1
314 )
315 }
316 }
317 }
318}
319
320impl std::error::Error for VideoError {
321 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
322 match self {
323 VideoError::IoError(e) => Some(e),
324 _ => None,
325 }
326 }
327}
328
329impl From<io::Error> for VideoError {
330 fn from(e: io::Error) -> Self {
331 if e.kind() == io::ErrorKind::NotFound {
332 VideoError::FfmpegNotFound
333 } else {
334 VideoError::IoError(e)
335 }
336 }
337}
338
339impl From<image::ImageError> for VideoError {
340 fn from(e: image::ImageError) -> Self { VideoError::ImageError(e.to_string()) }
341}
342
343pub trait VideoEncoder: Send {
351 fn write_frame(&mut self, pixel_buffer: &[RgbColor]) -> Result<(), VideoError>;
356
357 fn finish(&mut self) -> Result<(), VideoError>;
359
360 fn frames_written(&self) -> u64;
362}
363
364pub fn create_encoder(
372 format: VideoFormat,
373 output_path: &Path,
374 width: u32,
375 height: u32,
376 fps: f64,
377) -> Result<Box<dyn VideoEncoder>, VideoError> {
378 match format {
379 VideoFormat::Png => Ok(Box::new(PngSequenceEncoder::new(
380 output_path,
381 width,
382 height,
383 )?)),
384 VideoFormat::Ppm => Ok(Box::new(PpmSequenceEncoder::new(
385 output_path,
386 width,
387 height,
388 )?)),
389 VideoFormat::Mp4 => Ok(Box::new(FfmpegMp4Encoder::new(
390 output_path,
391 width,
392 height,
393 fps,
394 None,
395 )?)),
396 VideoFormat::Raw => Ok(Box::new(RawEncoder::new(width, height)?)),
397 }
398}
399
400pub fn create_encoder_with_scale(
405 output_path: &Path,
406 src_width: u32,
407 src_height: u32,
408 dst_width: u32,
409 dst_height: u32,
410 fps: f64,
411) -> Result<Box<dyn VideoEncoder>, VideoError> {
412 Ok(Box::new(FfmpegMp4Encoder::new(
413 output_path,
414 src_width,
415 src_height,
416 fps,
417 Some((dst_width, dst_height)),
418 )?))
419}
420
421pub struct PngSequenceEncoder {
427 base_path: PathBuf,
428 width: u32,
429 height: u32,
430 frame_count: u64,
431}
432
433impl PngSequenceEncoder {
434 pub fn new(output_path: &Path, width: u32, height: u32) -> Result<Self, VideoError> {
436 if let Some(parent) = output_path.parent()
437 && !parent.exists()
438 {
439 fs::create_dir_all(parent)?;
440 }
441
442 Ok(Self {
443 base_path: output_path.to_path_buf(),
444 width,
445 height,
446 frame_count: 0,
447 })
448 }
449
450 fn frame_path(&self, frame: u64) -> PathBuf {
451 let stem = self
452 .base_path
453 .file_stem()
454 .map(|s| s.to_string_lossy().to_string())
455 .unwrap_or_else(|| "frame".to_string());
456 let dir = self
457 .base_path
458 .parent()
459 .unwrap_or(Path::new("../../../../../../.."));
460 dir.join(format!("{}_{:06}.png", stem, frame))
461 }
462}
463
464impl VideoEncoder for PngSequenceEncoder {
465 fn write_frame(&mut self, pixel_buffer: &[RgbColor]) -> Result<(), VideoError> {
466 let expected_size = (self.width * self.height) as usize;
467 if pixel_buffer.len() != expected_size {
468 let got_height = pixel_buffer.len() / self.width as usize;
469 return Err(VideoError::InvalidDimensions {
470 expected: (self.width, self.height),
471 got: (self.width, got_height as u32),
472 });
473 }
474
475 let img: RgbaImage = ImageBuffer::from_fn(self.width, self.height, |x, y| {
476 let color = pixel_buffer[(y * self.width + x) as usize];
477 Rgba([color.r, color.g, color.b, 255])
478 });
479
480 let path = self.frame_path(self.frame_count);
481 img.save(&path)?;
482
483 self.frame_count += 1;
484 Ok(())
485 }
486
487 fn finish(&mut self) -> Result<(), VideoError> { Ok(()) }
488
489 fn frames_written(&self) -> u64 { self.frame_count }
490}
491
492pub struct PpmSequenceEncoder {
498 base_path: PathBuf,
499 width: u32,
500 height: u32,
501 frame_count: u64,
502}
503
504impl PpmSequenceEncoder {
505 pub fn new(output_path: &Path, width: u32, height: u32) -> Result<Self, VideoError> {
507 if let Some(parent) = output_path.parent()
508 && !parent.exists()
509 {
510 fs::create_dir_all(parent)?;
511 }
512
513 Ok(Self {
514 base_path: output_path.to_path_buf(),
515 width,
516 height,
517 frame_count: 0,
518 })
519 }
520
521 fn frame_path(&self, frame: u64) -> PathBuf {
522 let stem = self
523 .base_path
524 .file_stem()
525 .map(|s| s.to_string_lossy().to_string())
526 .unwrap_or_else(|| "frame".to_string());
527 let dir = self
528 .base_path
529 .parent()
530 .unwrap_or(Path::new("../../../../../../.."));
531 dir.join(format!("{}_{:06}.ppm", stem, frame))
532 }
533}
534
535impl VideoEncoder for PpmSequenceEncoder {
536 fn write_frame(&mut self, pixel_buffer: &[RgbColor]) -> Result<(), VideoError> {
537 let expected_size = (self.width * self.height) as usize;
538 if pixel_buffer.len() != expected_size {
539 let got_height = pixel_buffer.len() / self.width as usize;
540 return Err(VideoError::InvalidDimensions {
541 expected: (self.width, self.height),
542 got: (self.width, got_height as u32),
543 });
544 }
545
546 let path = self.frame_path(self.frame_count);
547 let file = File::create(&path)?;
548 let mut writer = BufWriter::new(file);
549
550 writeln!(writer, "P6")?;
551 writeln!(writer, "{} {}", self.width, self.height)?;
552 writeln!(writer, "255")?;
553
554 for &RgbColor {
555 r,
556 g,
557 b,
558 } in pixel_buffer
559 {
560 writer.write_all(&[r, g, b])?;
561 }
562
563 writer.flush()?;
564 self.frame_count += 1;
565 Ok(())
566 }
567
568 fn finish(&mut self) -> Result<(), VideoError> { Ok(()) }
569
570 fn frames_written(&self) -> u64 { self.frame_count }
571}
572
573pub struct FfmpegMp4Encoder {
581 child: Option<Child>,
582 stdin: Option<BufWriter<std::process::ChildStdin>>,
583 stderr_path: Option<PathBuf>,
584 width: u32,
585 height: u32,
586 frame_count: u64,
587}
588
589impl FfmpegMp4Encoder {
590 pub fn new(
595 output_path: &Path,
596 width: u32,
597 height: u32,
598 fps: f64,
599 scale_to: Option<(u32, u32)>,
600 ) -> Result<Self, VideoError> {
601 let ffmpeg_check = Command::new("ffmpeg").arg("-version").output();
603
604 match ffmpeg_check {
605 Err(e) if e.kind() == io::ErrorKind::NotFound => {
606 return Err(VideoError::FfmpegNotFound);
607 }
608 Err(e) => return Err(VideoError::IoError(e)),
609 Ok(_) => {}
610 }
611
612 if let Some(parent) = output_path.parent()
614 && !parent.exists()
615 {
616 fs::create_dir_all(parent)?;
617 }
618
619 let stderr_path =
620 std::env::temp_dir().join(format!("nes_ffmpeg_stderr_{}.log", std::process::id()));
621 let stderr_file = File::create(&stderr_path)?;
622
623 let path = output_path.with_extension("mp4");
624
625 let fps_str = fps_to_rational(fps);
630
631 let mut args = vec![
633 "-y".to_string(),
634 "-f".to_string(),
635 "rawvideo".to_string(),
636 "-pixel_format".to_string(),
637 "bgra".to_string(),
638 "-video_size".to_string(),
639 format!("{}x{}", width, height),
640 "-framerate".to_string(),
641 fps_str,
642 "-i".to_string(),
643 "-".to_string(),
644 ];
645
646 if let Some((dst_w, dst_h)) = scale_to
648 && (dst_w != width || dst_h != height)
649 {
650 eprintln!(
651 "FFmpeg scaling {}x{} -> {}x{} (nearest neighbor)",
652 width, height, dst_w, dst_h
653 );
654 args.extend([
655 "-vf".to_string(),
656 format!("scale={}:{}:flags=neighbor", dst_w, dst_h),
657 ]);
658 }
659
660 args.extend([
662 "-c:v".to_string(),
663 "libx264".to_string(),
664 "-preset".to_string(),
665 "fast".to_string(),
666 "-crf".to_string(),
667 "16".to_string(),
668 "-vsync".to_string(),
669 "cfr".to_string(),
670 "-video_track_timescale".to_string(),
671 "39375000".to_string(),
672 "-pix_fmt".to_string(),
673 "yuv420p".to_string(),
674 "-movflags".to_string(),
675 "+faststart".to_string(),
676 "-f".to_string(),
677 "mp4".to_string(),
678 path.to_str().unwrap_or("output.mp4").to_string(),
679 ]);
680
681 let mut child = Command::new("ffmpeg")
682 .args(&args)
683 .stdin(Stdio::piped())
684 .stdout(Stdio::null())
685 .stderr(Stdio::from(stderr_file))
686 .spawn()
687 .map_err(|e| {
688 if e.kind() == io::ErrorKind::NotFound {
689 VideoError::FfmpegNotFound
690 } else {
691 VideoError::IoError(e)
692 }
693 })?;
694
695 let stdin = child.stdin.take().map(BufWriter::new);
696
697 Ok(Self {
698 child: Some(child),
699 stdin,
700 stderr_path: Some(stderr_path),
701 width,
702 height,
703 frame_count: 0,
704 })
705 }
706}
707
708impl VideoEncoder for FfmpegMp4Encoder {
709 fn write_frame(&mut self, pixel_buffer: &[RgbColor]) -> Result<(), VideoError> {
710 let expected_size = (self.width * self.height) as usize;
711 if pixel_buffer.len() != expected_size {
712 let got_height = pixel_buffer.len() / self.width as usize;
713 return Err(VideoError::InvalidDimensions {
714 expected: (self.width, self.height),
715 got: (self.width, got_height as u32),
716 });
717 }
718
719 let mut bgra_buffer = Vec::with_capacity(pixel_buffer.len() * 4);
721 for &RgbColor {
722 r,
723 g,
724 b,
725 } in pixel_buffer
726 {
727 bgra_buffer.extend_from_slice(&[b, g, r, 255]);
728 }
729
730 match &mut self.stdin {
731 Some(stdin) => {
732 stdin.write_all(&bgra_buffer).map_err(|e| {
733 if e.kind() == io::ErrorKind::BrokenPipe {
734 VideoError::FfmpegFailed(
735 "FFmpeg closed input pipe unexpectedly".to_string(),
736 )
737 } else {
738 VideoError::IoError(e)
739 }
740 })?;
741 }
742 None => {
743 return Err(VideoError::FfmpegFailed(
744 "FFmpeg stdin not available".to_string(),
745 ));
746 }
747 }
748
749 self.frame_count += 1;
750 Ok(())
751 }
752
753 fn finish(&mut self) -> Result<(), VideoError> {
754 self.stdin.take();
756
757 if let Some(mut child) = self.child.take() {
759 let status = child.wait()?;
760 if !status.success() {
761 let stderr_content = if let Some(ref path) = self.stderr_path {
762 fs::read_to_string(path).unwrap_or_default()
763 } else {
764 String::new()
765 };
766 return Err(VideoError::FfmpegFailed(format!(
767 "FFmpeg exited with status {}: {}",
768 status,
769 stderr_content
770 .lines()
771 .take(10)
772 .collect::<Vec<_>>()
773 .join("\n")
774 )));
775 }
776 }
777
778 if let Some(ref path) = self.stderr_path {
780 let _ = fs::remove_file(path);
781 }
782
783 Ok(())
784 }
785
786 fn frames_written(&self) -> u64 { self.frame_count }
787}
788
789impl Drop for FfmpegMp4Encoder {
790 fn drop(&mut self) {
791 self.stdin.take();
793
794 if let Some(mut child) = self.child.take() {
796 let _ = child.wait();
797 }
798
799 if let Some(ref path) = self.stderr_path {
801 let _ = fs::remove_file(path);
802 }
803 }
804}
805
806pub struct RawEncoder {
812 width: u32,
813 height: u32,
814 frame_count: u64,
815 stdout: BufWriter<io::Stdout>,
816}
817
818impl RawEncoder {
819 pub fn new(width: u32, height: u32) -> Result<Self, VideoError> {
821 Ok(Self {
822 width,
823 height,
824 frame_count: 0,
825 stdout: BufWriter::new(io::stdout()),
826 })
827 }
828}
829
830impl VideoEncoder for RawEncoder {
831 fn write_frame(&mut self, pixel_buffer: &[RgbColor]) -> Result<(), VideoError> {
832 let expected_size = (self.width * self.height) as usize;
833 if pixel_buffer.len() != expected_size {
834 let got_height = pixel_buffer.len() / self.width as usize;
835 return Err(VideoError::InvalidDimensions {
836 expected: (self.width, self.height),
837 got: (self.width, got_height as u32),
838 });
839 }
840
841 for &RgbColor {
843 r,
844 g,
845 b,
846 } in pixel_buffer
847 {
848 self.stdout.write_all(&[b, g, r, 255])?;
849 }
850
851 self.frame_count += 1;
852 Ok(())
853 }
854
855 fn finish(&mut self) -> Result<(), VideoError> {
856 self.stdout.flush()?;
857 Ok(())
858 }
859
860 fn frames_written(&self) -> u64 { self.frame_count }
861}
862
863fn fps_to_rational(fps: f64) -> String {
875 const NES_TOLERANCE: f64 = 0.01;
879 const STANDARD_TOLERANCE: f64 = 0.001;
880
881 for multiplier in 1..=10 {
884 let target = NES_NTSC_FPS * multiplier as f64;
885 if (fps - target).abs() < NES_TOLERANCE {
886 let numerator = NES_NTSC_FPS_NUM * multiplier as u64;
887 return format!("{}/{}", numerator, NES_NTSC_FPS_DEN);
888 }
889 }
890
891 for multiplier in 1..=10 {
893 let target = SMOOTH_FPS * multiplier as f64;
894 if (fps - target).abs() < STANDARD_TOLERANCE {
895 return format!("{}/1", 60 * multiplier);
896 }
897 }
898
899 if (fps - 30.0).abs() < STANDARD_TOLERANCE {
901 return "30/1".to_string();
902 }
903 if (fps - 24.0).abs() < STANDARD_TOLERANCE {
904 return "24/1".to_string();
905 }
906 if (fps - 59.94).abs() < NES_TOLERANCE {
907 return "60000/1001".to_string(); }
909 if (fps - 29.97).abs() < NES_TOLERANCE {
910 return "30000/1001".to_string(); }
912 if (fps - 23.976).abs() < NES_TOLERANCE {
913 return "24000/1001".to_string(); }
915
916 let numerator = (fps * 1000.0).round() as u64;
919 format!("{}/1000", numerator)
920}
921
922pub fn encode_frames(
924 frames: &[Vec<RgbColor>],
925 format: VideoFormat,
926 output_path: &Path,
927 width: u32,
928 height: u32,
929 fps: f64,
930) -> Result<u64, VideoError> {
931 let mut encoder = create_encoder(format, output_path, width, height, fps)?;
932
933 for frame in frames {
934 encoder.write_frame(frame)?;
935 }
936
937 encoder.finish()?;
938 Ok(encoder.frames_written())
939}
940
941pub fn is_ffmpeg_available() -> bool {
943 Command::new("ffmpeg")
944 .arg("-version")
945 .output()
946 .map(|o| o.status.success())
947 .unwrap_or(false)
948}
949
950pub struct StreamingVideoEncoder {
962 encoder: Box<dyn VideoEncoder>,
963 src_width: u32,
964 src_height: u32,
965 dst_width: u32,
966 dst_height: u32,
967 fps_config: FpsConfig,
968}
969
970impl StreamingVideoEncoder {
971 pub fn with_fps_config(
976 format: VideoFormat,
977 output_path: &Path,
978 src_width: u32,
979 src_height: u32,
980 resolution: &VideoResolution,
981 fps_config: FpsConfig,
982 ) -> Result<Self, VideoError> {
983 let (dst_width, dst_height) = resolution.dimensions(src_width, src_height);
984 let fps = fps_config.output_fps();
985
986 let encoder: Box<dyn VideoEncoder> = match format {
987 VideoFormat::Mp4 => {
988 if dst_width != src_width || dst_height != src_height {
989 Box::new(FfmpegMp4Encoder::new(
991 output_path,
992 src_width,
993 src_height,
994 fps,
995 Some((dst_width, dst_height)),
996 )?)
997 } else {
998 Box::new(FfmpegMp4Encoder::new(
999 output_path,
1000 src_width,
1001 src_height,
1002 fps,
1003 None,
1004 )?)
1005 }
1006 }
1007 _ => {
1008 if dst_width != src_width || dst_height != src_height {
1010 eprintln!(
1011 "Warning: Scaling only supported for MP4 format. Using native resolution."
1012 );
1013 }
1014 create_encoder(format, output_path, src_width, src_height, fps)?
1015 }
1016 };
1017
1018 Ok(Self {
1019 encoder,
1020 src_width,
1021 src_height,
1022 dst_width,
1023 dst_height,
1024 fps_config,
1025 })
1026 }
1027
1028 pub fn write_frame(&mut self, frame: &[RgbColor]) -> Result<(), VideoError> {
1030 self.encoder.write_frame(frame)
1031 }
1032
1033 pub fn finish(&mut self) -> Result<(), VideoError> { self.encoder.finish() }
1035
1036 pub fn frames_written(&self) -> u64 { self.encoder.frames_written() }
1038
1039 pub fn source_dimensions(&self) -> (u32, u32) { (self.src_width, self.src_height) }
1041
1042 pub fn output_dimensions(&self) -> (u32, u32) { (self.dst_width, self.dst_height) }
1044
1045 pub fn is_scaling(&self) -> bool {
1047 self.dst_width != self.src_width || self.dst_height != self.src_height
1048 }
1049
1050 pub fn fps_config(&self) -> &FpsConfig { &self.fps_config }
1052
1053 pub fn needs_mid_frame_capture(&self) -> bool { self.fps_config.needs_mid_frame_capture() }
1055
1056 pub fn captures_per_frame(&self) -> u32 { self.fps_config.captures_per_frame() }
1058}
1059
1060#[cfg(test)]
1065mod tests {
1066 use super::*;
1067
1068 #[test]
1069 fn test_video_resolution_parse() {
1070 assert_eq!(
1071 VideoResolution::parse("native").unwrap(),
1072 VideoResolution::Native
1073 );
1074 assert_eq!(
1075 VideoResolution::parse("1x").unwrap(),
1076 VideoResolution::Native
1077 );
1078 assert_eq!(
1079 VideoResolution::parse("2x").unwrap(),
1080 VideoResolution::IntegerScale(2)
1081 );
1082 assert_eq!(
1083 VideoResolution::parse("4x").unwrap(),
1084 VideoResolution::IntegerScale(4)
1085 );
1086 assert_eq!(
1087 VideoResolution::parse("720p").unwrap(),
1088 VideoResolution::Hd720
1089 );
1090 assert_eq!(
1091 VideoResolution::parse("1080p").unwrap(),
1092 VideoResolution::Hd1080
1093 );
1094 assert_eq!(
1095 VideoResolution::parse("4k").unwrap(),
1096 VideoResolution::Uhd4k
1097 );
1098 assert_eq!(
1099 VideoResolution::parse("1920x1080").unwrap(),
1100 VideoResolution::Custom(1920, 1080)
1101 );
1102 }
1103
1104 #[test]
1105 fn test_video_resolution_dimensions() {
1106 assert_eq!(VideoResolution::Native.dimensions(256, 240), (256, 240));
1108
1109 assert_eq!(
1111 VideoResolution::IntegerScale(2).dimensions(256, 240),
1112 (512, 480)
1113 );
1114 assert_eq!(
1115 VideoResolution::IntegerScale(4).dimensions(256, 240),
1116 (1024, 960)
1117 );
1118
1119 let (w, h) = VideoResolution::Hd1080.dimensions(256, 240);
1121 assert!(w <= 1920);
1122 assert!(h <= 1080);
1123 }
1124
1125 #[test]
1126 fn test_video_error_display() {
1127 let err = VideoError::FfmpegNotFound;
1128 assert!(err.to_string().contains("FFmpeg not found"));
1129
1130 let err = VideoError::InvalidDimensions {
1131 expected: (256, 240),
1132 got: (128, 120),
1133 };
1134 assert!(err.to_string().contains("256x240"));
1135 assert!(err.to_string().contains("128x120"));
1136 }
1137
1138 #[test]
1139 fn test_png_encoder_frame_path() {
1140 let encoder = PngSequenceEncoder::new(Path::new("/tmp/test/frames"), 256, 240).unwrap();
1141 let path = encoder.frame_path(0);
1142 assert!(path.to_string_lossy().contains("frames_000000.png"));
1143
1144 let path = encoder.frame_path(42);
1145 assert!(path.to_string_lossy().contains("frames_000042.png"));
1146 }
1147
1148 #[test]
1149 fn test_ppm_encoder_frame_path() {
1150 let encoder = PpmSequenceEncoder::new(Path::new("/tmp/test/output"), 256, 240).unwrap();
1151 let path = encoder.frame_path(123);
1152 assert!(path.to_string_lossy().contains("output_000123.ppm"));
1153 }
1154
1155 #[test]
1156 fn test_ffmpeg_availability_check() { let _available = is_ffmpeg_available(); }
1157
1158 #[test]
1159 fn test_invalid_frame_dimensions() {
1160 let mut encoder =
1161 PpmSequenceEncoder::new(Path::new("/tmp/test_invalid"), 256, 240).unwrap();
1162
1163 let bad_frame: Vec<RgbColor> = vec![RgbColor::new(0, 0, 0); 100];
1164 let result = encoder.write_frame(&bad_frame);
1165
1166 assert!(result.is_err());
1167 if let Err(VideoError::InvalidDimensions {
1168 expected, ..
1169 }) = result
1170 {
1171 assert_eq!(expected, (256, 240));
1172 } else {
1173 panic!("Expected InvalidDimensions error");
1174 }
1175 }
1176
1177 #[test]
1178 fn test_fps_to_rational() {
1179 let nes_ntsc = 39375000.0 / 655171.0;
1181 assert_eq!(fps_to_rational(nes_ntsc), "39375000/655171");
1182
1183 assert_eq!(fps_to_rational(60.0), "60/1");
1185 assert_eq!(fps_to_rational(30.0), "30/1");
1186 assert_eq!(fps_to_rational(24.0), "24/1");
1187
1188 assert_eq!(fps_to_rational(59.94), "60000/1001");
1190 assert_eq!(fps_to_rational(29.97), "30000/1001");
1191 assert_eq!(fps_to_rational(23.976), "24000/1001");
1192
1193 assert_eq!(fps_to_rational(50.0), "50000/1000");
1195
1196 let nes_ntsc_2x = nes_ntsc * 2.0;
1198 assert_eq!(fps_to_rational(nes_ntsc_2x), "78750000/655171");
1199
1200 assert_eq!(fps_to_rational(120.0), "120/1");
1202 assert_eq!(fps_to_rational(180.0), "180/1");
1203 }
1204
1205 #[test]
1206 fn test_fps_config_parse_multipliers() {
1207 let config = FpsConfig::parse("1x", VideoExportMode::Accurate).unwrap();
1209 assert_eq!(config.multiplier, 1);
1210 assert_eq!(config.mode, VideoExportMode::Accurate);
1211
1212 let config = FpsConfig::parse("2x", VideoExportMode::Accurate).unwrap();
1213 assert_eq!(config.multiplier, 2);
1214
1215 let config = FpsConfig::parse("3x", VideoExportMode::Smooth).unwrap();
1216 assert_eq!(config.multiplier, 3);
1217 assert_eq!(config.mode, VideoExportMode::Smooth);
1218 }
1219
1220 #[test]
1221 fn test_fps_config_parse_fixed_values() {
1222 let config = FpsConfig::parse("60.0", VideoExportMode::Smooth).unwrap();
1224 assert_eq!(config.multiplier, 1); let config = FpsConfig::parse("120", VideoExportMode::Smooth).unwrap();
1227 assert_eq!(config.multiplier, 2); let config = FpsConfig::parse("60.0988", VideoExportMode::Accurate).unwrap();
1230 assert_eq!(config.multiplier, 1); let config = FpsConfig::parse("120.2", VideoExportMode::Accurate).unwrap();
1233 assert_eq!(config.multiplier, 2); }
1235
1236 #[test]
1237 fn test_fps_config_output_fps() {
1238 let config = FpsConfig::parse("1x", VideoExportMode::Accurate).unwrap();
1240 assert!((config.output_fps() - NES_NTSC_FPS).abs() < 0.001);
1241
1242 let config = FpsConfig::parse("1x", VideoExportMode::Smooth).unwrap();
1244 assert!((config.output_fps() - 60.0).abs() < 0.001);
1245
1246 let config = FpsConfig::parse("2x", VideoExportMode::Accurate).unwrap();
1248 assert!((config.output_fps() - NES_NTSC_FPS * 2.0).abs() < 0.001);
1249
1250 let config = FpsConfig::parse("2x", VideoExportMode::Smooth).unwrap();
1252 assert!((config.output_fps() - 120.0).abs() < 0.001);
1253 }
1254
1255 #[test]
1256 fn test_fps_config_output_rational() {
1257 let config = FpsConfig::parse("1x", VideoExportMode::Accurate).unwrap();
1259 assert_eq!(config.output_fps_rational(), "39375000/655171");
1260
1261 let config = FpsConfig::parse("1x", VideoExportMode::Smooth).unwrap();
1263 assert_eq!(config.output_fps_rational(), "60/1");
1264
1265 let config = FpsConfig::parse("2x", VideoExportMode::Accurate).unwrap();
1267 assert_eq!(config.output_fps_rational(), "78750000/655171");
1268
1269 let config = FpsConfig::parse("2x", VideoExportMode::Smooth).unwrap();
1271 assert_eq!(config.output_fps_rational(), "120/1");
1272 }
1273
1274 #[test]
1275 fn test_fps_config_parse_errors() {
1276 assert!(FpsConfig::parse("0x", VideoExportMode::Accurate).is_err());
1278 assert!(FpsConfig::parse("-1x", VideoExportMode::Accurate).is_err());
1279
1280 assert!(FpsConfig::parse("abc", VideoExportMode::Accurate).is_err());
1282 assert!(FpsConfig::parse("", VideoExportMode::Accurate).is_err());
1283
1284 assert!(FpsConfig::parse("-60", VideoExportMode::Accurate).is_err());
1286 }
1287
1288 #[test]
1289 fn test_fps_config_captures_per_frame() {
1290 let config = FpsConfig::parse("1x", VideoExportMode::Accurate).unwrap();
1291 assert_eq!(config.captures_per_frame(), 1);
1292 assert!(!config.needs_mid_frame_capture());
1293
1294 let config = FpsConfig::parse("2x", VideoExportMode::Accurate).unwrap();
1295 assert_eq!(config.captures_per_frame(), 2);
1296 assert!(config.needs_mid_frame_capture());
1297
1298 let config = FpsConfig::parse("3x", VideoExportMode::Smooth).unwrap();
1299 assert_eq!(config.captures_per_frame(), 3);
1300 assert!(config.needs_mid_frame_capture());
1301 }
1302}