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 ffmpeg for your operating system and ensure it 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}