Skip to main content

bambu_rs/
captures.rs

1//! Listing finished/in-progress capture runs on disk — the reusable read side of the
2//! timelapse feature, so the CLI and the server present the same recordings.
3//!
4//! A serve capture writes to `captures/<epoch>_<name>_<mode>/<camera-id>/`, where each
5//! camera dir holds one of: `park_NNNNNN.jpg` (a park-detected clean timelapse),
6//! `frame_NNNNNN_*.jpg` (a printer-synced smooth timelapse), or `plain.mp4` / `plain.mjpeg`
7//! (a head-in-shot video). The *kind* is detected from the files, not the dir name (older
8//! runs lacked the `_<mode>` suffix), so it stays correct across layout changes.
9//!
10//! The filename → kind classification is pure (unit-tested); walking the directory is the
11//! thin I/O wrapper (tested with a temp tree).
12
13use std::path::{Path, PathBuf};
14
15use serde::Serialize;
16
17use crate::core::park::{SelectTuning, Selection, select_park_frame};
18
19/// What a camera's capture dir holds.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "lowercase")]
22pub enum CaptureKind {
23    /// Object-only timelapse, park-detected from the camera (`park_*.jpg`). Scrubbable.
24    Park,
25    /// Object-only timelapse, printer-layer-synced snapshots (`frame_*.jpg`).
26    Smooth,
27    /// A head-in-shot video (`plain.mp4` / `plain.mjpeg`).
28    Video,
29}
30
31/// One camera's output within a run.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub struct CaptureCam {
34    /// The camera id (its subdir name; empty for an old single-dir layout).
35    pub id: String,
36    pub kind: CaptureKind,
37    /// Frame count for an image-sequence kind (Park/Smooth); 0 for a Video.
38    pub frames: u64,
39    /// Whether an assembled/recorded `plain.mp4` is already present (Video only).
40    pub has_mp4: bool,
41}
42
43/// One capture run (a print's recordings), newest first when listed.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct CaptureRun {
46    /// The run dir name, e.g. `1781634785_cube-petg2_gcode_3mf`.
47    pub id: String,
48    /// Unix epoch parsed from the dir name prefix (0 if unparseable).
49    pub started_at: u64,
50    /// The human-ish remainder of the dir name (the sanitized job name).
51    pub label: String,
52    pub cameras: Vec<CaptureCam>,
53}
54
55/// Classify a camera dir from its filenames (pure). `None` when nothing recognizable is
56/// present (e.g. an empty dir or a transient `.ring`).
57pub 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
95/// The representative still for a recording's thumbnail: the LAST frame of an image
96/// sequence. A timelapse's final frame shows the most-built-up object, which reads best as
97/// a poster. `None` for a Video (no frame file to serve — the caller extracts one with
98/// ffmpeg, see [`video_thumb_args`]). Pure. Frame indices are zero-padded, so the
99/// lexicographic max is the newest frame.
100pub 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
113/// Split a run dir name into (epoch, label): `1781634785_cube_gcode_3mf` → (1781634785,
114/// "cube_gcode_3mf"). A non-numeric/absent prefix yields `(0, whole-name)`.
115pub 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
125/// Read the filenames (not subdirs) directly in `dir`.
126fn 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
137/// List capture runs under `root` (newest first). Each run's cameras are its per-camera
138/// subdirs that hold recognizable output; an older single-dir run (files directly in the
139/// run dir) surfaces as one camera with an empty id. Best-effort: unreadable dirs are
140/// skipped, never an error.
141pub 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            // Per-camera subdirs.
153            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            // Old layout: files directly in the run dir. Give it a non-empty id so it has
168            // a usable download URL; the endpoint maps a missing subdir back to the run dir.
169            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
190/// The shared encode tail: downscale huge frames + yuv420p (plays everywhere), x264, and
191/// faststart so the mp4 streams. Ends with the output path.
192fn 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
206/// ffmpeg argv (after the program name) to assemble a camera dir's image sequence into
207/// `out` at `fps`. `None` for a Video kind (it's already a file — nothing to assemble).
208/// Park frames are a contiguous `park_%06d.jpg`; smooth frames sort lexicographically, so
209/// glob them. Pure, so the command shape is unit-tested without ffmpeg.
210pub 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
236/// ffmpeg argv to transcode a raw `plain.mjpeg` (the fallback recorded when ffmpeg was
237/// absent at capture time) into a playable mp4 at `out`. Pure.
238pub 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
244/// Transcode a raw mjpeg recording to mp4 (the thin ffmpeg seam). Same error mapping as
245/// [`assemble_mp4`].
246pub fn transcode_mp4(input: &Path, out: &Path) -> Result<(), String> {
247    run_ffmpeg(&transcode_args(input, out), out)
248}
249
250/// ffmpeg argv to grab a single downscaled still from a video `input` into `out` (a jpeg
251/// poster for a Video recording's thumbnail). Seeks ~1s in to skip a black/opening frame.
252/// Pure, so the command shape is unit-tested without ffmpeg.
253pub 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
268/// Extract a poster still from a video recording (the thin ffmpeg seam for Video thumbs).
269pub fn extract_video_thumb(input: &Path, out: &Path) -> Result<(), String> {
270    run_ffmpeg(&video_thumb_args(input, out), out)
271}
272
273/// Run ffmpeg with `args` producing `out`. The thin seam shared by assemble + transcode;
274/// friendly error if ffmpeg is missing or the encode fails.
275fn 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
292/// Assemble a camera dir's image sequence to an mp4 at `out` (overwriting). Shared by the
293/// CLI's `--assemble` and the server. Errors if the kind isn't an image sequence.
294pub 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
301/// Decode size for smooth burst selection — tiny gray, matching the live park detector and
302/// the skill's select_smooth.py (enough signal for the left-zone park, cheap to score).
303pub const SMOOTH_DECODE_W: usize = 64;
304pub const SMOOTH_DECODE_H: usize = 36;
305
306/// Parse a smooth burst frame name `frame_<n>_layer_<L>_t<offset>.jpg` → `(layer, offset_ms)`.
307/// `None` for anything that isn't a burst frame. Pure.
308pub 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
315/// ffmpeg argv to decode every JPEG matching `glob` to a `w`×`h` gray rawvideo stream on
316/// stdout (one frame after another, glob = capture order). One ffmpeg for the whole set —
317/// no per-frame spawn. Pure (command shape unit-tested without ffmpeg).
318pub 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
338/// Decode args for all `frame_*.jpg` in a smooth cam dir (whole-run selection).
339pub 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
343/// Decode + select the parked frame from ONE layer's burst — the live path used during a
344/// smooth capture (after each layer's burst settles). Decodes just that layer's
345/// `frame_*_layer_<L>_t*.jpg` with one ffmpeg and runs [`select_park_frame`]. Returns the
346/// chosen full-res JPEG path, or `None` if the layer's park wasn't captured.
347pub 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
411/// Pick the parked frame from each layer's burst in a smooth cam dir, returning the chosen
412/// full-res JPEG paths in layer order. Decodes the whole dir to tiny gray with ONE ffmpeg,
413/// groups by layer, and runs the pure [`select_park_frame`]. Layers whose park fell outside
414/// the burst are skipped (a gap beats a head-over-print frame).
415pub 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(); // lexicographic = zero-padded frame index = ffmpeg glob order
424    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        // glob/sort drift or a decode hiccup — bail so the caller falls back to assemble-all.
443        return Err(format!(
444            "decoded {} bytes, expected {} ({} frames)",
445            out.stdout.len(),
446            fsize * files.len(),
447            files.len()
448        ));
449    }
450    // Group frames by layer (BTreeMap keeps layer order); keep each frame's offset→path.
451    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
477/// Assemble a specific, ordered list of full-res JPEGs into an mp4 (the selected parked
478/// frames). Stages them as a contiguous `sel_%06d.jpg` sequence in a temp subdir next to
479/// `out`, image2-assembles, then cleans up.
480pub 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
508/// The clean smooth timelapse: select the parked frame per layer, then assemble just those.
509/// Errors (and the caller falls back to the raw all-frames assemble) if nothing selectable.
510pub 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        // Park/Smooth → the highest-numbered (most-built-up) frame; order in the listing
566        // doesn't matter since indices are zero-padded.
567        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        // A Video has no frame file to serve; the caller extracts one with ffmpeg.
590        assert_eq!(thumb_frame(&s(&["plain.mp4"]), CaptureKind::Video), None);
591        // Nothing matching → None (no crash on an empty/odd dir).
592        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        // run A (older): one park camera
621        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        // run B (newer): a smooth camera + a video camera
626        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        // newest (epoch 200) first
636        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        // older run
644        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"); // files directly in the run dir
662        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        // A video has nothing to assemble.
698        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        // Not a burst frame.
712        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}