1use std::path::{Path, PathBuf};
14
15use serde::Serialize;
16
17use crate::core::park::{SelectTuning, Selection, select_park_frame};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "lowercase")]
22pub enum CaptureKind {
23 Park,
25 Smooth,
27 Video,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub struct CaptureCam {
34 pub id: String,
36 pub kind: CaptureKind,
37 pub frames: u64,
39 pub has_mp4: bool,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct CaptureRun {
46 pub id: String,
48 pub started_at: u64,
50 pub label: String,
52 pub cameras: Vec<CaptureCam>,
53}
54
55pub fn classify(files: &[String]) -> Option<CaptureCam> {
58 let count = |prefix: &str| {
59 files
60 .iter()
61 .filter(|f| f.starts_with(prefix) && f.ends_with(".jpg"))
62 .count() as u64
63 };
64 let has = |name: &str| files.iter().any(|f| f == name);
65 let parks = count("park_");
66 let frames = count("frame_");
67 let has_mp4 = has("plain.mp4");
68 let has_mjpeg = has("plain.mjpeg");
69 if parks > 0 {
70 Some(CaptureCam {
71 id: String::new(),
72 kind: CaptureKind::Park,
73 frames: parks,
74 has_mp4: false,
75 })
76 } else if frames > 0 {
77 Some(CaptureCam {
78 id: String::new(),
79 kind: CaptureKind::Smooth,
80 frames,
81 has_mp4: false,
82 })
83 } else if has_mp4 || has_mjpeg {
84 Some(CaptureCam {
85 id: String::new(),
86 kind: CaptureKind::Video,
87 frames: 0,
88 has_mp4,
89 })
90 } else {
91 None
92 }
93}
94
95pub fn thumb_frame(files: &[String], kind: CaptureKind) -> Option<String> {
101 let prefix = match kind {
102 CaptureKind::Park => "park_",
103 CaptureKind::Smooth => "frame_",
104 CaptureKind::Video => return None,
105 };
106 files
107 .iter()
108 .filter(|f| f.starts_with(prefix) && f.ends_with(".jpg"))
109 .max()
110 .cloned()
111}
112
113pub fn parse_run_id(id: &str) -> (u64, String) {
116 match id.split_once('_') {
117 Some((epoch, rest)) => match epoch.parse::<u64>() {
118 Ok(e) => (e, rest.to_string()),
119 Err(_) => (0, id.to_string()),
120 },
121 None => (0, id.to_string()),
122 }
123}
124
125fn file_names(dir: &Path) -> Vec<String> {
127 std::fs::read_dir(dir)
128 .map(|rd| {
129 rd.flatten()
130 .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
131 .filter_map(|e| e.file_name().into_string().ok())
132 .collect()
133 })
134 .unwrap_or_default()
135}
136
137pub fn list_captures(root: &Path) -> Vec<CaptureRun> {
142 let Ok(rd) = std::fs::read_dir(root) else {
143 return Vec::new();
144 };
145 let mut runs: Vec<CaptureRun> = rd
146 .flatten()
147 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
148 .filter_map(|run_entry| {
149 let run_dir = run_entry.path();
150 let id = run_entry.file_name().into_string().ok()?;
151 let mut cameras = Vec::new();
152 if let Ok(inner) = std::fs::read_dir(&run_dir) {
154 for sub in inner.flatten() {
155 if !sub.file_type().map(|t| t.is_dir()).unwrap_or(false) {
156 continue;
157 }
158 if let (Some(mut cam), Ok(cam_id)) = (
159 classify(&file_names(&sub.path())),
160 sub.file_name().into_string(),
161 ) {
162 cam.id = cam_id;
163 cameras.push(cam);
164 }
165 }
166 }
167 if let Some(mut cam) = classify(&file_names(&run_dir)) {
170 cam.id = "default".to_string();
171 cameras.push(cam);
172 }
173 if cameras.is_empty() {
174 return None;
175 }
176 cameras.sort_by(|a, b| a.id.cmp(&b.id));
177 let (started_at, label) = parse_run_id(&id);
178 Some(CaptureRun {
179 id,
180 started_at,
181 label,
182 cameras,
183 })
184 })
185 .collect();
186 runs.sort_by(|a, b| b.started_at.cmp(&a.started_at).then(b.id.cmp(&a.id)));
187 runs
188}
189
190fn encode_tail(out: &Path) -> Vec<String> {
193 vec![
194 "-vf".into(),
195 "scale='min(1280,iw)':-2,format=yuv420p".into(),
196 "-c:v".into(),
197 "libx264".into(),
198 "-crf".into(),
199 "23".into(),
200 "-movflags".into(),
201 "+faststart".into(),
202 out.display().to_string(),
203 ]
204}
205
206pub fn assemble_args(
211 cam_dir: &Path,
212 kind: CaptureKind,
213 out: &Path,
214 fps: u32,
215) -> Option<Vec<String>> {
216 let mut args = vec!["-y".into(), "-framerate".into(), fps.max(1).to_string()];
217 match kind {
218 CaptureKind::Park => args.extend([
219 "-start_number".into(),
220 "0".into(),
221 "-i".into(),
222 cam_dir.join("park_%06d.jpg").display().to_string(),
223 ]),
224 CaptureKind::Smooth => args.extend([
225 "-pattern_type".into(),
226 "glob".into(),
227 "-i".into(),
228 cam_dir.join("frame_*.jpg").display().to_string(),
229 ]),
230 CaptureKind::Video => return None,
231 }
232 args.extend(encode_tail(out));
233 Some(args)
234}
235
236pub fn transcode_args(input: &Path, out: &Path) -> Vec<String> {
239 let mut args = vec!["-y".into(), "-i".into(), input.display().to_string()];
240 args.extend(encode_tail(out));
241 args
242}
243
244pub fn transcode_mp4(input: &Path, out: &Path) -> Result<(), String> {
247 run_ffmpeg(&transcode_args(input, out), out)
248}
249
250pub fn video_thumb_args(input: &Path, out: &Path) -> Vec<String> {
254 vec![
255 "-y".into(),
256 "-ss".into(),
257 "1".into(),
258 "-i".into(),
259 input.display().to_string(),
260 "-frames:v".into(),
261 "1".into(),
262 "-vf".into(),
263 "scale='min(480,iw)':-2".into(),
264 out.display().to_string(),
265 ]
266}
267
268pub fn extract_video_thumb(input: &Path, out: &Path) -> Result<(), String> {
270 run_ffmpeg(&video_thumb_args(input, out), out)
271}
272
273fn run_ffmpeg(args: &[String], out: &Path) -> Result<(), String> {
276 let status = std::process::Command::new("ffmpeg")
277 .args(args)
278 .status()
279 .map_err(|e| {
280 if e.kind() == std::io::ErrorKind::NotFound {
281 "ffmpeg not found on PATH — install ffmpeg to make the mp4".to_string()
282 } else {
283 format!("running ffmpeg: {e}")
284 }
285 })?;
286 if !status.success() {
287 return Err(format!("ffmpeg failed to make {}", out.display()));
288 }
289 Ok(())
290}
291
292pub fn assemble_mp4(cam_dir: &Path, kind: CaptureKind, out: &Path, fps: u32) -> Result<(), String> {
295 let Some(args) = assemble_args(cam_dir, kind, out, fps) else {
296 return Err("this recording is already a video — nothing to assemble".to_string());
297 };
298 run_ffmpeg(&args, out)
299}
300
301pub const SMOOTH_DECODE_W: usize = 64;
304pub const SMOOTH_DECODE_H: usize = 36;
305
306pub fn parse_smooth_frame(name: &str) -> Option<(u64, u64)> {
309 let rest = name.strip_suffix(".jpg")?.strip_prefix("frame_")?;
310 let (_n, rest) = rest.split_once("_layer_")?;
311 let (layer, offset) = rest.split_once("_t")?;
312 Some((layer.parse().ok()?, offset.parse().ok()?))
313}
314
315pub fn gray_decode_args(glob: &Path, w: usize, h: usize) -> Vec<String> {
319 vec![
320 "-v".into(),
321 "error".into(),
322 "-f".into(),
323 "image2".into(),
324 "-pattern_type".into(),
325 "glob".into(),
326 "-i".into(),
327 glob.display().to_string(),
328 "-vf".into(),
329 format!("scale={w}:{h},format=gray"),
330 "-f".into(),
331 "rawvideo".into(),
332 "-pix_fmt".into(),
333 "gray".into(),
334 "-".into(),
335 ]
336}
337
338pub fn smooth_decode_args(cam_dir: &Path, w: usize, h: usize) -> Vec<String> {
340 gray_decode_args(&cam_dir.join("frame_*.jpg"), w, h)
341}
342
343pub fn select_layer_burst(
348 cam_dir: &Path,
349 layer: i64,
350 sel: &SelectTuning,
351) -> Result<Option<(PathBuf, f64)>, String> {
352 let (w, h) = (SMOOTH_DECODE_W, SMOOTH_DECODE_H);
353 let tag = format!("_layer_{layer:05}_t");
354 let mut files: Vec<String> = std::fs::read_dir(cam_dir)
355 .map_err(|e| e.to_string())?
356 .flatten()
357 .filter_map(|e| e.file_name().into_string().ok())
358 .filter(|n| n.starts_with("frame_") && n.ends_with(".jpg") && n.contains(&tag))
359 .collect();
360 files.sort();
361 if files.is_empty() {
362 return Ok(None);
363 }
364 let glob = cam_dir.join(format!("frame_*{tag}*.jpg"));
365 let out = std::process::Command::new("ffmpeg")
366 .args(gray_decode_args(&glob, w, h))
367 .output()
368 .map_err(|e| {
369 if e.kind() == std::io::ErrorKind::NotFound {
370 "ffmpeg not found on PATH — install ffmpeg".to_string()
371 } else {
372 format!("running ffmpeg: {e}")
373 }
374 })?;
375 if !out.status.success() {
376 return Err("ffmpeg failed to decode the layer burst".to_string());
377 }
378 let fsize = w * h;
379 if out.stdout.len() != fsize * files.len() {
380 return Err(format!(
381 "decoded {} bytes, expected {} ({} frames)",
382 out.stdout.len(),
383 fsize * files.len(),
384 files.len()
385 ));
386 }
387 let mut frames = Vec::with_capacity(files.len());
388 let mut paths = Vec::with_capacity(files.len());
389 for (i, name) in files.iter().enumerate() {
390 let Some((_l, offset)) = parse_smooth_frame(name) else {
391 continue;
392 };
393 frames.push(crate::core::park::SelectFrame {
394 offset_ms: offset,
395 gray: out.stdout[i * fsize..(i + 1) * fsize].to_vec(),
396 });
397 paths.push((offset, cam_dir.join(name)));
398 }
399 match select_park_frame(&frames, w, h, sel) {
400 Selection::Selected {
401 offset_ms,
402 confidence,
403 } => Ok(paths
404 .into_iter()
405 .find(|(o, _)| *o == offset_ms)
406 .map(|(_, p)| (p, confidence))),
407 Selection::Skipped { .. } => Ok(None),
408 }
409}
410
411pub fn select_smooth_frames(cam_dir: &Path, sel: &SelectTuning) -> Result<Vec<PathBuf>, String> {
416 let (w, h) = (SMOOTH_DECODE_W, SMOOTH_DECODE_H);
417 let mut files: Vec<String> = std::fs::read_dir(cam_dir)
418 .map_err(|e| e.to_string())?
419 .flatten()
420 .filter_map(|e| e.file_name().into_string().ok())
421 .filter(|n| n.starts_with("frame_") && n.ends_with(".jpg"))
422 .collect();
423 files.sort(); if files.is_empty() {
425 return Err("no smooth frames to select".to_string());
426 }
427 let out = std::process::Command::new("ffmpeg")
428 .args(smooth_decode_args(cam_dir, w, h))
429 .output()
430 .map_err(|e| {
431 if e.kind() == std::io::ErrorKind::NotFound {
432 "ffmpeg not found on PATH — install ffmpeg".to_string()
433 } else {
434 format!("running ffmpeg: {e}")
435 }
436 })?;
437 if !out.status.success() {
438 return Err("ffmpeg failed to decode smooth frames".to_string());
439 }
440 let fsize = w * h;
441 if out.stdout.len() != fsize * files.len() {
442 return Err(format!(
444 "decoded {} bytes, expected {} ({} frames)",
445 out.stdout.len(),
446 fsize * files.len(),
447 files.len()
448 ));
449 }
450 use std::collections::BTreeMap;
452 type LayerGroup = (Vec<crate::core::park::SelectFrame>, Vec<(u64, PathBuf)>);
453 let mut by_layer: BTreeMap<u64, LayerGroup> = BTreeMap::new();
454 for (i, name) in files.iter().enumerate() {
455 let Some((layer, offset)) = parse_smooth_frame(name) else {
456 continue;
457 };
458 let gray = out.stdout[i * fsize..(i + 1) * fsize].to_vec();
459 let entry = by_layer.entry(layer).or_default();
460 entry.0.push(crate::core::park::SelectFrame {
461 offset_ms: offset,
462 gray,
463 });
464 entry.1.push((offset, cam_dir.join(name)));
465 }
466 let mut selected = Vec::new();
467 for (_layer, (frames, paths)) in by_layer {
468 if let Selection::Selected { offset_ms, .. } = select_park_frame(&frames, w, h, sel)
469 && let Some((_, path)) = paths.into_iter().find(|(o, _)| *o == offset_ms)
470 {
471 selected.push(path);
472 }
473 }
474 Ok(selected)
475}
476
477pub fn assemble_selected_mp4(frames: &[PathBuf], out: &Path, fps: u32) -> Result<(), String> {
481 if frames.is_empty() {
482 return Err("no selected frames to assemble".to_string());
483 }
484 let stage = out
485 .parent()
486 .unwrap_or_else(|| Path::new("."))
487 .join(".sel-stage");
488 let _ = std::fs::remove_dir_all(&stage);
489 std::fs::create_dir_all(&stage).map_err(|e| e.to_string())?;
490 for (i, f) in frames.iter().enumerate() {
491 std::fs::copy(f, stage.join(format!("sel_{i:06}.jpg"))).map_err(|e| e.to_string())?;
492 }
493 let mut args = vec![
494 "-y".into(),
495 "-framerate".into(),
496 fps.max(1).to_string(),
497 "-start_number".into(),
498 "0".into(),
499 "-i".into(),
500 stage.join("sel_%06d.jpg").display().to_string(),
501 ];
502 args.extend(encode_tail(out));
503 let r = run_ffmpeg(&args, out);
504 let _ = std::fs::remove_dir_all(&stage);
505 r
506}
507
508pub fn assemble_smooth_selected_mp4(
511 cam_dir: &Path,
512 sel: &SelectTuning,
513 out: &Path,
514 fps: u32,
515) -> Result<(), String> {
516 let frames = select_smooth_frames(cam_dir, sel)?;
517 if frames.is_empty() {
518 return Err("no parked frames selected".to_string());
519 }
520 assemble_selected_mp4(&frames, out, fps)
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use std::fs;
527
528 fn s(v: &[&str]) -> Vec<String> {
529 v.iter().map(|x| x.to_string()).collect()
530 }
531
532 #[test]
533 fn classify_detects_each_kind() {
534 assert_eq!(
535 classify(&s(&["park_000000.jpg", "park_000001.jpg", "parks.jsonl"])),
536 Some(CaptureCam {
537 id: String::new(),
538 kind: CaptureKind::Park,
539 frames: 2,
540 has_mp4: false
541 })
542 );
543 assert_eq!(
544 classify(&s(&[
545 "frame_000001_layer_00057.jpg",
546 "frame_000002_layer_00000.jpg"
547 ]))
548 .map(|c| (c.kind, c.frames)),
549 Some((CaptureKind::Smooth, 2))
550 );
551 assert_eq!(
552 classify(&s(&["plain.mp4"])).map(|c| (c.kind, c.has_mp4)),
553 Some((CaptureKind::Video, true))
554 );
555 assert_eq!(
556 classify(&s(&["plain.mjpeg"])).map(|c| (c.kind, c.has_mp4)),
557 Some((CaptureKind::Video, false))
558 );
559 assert_eq!(classify(&s(&["notes.txt"])), None);
560 assert_eq!(classify(&s(&[])), None);
561 }
562
563 #[test]
564 fn thumb_frame_picks_the_last_frame_of_a_sequence() {
565 assert_eq!(
568 thumb_frame(
569 &s(&[
570 "park_000002.jpg",
571 "park_000000.jpg",
572 "park_000001.jpg",
573 "parks.jsonl"
574 ]),
575 CaptureKind::Park
576 ),
577 Some("park_000002.jpg".to_string())
578 );
579 assert_eq!(
580 thumb_frame(
581 &s(&[
582 "frame_000001_layer_00057.jpg",
583 "frame_000012_layer_00120.jpg"
584 ]),
585 CaptureKind::Smooth
586 ),
587 Some("frame_000012_layer_00120.jpg".to_string())
588 );
589 assert_eq!(thumb_frame(&s(&["plain.mp4"]), CaptureKind::Video), None);
591 assert_eq!(thumb_frame(&s(&["notes.txt"]), CaptureKind::Park), None);
593 }
594
595 #[test]
596 fn video_thumb_args_grab_one_downscaled_still() {
597 let input = Path::new("/caps/run/ext-1/plain.mp4");
598 let out = Path::new("/caps/run/ext-1/thumb.jpg");
599 let a = video_thumb_args(input, out).join(" ");
600 assert!(a.contains("-i /caps/run/ext-1/plain.mp4"), "{a}");
601 assert!(a.contains("-frames:v 1"), "{a}");
602 assert!(a.contains("scale="), "{a}");
603 assert!(a.trim_end().ends_with("/caps/run/ext-1/thumb.jpg"), "{a}");
604 }
605
606 #[test]
607 fn parse_run_id_splits_epoch_and_label() {
608 assert_eq!(
609 parse_run_id("1781634785_cube_gcode_3mf"),
610 (1781634785, "cube_gcode_3mf".to_string())
611 );
612 assert_eq!(parse_run_id("noepoch"), (0, "noepoch".to_string()));
613 assert_eq!(parse_run_id("x_y"), (0, "x_y".to_string()));
614 }
615
616 #[test]
617 fn lists_runs_newest_first_with_cameras() {
618 let root = std::env::temp_dir().join(format!("bambu-caps-{}", std::process::id()));
619 let _ = fs::remove_dir_all(&root);
620 let a = root.join("100_old_print").join("ext-0");
622 fs::create_dir_all(&a).unwrap();
623 fs::write(a.join("park_000000.jpg"), b"x").unwrap();
624 fs::write(a.join("park_000001.jpg"), b"x").unwrap();
625 let b0 = root.join("200_new_print").join("ext-0");
627 let b1 = root.join("200_new_print").join("ext-1");
628 fs::create_dir_all(&b0).unwrap();
629 fs::create_dir_all(&b1).unwrap();
630 fs::write(b0.join("frame_000001_layer_00001.jpg"), b"x").unwrap();
631 fs::write(b1.join("plain.mp4"), b"x").unwrap();
632
633 let runs = list_captures(&root);
634 assert_eq!(runs.len(), 2);
635 assert_eq!(runs[0].id, "200_new_print");
637 assert_eq!(runs[0].started_at, 200);
638 assert_eq!(runs[0].cameras.len(), 2);
639 assert_eq!(runs[0].cameras[0].id, "ext-0");
640 assert_eq!(runs[0].cameras[0].kind, CaptureKind::Smooth);
641 assert_eq!(runs[0].cameras[1].kind, CaptureKind::Video);
642 assert!(runs[0].cameras[1].has_mp4);
643 assert_eq!(runs[1].id, "100_old_print");
645 assert_eq!(runs[1].cameras[0].kind, CaptureKind::Park);
646 assert_eq!(runs[1].cameras[0].frames, 2);
647
648 let _ = fs::remove_dir_all(&root);
649 }
650
651 #[test]
652 fn old_single_dir_layout_surfaces_as_one_camera() {
653 let root = std::env::temp_dir().join(format!("bambu-caps-old-{}", std::process::id()));
654 let _ = fs::remove_dir_all(&root);
655 let run = root.join("50_legacy");
656 fs::create_dir_all(&run).unwrap();
657 fs::write(run.join("frame_000001_layer_00057.jpg"), b"x").unwrap();
658 let runs = list_captures(&root);
659 assert_eq!(runs.len(), 1);
660 assert_eq!(runs[0].cameras.len(), 1);
661 assert_eq!(runs[0].cameras[0].id, "default"); assert_eq!(runs[0].cameras[0].kind, CaptureKind::Smooth);
663 let _ = fs::remove_dir_all(&root);
664 }
665
666 #[test]
667 fn missing_root_is_empty_not_an_error() {
668 assert!(list_captures(Path::new("/no/such/captures/dir")).is_empty());
669 }
670
671 #[test]
672 fn assemble_args_match_the_kind() {
673 let dir = Path::new("/caps/run/ext-0");
674 let out = Path::new("/caps/run/ext-0/timelapse.mp4");
675 let park = assemble_args(dir, CaptureKind::Park, out, 10)
676 .unwrap()
677 .join(" ");
678 assert!(park.contains("-framerate 10"), "{park}");
679 assert!(
680 park.contains("-start_number 0 -i /caps/run/ext-0/park_%06d.jpg"),
681 "{park}"
682 );
683 assert!(park.contains("libx264"), "{park}");
684 assert!(
685 park.trim_end().ends_with("/caps/run/ext-0/timelapse.mp4"),
686 "{park}"
687 );
688
689 let smooth = assemble_args(dir, CaptureKind::Smooth, out, 12)
690 .unwrap()
691 .join(" ");
692 assert!(
693 smooth.contains("-pattern_type glob -i /caps/run/ext-0/frame_*.jpg"),
694 "{smooth}"
695 );
696
697 assert!(assemble_args(dir, CaptureKind::Video, out, 10).is_none());
699 }
700
701 #[test]
702 fn parse_smooth_frame_pulls_layer_and_offset() {
703 assert_eq!(
704 parse_smooth_frame("frame_000012_layer_00007_t0900.jpg"),
705 Some((7, 900))
706 );
707 assert_eq!(
708 parse_smooth_frame("frame_000001_layer_00000_t0100.jpg"),
709 Some((0, 100))
710 );
711 assert_eq!(parse_smooth_frame("park_000003.jpg"), None);
713 assert_eq!(parse_smooth_frame("plain.mp4"), None);
714 }
715
716 #[test]
717 fn smooth_decode_args_glob_to_gray_rawvideo() {
718 let a = smooth_decode_args(Path::new("/caps/run/ext-0"), 64, 36).join(" ");
719 assert!(
720 a.contains("-pattern_type glob -i /caps/run/ext-0/frame_*.jpg"),
721 "{a}"
722 );
723 assert!(a.contains("scale=64:36,format=gray"), "{a}");
724 assert!(a.contains("-f rawvideo"), "{a}");
725 assert!(a.trim_end().ends_with(" -"), "{a}");
726 }
727
728 #[test]
729 fn transcode_args_wrap_an_mjpeg_input() {
730 let input = Path::new("/caps/run/ext-1/plain.mjpeg");
731 let out = Path::new("/caps/run/ext-1/plain.mp4");
732 let a = transcode_args(input, out).join(" ");
733 assert!(a.contains("-i /caps/run/ext-1/plain.mjpeg"), "{a}");
734 assert!(a.contains("libx264"), "{a}");
735 assert!(a.trim_end().ends_with("/caps/run/ext-1/plain.mp4"), "{a}");
736 }
737}