Skip to main content

monsoon_cli/cli/
video.rs

1//! Video export module for the NES emulator CLI.
2//!
3//! This module provides video encoding capabilities for exporting emulator
4//! frame buffers to various video formats.
5//!
6//! # Supported Formats
7//!
8//! | Format | Implementation | Dependencies |
9//! |--------|---------------|--------------|
10//! | PNG sequence | Pure Rust (image crate) | None - self-contained |
11//! | PPM sequence | Pure Rust | None - self-contained |
12//! | MP4 | FFmpeg subprocess | FFmpeg installed |
13//! | Raw | Writes raw BGRA bytes | None |
14//!
15//! # Scaling
16//!
17//! Video scaling is handled natively by FFmpeg using the nearest neighbor
18//! filter, which preserves sharp pixel edges for retro games.
19
20use 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// =============================================================================
31// Video Resolution
32// =============================================================================
33
34/// Target video resolution for output.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum VideoResolution {
37    /// Native resolution (256x240 for NES)
38    Native,
39    /// Integer scale (2x, 3x, 4x, etc.)
40    IntegerScale(u32),
41    /// 720p (1280x720) - fit within bounds
42    Hd720,
43    /// 1080p (1920x1080) - fit within bounds
44    Hd1080,
45    /// 4K (3840x2160) - fit within bounds
46    Uhd4k,
47    /// Custom resolution
48    Custom(u32, u32),
49}
50
51impl VideoResolution {
52    /// Parse a video resolution string.
53    ///
54    /// Supported formats:
55    /// - "native" - Native resolution
56    /// - "2x", "3x", "4x" - Integer scales
57    /// - "720p", "1080p", "4k" - Standard resolutions
58    /// - "WxH" or "WIDTHxHEIGHT" - Custom resolution (e.g., "1920x1080")
59    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                // Try to parse as WxH
73                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    /// Calculate the output dimensions for a given source size.
94    ///
95    /// For preset resolutions (720p, 1080p, 4k), the output is scaled to fit
96    /// within the target while maintaining aspect ratio with the NES PAR (8:7).
97    pub fn dimensions(&self, src_width: u32, src_height: u32) -> (u32, u32) {
98        // NES pixel aspect ratio: 8:7 (pixels are slightly wider than tall)
99        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
112/// Fit source dimensions to target bounds while maintaining aspect ratio.
113fn 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    // Calculate the maximum integer scale that fits within bounds
121    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    // Use integer scale for clean pixel scaling
126    let int_scale = scale.floor() as u32;
127    let int_scale = int_scale.max(1); // At least 1x
128
129    // Calculate output dimensions
130    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    // Ensure dimensions are even (required for many video codecs)
134    let out_width = (out_width + 1) & !1;
135    let out_height = (out_height + 1) & !1;
136
137    (out_width, out_height)
138}
139
140// =============================================================================
141// FPS Configuration
142// =============================================================================
143
144use crate::cli::args::VideoExportMode;
145
146/// NES NTSC framerate: 39375000 / 655171 ≈ 60.098814
147pub const NES_NTSC_FPS: f64 = 39375000.0 / 655171.0;
148
149/// NES NTSC framerate as exact numerator/denominator
150pub const NES_NTSC_FPS_NUM: u64 = 39375000;
151pub const NES_NTSC_FPS_DEN: u64 = 655171;
152
153/// Smooth framerate target (exactly 60 fps)
154pub const SMOOTH_FPS: f64 = 60.0;
155
156/// Parsed FPS configuration.
157///
158/// This struct handles parsing of FPS strings (like "1x", "2x", "120") and
159/// calculates the appropriate capture rate and output framerate based on
160/// the video export mode.
161#[derive(Debug, Clone)]
162pub struct FpsConfig {
163    /// Multiplier for frame capture (1 = normal, 2 = capture twice per frame, etc.)
164    pub multiplier: u32,
165    /// The export mode (accurate or smooth)
166    pub mode: VideoExportMode,
167}
168
169impl FpsConfig {
170    /// Parse an FPS string (e.g., "1x", "2x", "60", "120.0").
171    ///
172    /// - Multipliers like "1x", "2x", "3x" specify how often to sample the framebuffer
173    /// - Fixed values like "60" or "120.0" are converted to the nearest multiplier
174    pub fn parse(s: &str, mode: VideoExportMode) -> Result<Self, String> {
175        let s = s.trim().to_lowercase();
176
177        // Try parsing as multiplier (e.g., "1x", "2x", "3x")
178        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        // Try parsing as a fixed FPS value
192        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        // Convert fixed FPS to multiplier based on mode
204        let base_fps = match mode {
205            VideoExportMode::Accurate => NES_NTSC_FPS,
206            VideoExportMode::Smooth => SMOOTH_FPS,
207        };
208
209        // Calculate multiplier (round to nearest integer)
210        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    /// Get the output framerate as a floating-point value.
220    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    /// Get the output framerate as a rational string for FFmpeg.
228    ///
229    /// For accurate mode, this returns the exact NES framerate fraction multiplied.
230    /// For smooth mode, this returns clean integer multiples of 60.
231    pub fn output_fps_rational(&self) -> String {
232        match self.mode {
233            VideoExportMode::Accurate => {
234                // Use exact rational: (39375000 * multiplier) / 655171
235                let numerator = NES_NTSC_FPS_NUM * self.multiplier as u64;
236                format!("{}/{}", numerator, NES_NTSC_FPS_DEN)
237            }
238            VideoExportMode::Smooth => {
239                // Clean integer FPS
240                let fps = 60 * self.multiplier;
241                format!("{}/1", fps)
242            }
243        }
244    }
245
246    /// Get the number of frames to capture per PPU frame.
247    ///
248    /// For 1x, this is 1 (capture once per complete frame).
249    /// For 2x, this is 2 (capture at mid-frame and end of frame).
250    /// For 3x, this is 3 (capture at 1/3, 2/3, and end of frame).
251    pub fn captures_per_frame(&self) -> u32 { self.multiplier }
252
253    /// Check if this configuration requires mid-frame captures.
254    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// =============================================================================
267// Error Types
268// =============================================================================
269
270/// Video encoding error
271#[derive(Debug)]
272pub enum VideoError {
273    /// FFmpeg is not installed or not found in PATH
274    FfmpegNotFound,
275    /// FFmpeg process failed
276    FfmpegFailed(String),
277    /// I/O error
278    IoError(io::Error),
279    /// Image encoding error
280    ImageError(String),
281    /// Invalid frame dimensions
282    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
337// =============================================================================
338// Video Encoder Trait
339// =============================================================================
340
341/// Trait for video encoders.
342///
343/// Implement this trait to add support for new video formats.
344pub trait VideoEncoder: Send {
345    /// Write a frame to the video.
346    ///
347    /// The pixel buffer is in RGB format as (R, G, B) tuples,
348    /// as a flat array of width × height values.
349    fn write_frame(&mut self, pixel_buffer: &[RgbColor]) -> Result<(), VideoError>;
350
351    /// Finish encoding and flush any remaining data.
352    fn finish(&mut self) -> Result<(), VideoError>;
353
354    /// Get the number of frames written so far.
355    fn frames_written(&self) -> u64;
356}
357
358// =============================================================================
359// Factory Function
360// =============================================================================
361
362/// Create a video encoder for the specified format.
363///
364/// For MP4 format with scaling, use `create_encoder_with_scale` instead.
365pub 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
394/// Create an MP4 encoder with FFmpeg native nearest-neighbor scaling.
395///
396/// This passes scaling to FFmpeg using `-vf scale=W:H:flags=neighbor`,
397/// which is efficient and produces sharp pixel edges.
398pub 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
415// =============================================================================
416// PNG Sequence Encoder
417// =============================================================================
418
419/// Encoder that outputs a sequence of PNG images.
420pub struct PngSequenceEncoder {
421    base_path: PathBuf,
422    width: u32,
423    height: u32,
424    frame_count: u64,
425}
426
427impl PngSequenceEncoder {
428    /// Create a new PNG sequence encoder.
429    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
486// =============================================================================
487// PPM Sequence Encoder
488// =============================================================================
489
490/// Encoder that outputs a sequence of PPM images.
491pub struct PpmSequenceEncoder {
492    base_path: PathBuf,
493    width: u32,
494    height: u32,
495    frame_count: u64,
496}
497
498impl PpmSequenceEncoder {
499    /// Create a new PPM sequence encoder.
500    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
567// =============================================================================
568// FFmpeg MP4 Encoder
569// =============================================================================
570
571/// Encoder that pipes frames to FFmpeg for MP4 encoding.
572///
573/// Supports native nearest-neighbor scaling via FFmpeg's scale filter.
574pub 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    /// Create a new FFmpeg MP4 encoder.
585    ///
586    /// If `scale_to` is provided, FFmpeg will scale to that resolution using
587    /// nearest-neighbor interpolation (sharp pixel edges).
588    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        // Check if ffmpeg exists
596        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        // Create output directory if needed
607        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        // Convert FPS to a precise fractional representation for FFmpeg.
620        // This avoids frame timing drift caused by floating-point approximations.
621        // For the NES NTSC framerate (39375000/655171 ≈ 60.0988), we use the exact fraction.
622        let fps_str = fps_to_rational(fps);
623
624        // Build FFmpeg arguments
625        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        // Add scaling filter if requested (nearest neighbor for sharp pixels)
640        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        // Encoder settings
654        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        // Convert RGB to BGRA
713        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        // Close stdin to signal EOF to ffmpeg
748        self.stdin.take();
749
750        // Wait for ffmpeg to finish
751        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        // Clean up stderr file
772        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        // Ensure stdin is closed
785        self.stdin.take();
786
787        // Try to wait for child process
788        if let Some(mut child) = self.child.take() {
789            let _ = child.wait();
790        }
791
792        // Clean up stderr file
793        if let Some(ref path) = self.stderr_path {
794            let _ = fs::remove_file(path);
795        }
796    }
797}
798
799// =============================================================================
800// Raw Encoder
801// =============================================================================
802
803/// Encoder that outputs raw BGRA frames.
804pub struct RawEncoder {
805    width: u32,
806    height: u32,
807    frame_count: u64,
808    stdout: BufWriter<io::Stdout>,
809}
810
811impl RawEncoder {
812    /// Create a new raw encoder.
813    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        // Convert RGB to BGRA and write to stdout
835        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
856// =============================================================================
857// Helper Functions
858// =============================================================================
859
860/// Convert FPS to a rational string representation for FFmpeg.
861///
862/// This function converts floating-point FPS values to precise fractional
863/// representations to avoid frame timing drift in video encoding.
864///
865/// Known framerates (like NES NTSC 60.0988 FPS) are converted to their
866/// exact rational form. Other values use a high-precision approximation.
867fn fps_to_rational(fps: f64) -> String {
868    // Tolerance values:
869    // - NES NTSC: 0.01 because the irrational framerate may have rounding errors
870    // - Smooth/standard: 0.001 for clean integer framerates
871    const NES_TOLERANCE: f64 = 0.01;
872    const STANDARD_TOLERANCE: f64 = 0.001;
873
874    // Check for NES NTSC framerate and its multiples (within tolerance)
875    // NES NTSC framerate: 39375000 / 655171 ≈ 60.098814
876    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    // Check for smooth framerate multiples (60, 120, 180, etc.)
885    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    // Check for other common standard framerates
893    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(); // NTSC video standard
901    }
902    if (fps - 29.97).abs() < NES_TOLERANCE {
903        return "30000/1001".to_string(); // NTSC video standard
904    }
905    if (fps - 23.976).abs() < NES_TOLERANCE {
906        return "24000/1001".to_string(); // Film standard
907    }
908
909    // For other framerates, use a high-precision rational approximation
910    // by multiplying by 1000 and rounding to get integer numerator
911    let numerator = (fps * 1000.0).round() as u64;
912    format!("{}/1000", numerator)
913}
914
915/// Encode collected frames to video.
916pub 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
934/// Check if FFmpeg is available for MP4 encoding.
935pub 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
943// =============================================================================
944// Streaming Video Encoder
945// =============================================================================
946
947/// A streaming video encoder that handles scaling via FFmpeg.
948///
949/// This encoder is designed for use during emulation - frames are written
950/// immediately as they are generated, without buffering all frames in memory.
951///
952/// Scaling is handled natively by FFmpeg using nearest-neighbor interpolation,
953/// which is efficient and produces sharp pixel edges.
954pub 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    /// Create a new streaming encoder with explicit FPS configuration.
965    ///
966    /// This is the preferred constructor when using the new FPS multiplier system.
967    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                    // Use FFmpeg native scaling
982                    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                // For non-MP4 formats, no scaling (use native resolution)
1001                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    /// Write a single frame.
1021    pub fn write_frame(&mut self, frame: &[RgbColor]) -> Result<(), VideoError> {
1022        self.encoder.write_frame(frame)
1023    }
1024
1025    /// Finish encoding and finalize the output file.
1026    pub fn finish(&mut self) -> Result<(), VideoError> { self.encoder.finish() }
1027
1028    /// Get the number of frames written so far.
1029    pub fn frames_written(&self) -> u64 { self.encoder.frames_written() }
1030
1031    /// Get the source dimensions.
1032    pub fn source_dimensions(&self) -> (u32, u32) { (self.src_width, self.src_height) }
1033
1034    /// Get the output dimensions (after scaling).
1035    pub fn output_dimensions(&self) -> (u32, u32) { (self.dst_width, self.dst_height) }
1036
1037    /// Check if scaling is enabled.
1038    pub fn is_scaling(&self) -> bool {
1039        self.dst_width != self.src_width || self.dst_height != self.src_height
1040    }
1041
1042    /// Get the FPS configuration.
1043    pub fn fps_config(&self) -> &FpsConfig { &self.fps_config }
1044
1045    /// Check if mid-frame captures are needed.
1046    pub fn needs_mid_frame_capture(&self) -> bool { self.fps_config.needs_mid_frame_capture() }
1047
1048    /// Get the number of captures per PPU frame.
1049    pub fn captures_per_frame(&self) -> u32 { self.fps_config.captures_per_frame() }
1050}
1051
1052// =============================================================================
1053// Tests
1054// =============================================================================
1055
1056#[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        // Native
1099        assert_eq!(VideoResolution::Native.dimensions(256, 240), (256, 240));
1100
1101        // Integer scale
1102        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        // 1080p should fit within bounds
1112        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        // NES NTSC framerate (39375000 / 655171)
1172        let nes_ntsc = 39375000.0 / 655171.0;
1173        assert_eq!(fps_to_rational(nes_ntsc), "39375000/655171");
1174
1175        // Standard framerates
1176        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        // NTSC video standards
1181        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        // Custom framerate (fallback to x/1000)
1186        assert_eq!(fps_to_rational(50.0), "50000/1000");
1187
1188        // NES NTSC multiples (accurate mode)
1189        let nes_ntsc_2x = nes_ntsc * 2.0;
1190        assert_eq!(fps_to_rational(nes_ntsc_2x), "78750000/655171");
1191
1192        // Smooth mode multiples
1193        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        // Parse multipliers
1200        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        // Parse fixed FPS values - should convert to multipliers
1215        let config = FpsConfig::parse("60.0", VideoExportMode::Smooth).unwrap();
1216        assert_eq!(config.multiplier, 1); // 60/60 = 1x
1217
1218        let config = FpsConfig::parse("120", VideoExportMode::Smooth).unwrap();
1219        assert_eq!(config.multiplier, 2); // 120/60 = 2x
1220
1221        let config = FpsConfig::parse("60.0988", VideoExportMode::Accurate).unwrap();
1222        assert_eq!(config.multiplier, 1); // ~60.0988/60.0988 = 1x
1223
1224        let config = FpsConfig::parse("120.2", VideoExportMode::Accurate).unwrap();
1225        assert_eq!(config.multiplier, 2); // ~120.2/60.0988 ≈ 2x
1226    }
1227
1228    #[test]
1229    fn test_fps_config_output_fps() {
1230        // Accurate mode at 1x
1231        let config = FpsConfig::parse("1x", VideoExportMode::Accurate).unwrap();
1232        assert!((config.output_fps() - NES_NTSC_FPS).abs() < 0.001);
1233
1234        // Smooth mode at 1x
1235        let config = FpsConfig::parse("1x", VideoExportMode::Smooth).unwrap();
1236        assert!((config.output_fps() - 60.0).abs() < 0.001);
1237
1238        // Accurate mode at 2x
1239        let config = FpsConfig::parse("2x", VideoExportMode::Accurate).unwrap();
1240        assert!((config.output_fps() - NES_NTSC_FPS * 2.0).abs() < 0.001);
1241
1242        // Smooth mode at 2x
1243        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        // Accurate mode at 1x - exact NES framerate
1250        let config = FpsConfig::parse("1x", VideoExportMode::Accurate).unwrap();
1251        assert_eq!(config.output_fps_rational(), "39375000/655171");
1252
1253        // Smooth mode at 1x - clean 60fps
1254        let config = FpsConfig::parse("1x", VideoExportMode::Smooth).unwrap();
1255        assert_eq!(config.output_fps_rational(), "60/1");
1256
1257        // Accurate mode at 2x
1258        let config = FpsConfig::parse("2x", VideoExportMode::Accurate).unwrap();
1259        assert_eq!(config.output_fps_rational(), "78750000/655171");
1260
1261        // Smooth mode at 2x
1262        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        // Invalid multiplier
1269        assert!(FpsConfig::parse("0x", VideoExportMode::Accurate).is_err());
1270        assert!(FpsConfig::parse("-1x", VideoExportMode::Accurate).is_err());
1271
1272        // Invalid value
1273        assert!(FpsConfig::parse("abc", VideoExportMode::Accurate).is_err());
1274        assert!(FpsConfig::parse("", VideoExportMode::Accurate).is_err());
1275
1276        // Negative FPS
1277        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}