Skip to main content

ferrisgrid_export/
lib.rs

1use ferrisgrid_core::{ErrorKind, FerrisError, Result};
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7pub struct RecapResult {
8    pub session_dir: PathBuf,
9    pub recap_path: PathBuf,
10    pub frame_count: usize,
11    pub video_path: Option<PathBuf>,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum VideoFormat {
16    Mp4,
17}
18
19impl VideoFormat {
20    pub fn parse(value: &str) -> Result<Self> {
21        match value {
22            "mp4" => Ok(Self::Mp4),
23            other => Err(FerrisError::new(
24                ErrorKind::Protocol,
25                format!("unsupported video format: {other}"),
26            )),
27        }
28    }
29}
30
31#[derive(Debug, Clone, Copy, Default)]
32pub struct RecapOptions {
33    pub video: Option<VideoFormat>,
34    pub framerate: u32,
35}
36
37pub fn recap(session_dir: &Path) -> Result<RecapResult> {
38    recap_with_options(session_dir, RecapOptions::default())
39}
40
41pub fn recap_with_options(session_dir: &Path, mut options: RecapOptions) -> Result<RecapResult> {
42    if !session_dir.exists() {
43        return Err(FerrisError::new(
44            ErrorKind::Storage,
45            format!("session path not found: {}", session_dir.display()),
46        ));
47    }
48    if options.framerate == 0 {
49        options.framerate = 2;
50    }
51    let export_dir = session_dir.join("export");
52    fs::create_dir_all(&export_dir)?;
53    let frame_count = count_frame_dirs(session_dir)?;
54    let recap_path = export_dir.join("recap.md");
55    let video_path = match options.video {
56        Some(VideoFormat::Mp4) => Some(export_mp4(session_dir, &export_dir, options.framerate)?),
57        None => None,
58    };
59    let video_line = video_path
60        .as_ref()
61        .map(|path| format!("- video: {}\n", path.display()))
62        .unwrap_or_default();
63    fs::write(
64        &recap_path,
65        format!(
66            "## FerrisGrid Recap\n- session: {}\n- frames: {}\n- recap: {}\n{}",
67            session_dir.display(),
68            frame_count,
69            recap_path.display(),
70            video_line
71        ),
72    )?;
73    Ok(RecapResult {
74        session_dir: session_dir.to_path_buf(),
75        recap_path,
76        frame_count,
77        video_path,
78    })
79}
80
81pub fn render_recap(result: &RecapResult) -> String {
82    let video_line = result
83        .video_path
84        .as_ref()
85        .map(|path| format!("- video: {}\n", path.display()))
86        .unwrap_or_default();
87    format!(
88        "## FerrisGrid Recap\n- session: {}\n- frames: {}\n- recap: {}\n{}",
89        result.session_dir.display(),
90        result.frame_count,
91        result.recap_path.display(),
92        video_line
93    )
94}
95
96fn count_frame_dirs(session_dir: &Path) -> Result<usize> {
97    let frames = session_dir.join("frames");
98    if !frames.exists() {
99        return Ok(0);
100    }
101    let mut count = 0;
102    for entry in fs::read_dir(frames)? {
103        if entry?.file_type()?.is_dir() {
104            count += 1;
105        }
106    }
107    Ok(count)
108}
109
110fn export_mp4(session_dir: &Path, export_dir: &Path, framerate: u32) -> Result<PathBuf> {
111    let frame_name = first_frame_file_name(session_dir)?;
112    let input_pattern = session_dir
113        .join("frames")
114        .join("*")
115        .join(&frame_name)
116        .display()
117        .to_string();
118    let output_path = export_dir.join("session.mp4");
119    let output = Command::new("ffmpeg")
120        .arg("-y")
121        .arg("-framerate")
122        .arg(framerate.to_string())
123        .arg("-pattern_type")
124        .arg("glob")
125        .arg("-i")
126        .arg(&input_pattern)
127        .arg("-vf")
128        .arg("scale=trunc(iw/2)*2:trunc(ih/2)*2")
129        .arg("-c:v")
130        .arg("libx264")
131        .arg("-pix_fmt")
132        .arg("yuv420p")
133        .arg(&output_path)
134        .output()
135        .map_err(ffmpeg_error)?;
136    if !output.status.success() {
137        let stderr = String::from_utf8_lossy(&output.stderr);
138        return Err(FerrisError::new(
139            ErrorKind::Execution,
140            format!("ffmpeg failed: {}", stderr.trim()),
141        ));
142    }
143    Ok(output_path)
144}
145
146fn ffmpeg_error(error: io::Error) -> FerrisError {
147    if error.kind() == io::ErrorKind::NotFound {
148        return FerrisError::new(
149            ErrorKind::Execution,
150            "ffmpeg not found; install it with `brew install ffmpeg` or ensure ffmpeg is on PATH",
151        );
152    }
153    FerrisError::new(
154        ErrorKind::Execution,
155        format!("failed to run ffmpeg: {error}"),
156    )
157}
158
159fn first_frame_file_name(session_dir: &Path) -> Result<String> {
160    let frames = session_dir.join("frames");
161    if !frames.exists() {
162        return Err(FerrisError::new(
163            ErrorKind::Storage,
164            format!("frames path not found: {}", frames.display()),
165        ));
166    }
167    let mut frame_dirs = fs::read_dir(&frames)?
168        .filter_map(|entry| entry.ok())
169        .filter_map(|entry| match entry.file_type() {
170            Ok(file_type) if file_type.is_dir() => Some(entry.path()),
171            _ => None,
172        })
173        .collect::<Vec<_>>();
174    frame_dirs.sort();
175    for frame_dir in frame_dirs {
176        let mut files = fs::read_dir(frame_dir)?
177            .filter_map(|entry| entry.ok())
178            .filter_map(|entry| match entry.file_type() {
179                Ok(file_type) if file_type.is_file() => Some(entry.path()),
180                _ => None,
181            })
182            .collect::<Vec<_>>();
183        files.sort();
184        for file in files {
185            let Some(extension) = file.extension().and_then(|value| value.to_str()) else {
186                continue;
187            };
188            if matches!(extension, "jpg" | "jpeg" | "png") {
189                let Some(file_name) = file.file_name().and_then(|value| value.to_str()) else {
190                    continue;
191                };
192                return Ok(file_name.to_string());
193            }
194        }
195    }
196    Err(FerrisError::new(
197        ErrorKind::Storage,
198        "no screenshot frames found for video export",
199    ))
200}