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 \
86                         WxH",
87                        s
88                    ))
89                }
90            }
91        }
92    }
93
94    /// Calculate the output dimensions for a given source size.
95    ///
96    /// For preset resolutions (720p, 1080p, 4k), the output is scaled to fit
97    /// within the target while maintaining aspect ratio with the NES PAR (8:7).
98    pub fn dimensions(&self, src_width: u32, src_height: u32) -> (u32, u32) {
99        // NES pixel aspect ratio: 8:7 (pixels are slightly wider than tall)
100        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
113/// Fit source dimensions to target bounds while maintaining aspect ratio.
114fn 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    // Calculate the maximum integer scale that fits within bounds
122    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    // Use integer scale for clean pixel scaling
127    let int_scale = scale.floor() as u32;
128    let int_scale = int_scale.max(1); // At least 1x
129
130    // Calculate output dimensions
131    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    // Ensure dimensions are even (required for many video codecs)
135    let out_width = (out_width + 1) & !1;
136    let out_height = (out_height + 1) & !1;
137
138    (out_width, out_height)
139}
140
141// =============================================================================
142// FPS Configuration
143// =============================================================================
144
145use crate::cli::args::VideoExportMode;
146
147/// NES NTSC framerate: 39375000 / 655171 ≈ 60.098814
148pub const NES_NTSC_FPS: f64 = 39375000.0 / 655171.0;
149
150/// NES NTSC framerate as exact numerator/denominator
151pub const NES_NTSC_FPS_NUM: u64 = 39375000;
152pub const NES_NTSC_FPS_DEN: u64 = 655171;
153
154/// Smooth framerate target (exactly 60 fps)
155pub const SMOOTH_FPS: f64 = 60.0;
156
157/// Parsed FPS configuration.
158///
159/// This struct handles parsing of FPS strings (like "1x", "2x", "120") and
160/// calculates the appropriate capture rate and output framerate based on
161/// the video export mode.
162#[derive(Debug, Clone)]
163pub struct FpsConfig {
164    /// Multiplier for frame capture (1 = normal, 2 = capture twice per frame,
165    /// etc.)
166    pub multiplier: u32,
167    /// The export mode (accurate or smooth)
168    pub mode: VideoExportMode,
169}
170
171impl FpsConfig {
172    /// Parse an FPS string (e.g., "1x", "2x", "60", "120.0").
173    ///
174    /// - Multipliers like "1x", "2x", "3x" specify how often to sample the
175    ///   framebuffer
176    /// - Fixed values like "60" or "120.0" are converted to the nearest
177    ///   multiplier
178    pub fn parse(s: &str, mode: VideoExportMode) -> Result<Self, String> {
179        let s = s.trim().to_lowercase();
180
181        // Try parsing as multiplier (e.g., "1x", "2x", "3x")
182        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        // Try parsing as a fixed FPS value
196        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        // Convert fixed FPS to multiplier based on mode
208        let base_fps = match mode {
209            VideoExportMode::Accurate => NES_NTSC_FPS,
210            VideoExportMode::Smooth => SMOOTH_FPS,
211        };
212
213        // Calculate multiplier (round to nearest integer)
214        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    /// Get the output framerate as a floating-point value.
224    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    /// Get the output framerate as a rational string for FFmpeg.
232    ///
233    /// For accurate mode, this returns the exact NES framerate fraction
234    /// multiplied. For smooth mode, this returns clean integer multiples of
235    /// 60.
236    pub fn output_fps_rational(&self) -> String {
237        match self.mode {
238            VideoExportMode::Accurate => {
239                // Use exact rational: (39375000 * multiplier) / 655171
240                let numerator = NES_NTSC_FPS_NUM * self.multiplier as u64;
241                format!("{}/{}", numerator, NES_NTSC_FPS_DEN)
242            }
243            VideoExportMode::Smooth => {
244                // Clean integer FPS
245                let fps = 60 * self.multiplier;
246                format!("{}/1", fps)
247            }
248        }
249    }
250
251    /// Get the number of frames to capture per PPU frame.
252    ///
253    /// For 1x, this is 1 (capture once per complete frame).
254    /// For 2x, this is 2 (capture at mid-frame and end of frame).
255    /// For 3x, this is 3 (capture at 1/3, 2/3, and end of frame).
256    pub fn captures_per_frame(&self) -> u32 { self.multiplier }
257
258    /// Check if this configuration requires mid-frame captures.
259    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// =============================================================================
272// Error Types
273// =============================================================================
274
275/// Video encoding error
276#[derive(Debug)]
277pub enum VideoError {
278    /// FFmpeg is not installed or not found in PATH
279    FfmpegNotFound,
280    /// FFmpeg process failed
281    FfmpegFailed(String),
282    /// I/O error
283    IoError(io::Error),
284    /// Image encoding error
285    ImageError(String),
286    /// Invalid frame dimensions
287    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
343// =============================================================================
344// Video Encoder Trait
345// =============================================================================
346
347/// Trait for video encoders.
348///
349/// Implement this trait to add support for new video formats.
350pub trait VideoEncoder: Send {
351    /// Write a frame to the video.
352    ///
353    /// The pixel buffer is in RGB format as (R, G, B) tuples,
354    /// as a flat array of width × height values.
355    fn write_frame(&mut self, pixel_buffer: &[RgbColor]) -> Result<(), VideoError>;
356
357    /// Finish encoding and flush any remaining data.
358    fn finish(&mut self) -> Result<(), VideoError>;
359
360    /// Get the number of frames written so far.
361    fn frames_written(&self) -> u64;
362}
363
364// =============================================================================
365// Factory Function
366// =============================================================================
367
368/// Create a video encoder for the specified format.
369///
370/// For MP4 format with scaling, use `create_encoder_with_scale` instead.
371pub 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
400/// Create an MP4 encoder with FFmpeg native nearest-neighbor scaling.
401///
402/// This passes scaling to FFmpeg using `-vf scale=W:H:flags=neighbor`,
403/// which is efficient and produces sharp pixel edges.
404pub 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
421// =============================================================================
422// PNG Sequence Encoder
423// =============================================================================
424
425/// Encoder that outputs a sequence of PNG images.
426pub struct PngSequenceEncoder {
427    base_path: PathBuf,
428    width: u32,
429    height: u32,
430    frame_count: u64,
431}
432
433impl PngSequenceEncoder {
434    /// Create a new PNG sequence encoder.
435    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
492// =============================================================================
493// PPM Sequence Encoder
494// =============================================================================
495
496/// Encoder that outputs a sequence of PPM images.
497pub struct PpmSequenceEncoder {
498    base_path: PathBuf,
499    width: u32,
500    height: u32,
501    frame_count: u64,
502}
503
504impl PpmSequenceEncoder {
505    /// Create a new PPM sequence encoder.
506    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
573// =============================================================================
574// FFmpeg MP4 Encoder
575// =============================================================================
576
577/// Encoder that pipes frames to FFmpeg for MP4 encoding.
578///
579/// Supports native nearest-neighbor scaling via FFmpeg's scale filter.
580pub 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    /// Create a new FFmpeg MP4 encoder.
591    ///
592    /// If `scale_to` is provided, FFmpeg will scale to that resolution using
593    /// nearest-neighbor interpolation (sharp pixel edges).
594    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        // Check if ffmpeg exists
602        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        // Create output directory if needed
613        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        // Convert FPS to a precise fractional representation for FFmpeg.
626        // This avoids frame timing drift caused by floating-point approximations.
627        // For the NES NTSC framerate (39375000/655171 ≈ 60.0988), we use the exact
628        // fraction.
629        let fps_str = fps_to_rational(fps);
630
631        // Build FFmpeg arguments
632        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        // Add scaling filter if requested (nearest neighbor for sharp pixels)
647        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        // Encoder settings
661        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        // Convert RGB to BGRA
720        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        // Close stdin to signal EOF to ffmpeg
755        self.stdin.take();
756
757        // Wait for ffmpeg to finish
758        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        // Clean up stderr file
779        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        // Ensure stdin is closed
792        self.stdin.take();
793
794        // Try to wait for child process
795        if let Some(mut child) = self.child.take() {
796            let _ = child.wait();
797        }
798
799        // Clean up stderr file
800        if let Some(ref path) = self.stderr_path {
801            let _ = fs::remove_file(path);
802        }
803    }
804}
805
806// =============================================================================
807// Raw Encoder
808// =============================================================================
809
810/// Encoder that outputs raw BGRA frames.
811pub struct RawEncoder {
812    width: u32,
813    height: u32,
814    frame_count: u64,
815    stdout: BufWriter<io::Stdout>,
816}
817
818impl RawEncoder {
819    /// Create a new raw encoder.
820    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        // Convert RGB to BGRA and write to stdout
842        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
863// =============================================================================
864// Helper Functions
865// =============================================================================
866
867/// Convert FPS to a rational string representation for FFmpeg.
868///
869/// This function converts floating-point FPS values to precise fractional
870/// representations to avoid frame timing drift in video encoding.
871///
872/// Known framerates (like NES NTSC 60.0988 FPS) are converted to their
873/// exact rational form. Other values use a high-precision approximation.
874fn fps_to_rational(fps: f64) -> String {
875    // Tolerance values:
876    // - NES NTSC: 0.01 because the irrational framerate may have rounding errors
877    // - Smooth/standard: 0.001 for clean integer framerates
878    const NES_TOLERANCE: f64 = 0.01;
879    const STANDARD_TOLERANCE: f64 = 0.001;
880
881    // Check for NES NTSC framerate and its multiples (within tolerance)
882    // NES NTSC framerate: 39375000 / 655171 ≈ 60.098814
883    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    // Check for smooth framerate multiples (60, 120, 180, etc.)
892    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    // Check for other common standard framerates
900    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(); // NTSC video standard
908    }
909    if (fps - 29.97).abs() < NES_TOLERANCE {
910        return "30000/1001".to_string(); // NTSC video standard
911    }
912    if (fps - 23.976).abs() < NES_TOLERANCE {
913        return "24000/1001".to_string(); // Film standard
914    }
915
916    // For other framerates, use a high-precision rational approximation
917    // by multiplying by 1000 and rounding to get integer numerator
918    let numerator = (fps * 1000.0).round() as u64;
919    format!("{}/1000", numerator)
920}
921
922/// Encode collected frames to video.
923pub 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
941/// Check if FFmpeg is available for MP4 encoding.
942pub 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
950// =============================================================================
951// Streaming Video Encoder
952// =============================================================================
953
954/// A streaming video encoder that handles scaling via FFmpeg.
955///
956/// This encoder is designed for use during emulation - frames are written
957/// immediately as they are generated, without buffering all frames in memory.
958///
959/// Scaling is handled natively by FFmpeg using nearest-neighbor interpolation,
960/// which is efficient and produces sharp pixel edges.
961pub 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    /// Create a new streaming encoder with explicit FPS configuration.
972    ///
973    /// This is the preferred constructor when using the new FPS multiplier
974    /// system.
975    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                    // Use FFmpeg native scaling
990                    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                // For non-MP4 formats, no scaling (use native resolution)
1009                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    /// Write a single frame.
1029    pub fn write_frame(&mut self, frame: &[RgbColor]) -> Result<(), VideoError> {
1030        self.encoder.write_frame(frame)
1031    }
1032
1033    /// Finish encoding and finalize the output file.
1034    pub fn finish(&mut self) -> Result<(), VideoError> { self.encoder.finish() }
1035
1036    /// Get the number of frames written so far.
1037    pub fn frames_written(&self) -> u64 { self.encoder.frames_written() }
1038
1039    /// Get the source dimensions.
1040    pub fn source_dimensions(&self) -> (u32, u32) { (self.src_width, self.src_height) }
1041
1042    /// Get the output dimensions (after scaling).
1043    pub fn output_dimensions(&self) -> (u32, u32) { (self.dst_width, self.dst_height) }
1044
1045    /// Check if scaling is enabled.
1046    pub fn is_scaling(&self) -> bool {
1047        self.dst_width != self.src_width || self.dst_height != self.src_height
1048    }
1049
1050    /// Get the FPS configuration.
1051    pub fn fps_config(&self) -> &FpsConfig { &self.fps_config }
1052
1053    /// Check if mid-frame captures are needed.
1054    pub fn needs_mid_frame_capture(&self) -> bool { self.fps_config.needs_mid_frame_capture() }
1055
1056    /// Get the number of captures per PPU frame.
1057    pub fn captures_per_frame(&self) -> u32 { self.fps_config.captures_per_frame() }
1058}
1059
1060// =============================================================================
1061// Tests
1062// =============================================================================
1063
1064#[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        // Native
1107        assert_eq!(VideoResolution::Native.dimensions(256, 240), (256, 240));
1108
1109        // Integer scale
1110        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        // 1080p should fit within bounds
1120        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        // NES NTSC framerate (39375000 / 655171)
1180        let nes_ntsc = 39375000.0 / 655171.0;
1181        assert_eq!(fps_to_rational(nes_ntsc), "39375000/655171");
1182
1183        // Standard framerates
1184        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        // NTSC video standards
1189        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        // Custom framerate (fallback to x/1000)
1194        assert_eq!(fps_to_rational(50.0), "50000/1000");
1195
1196        // NES NTSC multiples (accurate mode)
1197        let nes_ntsc_2x = nes_ntsc * 2.0;
1198        assert_eq!(fps_to_rational(nes_ntsc_2x), "78750000/655171");
1199
1200        // Smooth mode multiples
1201        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        // Parse multipliers
1208        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        // Parse fixed FPS values - should convert to multipliers
1223        let config = FpsConfig::parse("60.0", VideoExportMode::Smooth).unwrap();
1224        assert_eq!(config.multiplier, 1); // 60/60 = 1x
1225
1226        let config = FpsConfig::parse("120", VideoExportMode::Smooth).unwrap();
1227        assert_eq!(config.multiplier, 2); // 120/60 = 2x
1228
1229        let config = FpsConfig::parse("60.0988", VideoExportMode::Accurate).unwrap();
1230        assert_eq!(config.multiplier, 1); // ~60.0988/60.0988 = 1x
1231
1232        let config = FpsConfig::parse("120.2", VideoExportMode::Accurate).unwrap();
1233        assert_eq!(config.multiplier, 2); // ~120.2/60.0988 ≈ 2x
1234    }
1235
1236    #[test]
1237    fn test_fps_config_output_fps() {
1238        // Accurate mode at 1x
1239        let config = FpsConfig::parse("1x", VideoExportMode::Accurate).unwrap();
1240        assert!((config.output_fps() - NES_NTSC_FPS).abs() < 0.001);
1241
1242        // Smooth mode at 1x
1243        let config = FpsConfig::parse("1x", VideoExportMode::Smooth).unwrap();
1244        assert!((config.output_fps() - 60.0).abs() < 0.001);
1245
1246        // Accurate mode at 2x
1247        let config = FpsConfig::parse("2x", VideoExportMode::Accurate).unwrap();
1248        assert!((config.output_fps() - NES_NTSC_FPS * 2.0).abs() < 0.001);
1249
1250        // Smooth mode at 2x
1251        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        // Accurate mode at 1x - exact NES framerate
1258        let config = FpsConfig::parse("1x", VideoExportMode::Accurate).unwrap();
1259        assert_eq!(config.output_fps_rational(), "39375000/655171");
1260
1261        // Smooth mode at 1x - clean 60fps
1262        let config = FpsConfig::parse("1x", VideoExportMode::Smooth).unwrap();
1263        assert_eq!(config.output_fps_rational(), "60/1");
1264
1265        // Accurate mode at 2x
1266        let config = FpsConfig::parse("2x", VideoExportMode::Accurate).unwrap();
1267        assert_eq!(config.output_fps_rational(), "78750000/655171");
1268
1269        // Smooth mode at 2x
1270        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        // Invalid multiplier
1277        assert!(FpsConfig::parse("0x", VideoExportMode::Accurate).is_err());
1278        assert!(FpsConfig::parse("-1x", VideoExportMode::Accurate).is_err());
1279
1280        // Invalid value
1281        assert!(FpsConfig::parse("abc", VideoExportMode::Accurate).is_err());
1282        assert!(FpsConfig::parse("", VideoExportMode::Accurate).is_err());
1283
1284        // Negative FPS
1285        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}