1use std::path::PathBuf;
8use std::time::Duration;
9
10use ff_decode::{SeekMode, VideoDecoder};
11use ff_format::VideoFrame;
12
13use crate::PipelineError;
14
15pub struct ThumbnailPipeline {
29 path: String,
30 timestamps: Vec<f64>,
31 output_dir: Option<PathBuf>,
32 width: Option<u32>,
33 quality: Option<u32>,
34}
35
36impl ThumbnailPipeline {
37 pub fn new(path: &str) -> Self {
39 Self {
40 path: path.to_owned(),
41 timestamps: Vec::new(),
42 output_dir: None,
43 width: None,
44 quality: None,
45 }
46 }
47
48 #[must_use]
50 pub fn timestamps(mut self, times: Vec<f64>) -> Self {
51 self.timestamps = times;
52 self
53 }
54
55 #[must_use]
57 pub fn output_dir(mut self, dir: impl AsRef<std::path::Path>) -> Self {
58 self.output_dir = Some(dir.as_ref().to_path_buf());
59 self
60 }
61
62 #[must_use]
66 pub fn width(mut self, w: u32) -> Self {
67 self.width = Some(w);
68 self
69 }
70
71 #[must_use]
75 pub fn quality(mut self, q: u32) -> Self {
76 self.quality = Some(q);
77 self
78 }
79
80 pub fn run(mut self) -> Result<Vec<VideoFrame>, PipelineError> {
94 if self.timestamps.is_empty() {
95 return Ok(vec![]);
96 }
97 decode_frames(&self.path, &mut self.timestamps)
98 }
99
100 pub fn run_to_files(mut self) -> Result<Vec<PathBuf>, PipelineError> {
113 let dir = self.output_dir.take().ok_or(PipelineError::NoOutput)?;
114
115 if self.timestamps.is_empty() {
116 return Ok(vec![]);
117 }
118
119 std::fs::create_dir_all(&dir)?;
120
121 let frames = decode_frames(&self.path, &mut self.timestamps)?;
122
123 let mut paths = Vec::with_capacity(frames.len());
124 for (i, frame) in frames.into_iter().enumerate() {
125 let fw = frame.width();
126 let fh = frame.height();
127
128 let (enc_w, enc_h) = match self.width {
129 Some(w) if fw > w => {
130 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
131 let enc_h =
132 ((f64::from(fh) * f64::from(w) / f64::from(fw)).round() as u32).max(1);
133 (w, enc_h)
134 }
135 _ => (fw, fh),
136 };
137
138 let out_path = dir.join(format!("thumb_{i:04}.jpg"));
139
140 let mut builder = ff_encode::ImageEncoder::create(&out_path)
141 .width(enc_w)
142 .height(enc_h);
143 if let Some(q) = self.quality {
144 builder = builder.quality(q);
145 }
146 builder.build()?.encode(&frame)?;
147
148 log::info!(
149 "thumbnail written path={} width={enc_w} height={enc_h}",
150 out_path.display()
151 );
152 paths.push(out_path);
153 }
154
155 Ok(paths)
156 }
157}
158
159fn decode_frames(path: &str, timestamps: &mut [f64]) -> Result<Vec<VideoFrame>, PipelineError> {
160 timestamps.sort_by(f64::total_cmp);
161
162 #[cfg(feature = "parallel")]
163 {
164 use rayon::prelude::*;
165
166 log::info!(
167 "thumbnail pipeline starting parallel extraction path={} count={}",
168 path,
169 timestamps.len()
170 );
171
172 timestamps
175 .par_iter()
176 .map(|ts| {
177 let mut decoder = VideoDecoder::open(path).build()?;
178 decoder.seek(Duration::from_secs_f64(*ts), SeekMode::Keyframe)?;
179 let frame = decoder
180 .decode_one()?
181 .ok_or(PipelineError::FrameNotAvailable)?;
182 Ok(frame)
183 })
184 .collect()
185 }
186
187 #[cfg(not(feature = "parallel"))]
188 {
189 let mut decoder = VideoDecoder::open(path).build()?;
190 log::info!("thumbnail pipeline opened file path={path}");
191
192 let mut frames = Vec::with_capacity(timestamps.len());
193 for ts in timestamps.iter() {
194 decoder.seek(Duration::from_secs_f64(*ts), SeekMode::Keyframe)?;
195 let frame = decoder
196 .decode_one()?
197 .ok_or(PipelineError::FrameNotAvailable)?;
198 frames.push(frame);
199 }
200
201 Ok(frames)
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn new_should_store_path() {
211 let pipeline = ThumbnailPipeline::new("video.mp4");
212 assert_eq!(pipeline.path, "video.mp4");
213 }
214
215 #[test]
216 fn timestamps_should_store_timestamps() {
217 let pipeline = ThumbnailPipeline::new("video.mp4").timestamps(vec![1.0, 2.0, 3.0]);
218 assert_eq!(pipeline.timestamps, vec![1.0, 2.0, 3.0]);
219 }
220
221 #[test]
222 fn run_with_no_timestamps_should_return_empty_vec() {
223 let result = ThumbnailPipeline::new("nonexistent.mp4").run();
224 assert!(matches!(result, Ok(ref v) if v.is_empty()));
225 }
226
227 #[test]
228 fn timestamps_should_sort_ascending_before_run() {
229 let mut ts = [3.0_f64, 1.0, 2.0];
230 ts.sort_by(f64::total_cmp);
231 assert_eq!(ts, [1.0, 2.0, 3.0]);
232 }
233
234 #[test]
235 #[allow(clippy::float_cmp)]
236 fn timestamps_nan_should_sort_after_finite_values() {
237 let mut ts = [2.0_f64, f64::NAN, 1.0];
238 ts.sort_by(f64::total_cmp);
239 assert_eq!(ts[0], 1.0);
240 assert_eq!(ts[1], 2.0);
241 assert!(ts[2].is_nan());
242 }
243
244 #[cfg(feature = "parallel")]
245 #[test]
246 fn parallel_run_with_no_timestamps_should_return_empty_vec() {
247 let result = ThumbnailPipeline::new("nonexistent.mp4").run();
248 assert!(matches!(result, Ok(ref v) if v.is_empty()));
249 }
250
251 #[test]
252 fn output_dir_should_store_path() {
253 let pipeline = ThumbnailPipeline::new("video.mp4").output_dir("/tmp/thumbs");
254 assert_eq!(pipeline.output_dir, Some(PathBuf::from("/tmp/thumbs")));
255 }
256
257 #[test]
258 fn width_setter_should_store_value() {
259 let pipeline = ThumbnailPipeline::new("video.mp4").width(320);
260 assert_eq!(pipeline.width, Some(320));
261 }
262
263 #[test]
264 fn quality_setter_should_store_value() {
265 let pipeline = ThumbnailPipeline::new("video.mp4").quality(85);
266 assert_eq!(pipeline.quality, Some(85));
267 }
268
269 #[test]
270 fn run_to_files_without_output_dir_should_return_no_output_error() {
271 let result = ThumbnailPipeline::new("nonexistent.mp4")
272 .timestamps(vec![0.0])
273 .run_to_files();
274 assert!(matches!(result, Err(PipelineError::NoOutput)));
275 }
276
277 #[test]
278 fn run_to_files_with_empty_timestamps_and_no_dir_should_return_no_output_error() {
279 let result = ThumbnailPipeline::new("nonexistent.mp4").run_to_files();
280 assert!(matches!(result, Err(PipelineError::NoOutput)));
281 }
282}