Skip to main content

bambu_rs/server/
timelapse.rs

1//! Serve-internal per-layer timelapse capture. The dashboard's single MQTT
2//! connection already streams [`PrinterStatus`] over a `watch` channel, so the
3//! capture runs *inside* `bambu serve` off that feed — no second printer
4//! connection (the A1 mini allows only one) and the lowest possible latency.
5//!
6//! It's driven by camera *id* (not an arbitrary command), so the control
7//! endpoint is a normal gated write with no command-execution surface. The pure
8//! [`CaptureSession`] decides when to grab; this owns the I/O (fetching the
9//! frame and writing files) and the run lifecycle.
10
11use std::io::Write;
12use std::path::PathBuf;
13use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
14use std::sync::{Arc, Mutex};
15use std::time::Duration;
16
17use tokio::sync::watch;
18use tokio::task::JoinHandle;
19
20use super::camera::StreamOpen;
21use super::stream_record::record_loop;
22use crate::core::park::{Park, SelectTuning};
23use crate::core::status::PrinterStatus;
24use crate::core::timelapse::{ActivityAction, CaptureAction, CaptureSession, PrintActivitySession};
25use crate::park::{
26    DECODE_H, DECODE_W, ParkCapture, ParkEvent, ParkRunStats, ParkWriter, SegmentCapture,
27    run_park_camera, run_segment_camera,
28};
29
30/// Spawns the live-park worker for ONE camera, returning its task handle. Injected so the
31/// slot lifecycle (lazy spawn on print-active, stop at finish) is unit-tested with a fake
32/// worker, while production runs the real ffmpeg supervisor ([`crate::park::run_park_camera`]).
33pub type ParkSpawn = Arc<
34    dyn Fn(ParkCapture, PathBuf, Arc<AtomicBool>, Arc<Mutex<TimelapseStatus>>) -> JoinHandle<()>
35        + Send
36        + Sync,
37>;
38
39/// The production [`ParkSpawn`]: each camera's blocking ffmpeg supervisor on the blocking
40/// pool. This is the server's thin adapter — it maps the library runner's progress
41/// callback + result onto the shared [`TimelapseStatus`] (frames/failures/last_error).
42pub fn real_park_spawn() -> ParkSpawn {
43    Arc::new(
44        |cap: ParkCapture, out_dir, cancel, status: Arc<Mutex<TimelapseStatus>>| {
45            tokio::task::spawn_blocking(move || {
46                let mut on_park = park_progress(cap.id.clone(), status.clone());
47                let cam_dir = out_dir.join(&cap.id);
48                let outcome =
49                    run_park_camera(&cap, &cam_dir, DECODE_W, DECODE_H, &cancel, &mut on_park);
50                report_park_run(&cap.id, &cap.stream_url, outcome, &status);
51            })
52        },
53    )
54}
55
56/// Spawns the dense-stream segment worker for ONE camera, returning its task handle. Like
57/// [`ParkSpawn`] but the worker also reads the live print layer (the `Arc<AtomicI64>` the
58/// lifecycle loop feeds from MQTT `layer_num`), so it can segment the stream per layer.
59/// Injected so the slot lifecycle is unit-tested with a fake worker; production runs the
60/// real ffmpeg supervisor ([`crate::park::run_segment_camera`]).
61pub type SegmentSpawn = Arc<
62    dyn Fn(
63            SegmentCapture,
64            PathBuf,
65            Arc<AtomicI64>,
66            Arc<AtomicBool>,
67            Arc<Mutex<TimelapseStatus>>,
68        ) -> JoinHandle<()>
69        + Send
70        + Sync,
71>;
72
73/// The production [`SegmentSpawn`]: each camera's blocking ffmpeg supervisor for the
74/// dense-stream segmented capture, sharing the same progress→status adapter as the park
75/// slot (both write the identical `park_*.jpg` output).
76pub fn real_segment_spawn() -> SegmentSpawn {
77    Arc::new(
78        |cap: SegmentCapture,
79         out_dir,
80         current_layer: Arc<AtomicI64>,
81         cancel,
82         status: Arc<Mutex<TimelapseStatus>>| {
83            tokio::task::spawn_blocking(move || {
84                let mut on_park = park_progress(cap.id.clone(), status.clone());
85                let cam_dir = out_dir.join(&cap.id);
86                let outcome = run_segment_camera(
87                    &cap,
88                    &cam_dir,
89                    DECODE_W,
90                    DECODE_H,
91                    &current_layer,
92                    &cancel,
93                    &mut on_park,
94                );
95                report_park_run(&cap.id, &cap.stream_url, outcome, &status);
96            })
97        },
98    )
99}
100
101/// The shared progress→status adapter for both park runners: a written park bumps the
102/// frame count, a replace refines the previous park in place (same layer — not a new
103/// frame, so the count stays put), and a dropped ring JPEG is surfaced as a failure.
104fn park_progress(id: String, status: Arc<Mutex<TimelapseStatus>>) -> impl FnMut(ParkEvent) {
105    move |ev: ParkEvent| {
106        let mut s = status.lock().unwrap();
107        match ev {
108            ParkEvent::Written => s.frames += 1,
109            ParkEvent::Replaced => {}
110            ParkEvent::Dropped => {
111                s.failures += 1;
112                s.last_error = Some(format!("park {id}: a ring JPEG never arrived"));
113            }
114        }
115    }
116}
117
118/// Fold a park/segment runner's final outcome into the shared status: a clean run with zero
119/// frames (the stream produced nothing) and an outright error both count a failure with a
120/// message, so a silently-dead camera is visible.
121fn report_park_run(
122    id: &str,
123    source: &str,
124    outcome: Result<ParkRunStats, String>,
125    status: &Arc<Mutex<TimelapseStatus>>,
126) {
127    let mut s = status.lock().unwrap();
128    match outcome {
129        Ok(stats) if stats.frames == 0 => {
130            s.failures += 1;
131            s.last_error = Some(format!("park {id}: read 0 frames from {source}"));
132        }
133        Ok(_) => {}
134        Err(e) => {
135            s.failures += 1;
136            s.last_error = Some(e);
137        }
138    }
139}
140
141/// Grab a single JPEG frame (blocking). Resolved from a camera id at start time
142/// and held for the run's duration, so later `/api/camera/config` edits can't
143/// repoint a running capture.
144pub type FrameGrab = Arc<dyn Fn() -> Result<Vec<u8>, String> + Send + Sync>;
145
146/// Disk cap for the raw-MJPEG fallback (no ffmpeg). Raw MJPEG is ~9 GB/hour, so
147/// this bounds a runaway recording; the recorder stops cleanly at the cap.
148const MAX_STREAM_BYTES: u64 = 2 * 1024 * 1024 * 1024;
149
150/// Backstop for the ffmpeg (live-mp4) path. There the raw bytes stream *through*
151/// ffmpeg's stdin and are never stored — only the compact mp4 hits disk — so this
152/// is just a runaway guard (~6h of stream); a normal print ends (cancel) first.
153const MAX_STREAM_INPUT_BYTES: u64 = 64 * 1024 * 1024 * 1024;
154
155/// How a camera contributes to a `plain` run: `Sample` grabs a JPEG every tick
156/// (snapshot-only cameras); `Stream` records the camera's continuous MJPEG stream
157/// to one file (cameras that expose a real `/stream` — the actual video, not
158/// time-sampled frames).
159pub enum PlainCapture {
160    Sample { id: String, grab: FrameGrab },
161    Stream { id: String, open: StreamOpen },
162}
163
164impl PlainCapture {
165    fn id(&self) -> &str {
166        match self {
167            PlainCapture::Sample { id, .. } | PlainCapture::Stream { id, .. } => id,
168        }
169    }
170}
171
172/// Live capture status for one run (smooth or plain), surfaced by
173/// `GET /api/timelapse`.
174#[derive(Clone, Default)]
175pub struct TimelapseStatus {
176    pub running: bool,
177    /// `"smooth"` (per-layer, park-synced) or `"plain"` (wall-time sampled).
178    pub mode: &'static str,
179    /// The cameras captured in this run (one frame each per trigger). Empty when
180    /// idle. `camera` (singular) is kept in the JSON for the common one-cam case.
181    pub cameras: Vec<String>,
182    /// Smooth: capture every Nth layer. Plain: 0 (uses `interval_ms` instead).
183    pub every: u64,
184    /// Plain: sampling period in ms. Smooth: `None` (it's layer-driven).
185    pub interval_ms: Option<u64>,
186    /// Total frames written across all cameras (minus skips).
187    pub frames: u64,
188    pub failures: u64,
189    pub current_layer: Option<i64>,
190    pub out_dir: Option<String>,
191    pub last_error: Option<String>,
192}
193
194impl TimelapseStatus {
195    pub fn to_json(&self) -> serde_json::Value {
196        serde_json::json!({
197            "running": self.running,
198            "mode": self.mode,
199            "cameras": self.cameras,
200            // Back-compat: surface the first camera as `camera` for the common
201            // single-camera case so older readers keep working.
202            "camera": self.cameras.first(),
203            "every": self.every,
204            "interval_ms": self.interval_ms,
205            "frames": self.frames,
206            "failures": self.failures,
207            "current_layer": self.current_layer,
208            "out_dir": self.out_dir,
209            "last_error": self.last_error,
210        })
211    }
212}
213
214#[derive(Default)]
215struct Inner {
216    /// Shared with the running task, which updates it; replaced on each `start`.
217    status: Arc<Mutex<TimelapseStatus>>,
218    handle: Option<JoinHandle<()>>,
219    /// Set on stop. The async run task is `abort`ed, but a `plain` run's blocking
220    /// stream-recorder workers can't be aborted — they watch this flag and exit.
221    cancel: Arc<AtomicBool>,
222}
223
224/// Owns up to two concurrent captures of the same print — a `smooth` one
225/// (per-layer, synced to the printer's park) and a `plain` one (sampled on a
226/// wall-time interval, head in shot). Each is started/stopped independently.
227/// Lives in `AppState`.
228#[derive(Default)]
229pub struct TimelapseManager {
230    smooth: Mutex<Inner>,
231    plain: Mutex<Inner>,
232    /// Live park-preview slot: one ffmpeg supervisor per tuned stream camera, lazily
233    /// started when the print is active. Independent of smooth/plain.
234    park: Mutex<Inner>,
235    /// Dense-stream segmented slot: like `park`, but each worker segments the continuous
236    /// stream by the live MQTT layer and median-subtract-selects the parked frame — the
237    /// robust capture for the brief native park. Independent of the others.
238    segment: Mutex<Inner>,
239}
240
241impl TimelapseManager {
242    /// Start the smooth (per-layer) capture: one frame per `every`-th layer from
243    /// each camera, written to `out_dir/<camera-id>/`.
244    pub fn start_smooth(
245        &self,
246        cameras: Vec<(String, FrameGrab)>,
247        every: u64,
248        burst_offsets: Vec<u64>,
249        rx: watch::Receiver<PrinterStatus>,
250        out_dir: PathBuf,
251    ) -> Result<(), String> {
252        self.start_smooth_with_select(cameras, every, burst_offsets, rx, out_dir, Vec::new())
253    }
254
255    /// Like [`start_smooth`](Self::start_smooth), plus per-camera burst-SELECTION tuning
256    /// (index-aligned with `cameras`; `None`/missing = no live selection). When a camera has
257    /// select tuning, after each layer's burst settles the run picks the parked frame and
258    /// publishes it as `park_*.jpg`/`parks.jsonl` in that camera's dir — so the live park
259    /// preview shows the clean timelapse DURING a smooth capture, and the finished run reads
260    /// back as a clean park recording.
261    pub fn start_smooth_with_select(
262        &self,
263        cameras: Vec<(String, FrameGrab)>,
264        every: u64,
265        burst_offsets: Vec<u64>,
266        rx: watch::Receiver<PrinterStatus>,
267        out_dir: PathBuf,
268        selects: Vec<Option<SelectTuning>>,
269    ) -> Result<(), String> {
270        let every = every.max(1);
271        // Sort + de-dup so duplicate offsets can't clobber each other's frame (and
272        // an empty spec still grabs once at the layer edge).
273        let burst_offsets = normalize_burst_offsets(burst_offsets);
274        let ids = cameras.iter().map(|(id, _)| id.clone()).collect();
275        start_slot(
276            &self.smooth,
277            cameras,
278            ids,
279            out_dir,
280            TimelapseStatus {
281                mode: "smooth",
282                every,
283                ..Default::default()
284            },
285            // The per-layer burst spawns short-lived delayed grabs; they honor
286            // `cancel` so none fire after the run is stopped.
287            move |status, cams, dir, cancel| {
288                tokio::spawn(run(
289                    status,
290                    rx,
291                    cams,
292                    dir,
293                    every,
294                    burst_offsets,
295                    selects,
296                    cancel,
297                ))
298            },
299        )
300    }
301
302    /// Start the plain (time-sampled) capture: one frame from each camera every
303    /// `interval_ms`, while the print is active.
304    pub fn start_plain(
305        &self,
306        cameras: Vec<PlainCapture>,
307        interval_ms: u64,
308        rx: watch::Receiver<PrinterStatus>,
309        out_dir: PathBuf,
310    ) -> Result<(), String> {
311        let interval_ms = interval_ms.max(1);
312        let ids = cameras.iter().map(|c| c.id().to_string()).collect();
313        start_slot(
314            &self.plain,
315            cameras,
316            ids,
317            out_dir,
318            TimelapseStatus {
319                mode: "plain",
320                interval_ms: Some(interval_ms),
321                ..Default::default()
322            },
323            move |status, caps, dir, cancel| {
324                tokio::spawn(run_plain(status, rx, caps, dir, interval_ms, cancel))
325            },
326        )
327    }
328
329    /// Start the live park-preview capture: for each tuned stream camera, lazily spawn
330    /// one ffmpeg supervisor (via `spawn_worker`) once the print is active; each emits
331    /// `latest_park.jpg` per layer under `out_dir/<camera-id>/`. `spawn_worker` is
332    /// injected so the lifecycle is testable without ffmpeg ([`real_park_spawn`] runs the
333    /// real one). Rejected if a park run is already active or `cameras` is empty.
334    pub fn start_park(
335        &self,
336        cameras: Vec<ParkCapture>,
337        rx: watch::Receiver<PrinterStatus>,
338        out_dir: PathBuf,
339        spawn_worker: ParkSpawn,
340    ) -> Result<(), String> {
341        let ids = cameras.iter().map(|c| c.id.clone()).collect();
342        start_slot(
343            &self.park,
344            cameras,
345            ids,
346            out_dir,
347            TimelapseStatus {
348                mode: "park",
349                ..Default::default()
350            },
351            move |status, caps, dir, cancel| {
352                tokio::spawn(run_park(status, rx, caps, dir, cancel, spawn_worker))
353            },
354        )
355    }
356
357    /// Start the dense-stream segmented capture: for each capable stream camera, lazily
358    /// spawn one ffmpeg supervisor (via `spawn_worker`) once the print is active. The
359    /// lifecycle maintains the live print layer (from MQTT `layer_num`) and feeds it to the
360    /// workers, which segment the stream per layer and publish the picked frame as
361    /// `latest_park.jpg`/`park_NNNNNN.jpg` (read by `/api/camera/{id}/park`, exactly like
362    /// `park`). `spawn_worker` is injected for testing; [`real_segment_spawn`] runs ffmpeg.
363    /// Rejected if a segment run is already active or `cameras` is empty.
364    pub fn start_segment(
365        &self,
366        cameras: Vec<SegmentCapture>,
367        rx: watch::Receiver<PrinterStatus>,
368        out_dir: PathBuf,
369        spawn_worker: SegmentSpawn,
370    ) -> Result<(), String> {
371        let ids = cameras.iter().map(|c| c.id.clone()).collect();
372        start_slot(
373            &self.segment,
374            cameras,
375            ids,
376            out_dir,
377            TimelapseStatus {
378                mode: "segment",
379                ..Default::default()
380            },
381            move |status, caps, dir, cancel| {
382                tokio::spawn(run_segment(status, rx, caps, dir, cancel, spawn_worker))
383            },
384        )
385    }
386
387    /// Stop the smooth capture (idempotent). Returns whether one was running.
388    pub fn stop_smooth(&self) -> bool {
389        stop_slot(&self.smooth)
390    }
391    /// Stop the plain capture (idempotent). Returns whether one was running.
392    pub fn stop_plain(&self) -> bool {
393        stop_slot(&self.plain)
394    }
395    /// Stop the live park-preview capture (idempotent). Returns whether one was running.
396    pub fn stop_park(&self) -> bool {
397        stop_slot(&self.park)
398    }
399    /// Stop the dense-stream segmented capture (idempotent). Returns whether one was running.
400    pub fn stop_segment(&self) -> bool {
401        stop_slot(&self.segment)
402    }
403
404    pub fn status_smooth(&self) -> TimelapseStatus {
405        self.smooth.lock().unwrap().status.lock().unwrap().clone()
406    }
407    pub fn status_plain(&self) -> TimelapseStatus {
408        self.plain.lock().unwrap().status.lock().unwrap().clone()
409    }
410    pub fn status_park(&self) -> TimelapseStatus {
411        self.park.lock().unwrap().status.lock().unwrap().clone()
412    }
413    pub fn status_segment(&self) -> TimelapseStatus {
414        self.segment.lock().unwrap().status.lock().unwrap().clone()
415    }
416}
417
418/// Shared start path for either slot: refuse if that slot is already running or
419/// no cameras are given, create the per-camera dirs, install a fresh status, and
420/// spawn the runner (`spawn` builds the right one — smooth or plain).
421fn start_slot<C>(
422    inner: &Mutex<Inner>,
423    cameras: Vec<C>,
424    ids: Vec<String>,
425    out_dir: PathBuf,
426    init: TimelapseStatus,
427    spawn: impl FnOnce(Arc<Mutex<TimelapseStatus>>, Vec<C>, PathBuf, Arc<AtomicBool>) -> JoinHandle<()>,
428) -> Result<(), String> {
429    let mut g = inner.lock().unwrap();
430    if g.status.lock().unwrap().running {
431        return Err(format!("a {} timelapse is already running", init.mode));
432    }
433    if ids.is_empty() {
434        return Err("no cameras to capture".to_string());
435    }
436    for id in &ids {
437        let dir = out_dir.join(id);
438        std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
439    }
440    let cancel = Arc::new(AtomicBool::new(false));
441    let status = Arc::new(Mutex::new(TimelapseStatus {
442        running: true,
443        cameras: ids,
444        out_dir: Some(out_dir.display().to_string()),
445        ..init
446    }));
447    let handle = spawn(status.clone(), cameras, out_dir, cancel.clone());
448    g.status = status;
449    g.handle = Some(handle);
450    g.cancel = cancel;
451    Ok(())
452}
453
454fn stop_slot(inner: &Mutex<Inner>) -> bool {
455    let mut g = inner.lock().unwrap();
456    // Signal blocking stream workers first (abort can't reach them), then abort
457    // the async task (which drops any in-flight snapshot grab).
458    g.cancel.store(true, Ordering::Relaxed);
459    if let Some(h) = g.handle.take() {
460        h.abort();
461    }
462    let mut s = g.status.lock().unwrap();
463    let was = s.running;
464    s.running = false;
465    was
466}
467
468/// Default per-layer park-capture burst (ms after the MQTT layer edge). The A1's
469/// native `time_lapse_gcode` parks the head at the far-left X-min *after*
470/// `layer_num` increments and holds it ~300 ms, so a single grab at the edge
471/// catches the head still over the print. Device calibration found the park lands
472/// at a widely VARIABLE delay, jittering layer-to-layer and DRIFTING LATER with print
473/// height — so the burst spans the range; one offset per layer lands in the park, and
474/// the per-layer selector picks it (or skips the layer). A full-benchy diagnosis
475/// (2026-06-21) found the selected parks cluster at the 1900 ms window EDGE while the
476/// skipped layers had no left excursion in 100–1900 ms at all — i.e. the park had drifted
477/// PAST the window on the taller layers. So the window now reaches 2900 ms. Each frame is
478/// tagged with its offset; override via `burst_offsets_ms`.
479pub const DEFAULT_SMOOTH_BURST_MS: &[u64] = &[
480    100, 300, 500, 700, 900, 1100, 1300, 1500, 1700, 1900, 2100, 2300, 2500, 2700, 2900,
481];
482
483/// `frame_<n>_layer_<L>_t<offset>.jpg`. The offset tag distinguishes a layer's
484/// burst samples and records which delay produced each one (for calibration).
485fn burst_frame_name(frame_no: u64, layer: i64, offset_ms: u64) -> String {
486    format!("frame_{frame_no:06}_layer_{layer:05}_t{offset_ms:04}.jpg")
487}
488
489/// Sanitize a burst spec before it drives filenames: sort and drop duplicate
490/// offsets. Two equal offsets map to the same `..._tNNNN.jpg` path, so the second
491/// grab would clobber the first while still counting a frame. An empty spec falls
492/// back to a single grab at the layer edge.
493fn normalize_burst_offsets(mut offsets: Vec<u64>) -> Vec<u64> {
494    offsets.sort_unstable();
495    offsets.dedup();
496    if offsets.is_empty() { vec![0] } else { offsets }
497}
498
499/// Where a burst's grabs go: the worker channel, the cameras, the status (for
500/// failure counts), the output dir, and the run's cancel flag. Built once per run
501/// and reused for every layer's burst; cheap to clone into each delayed task.
502#[derive(Clone)]
503struct BurstSink {
504    tx: tokio::sync::mpsc::Sender<(FrameGrab, PathBuf)>,
505    cameras: Arc<Vec<(String, FrameGrab)>>,
506    status: Arc<Mutex<TimelapseStatus>>,
507    out_dir: PathBuf,
508    cancel: Arc<AtomicBool>,
509}
510
511/// Schedule one layer's park-capture burst: for each `offset_ms`, spawn a delayed
512/// task that — unless `cancel` was set meanwhile — enqueues one grab per camera at
513/// that offset after the layer edge. Non-blocking: returns at once so the observe
514/// loop never stalls (the reason for the worker indirection). A full queue drops
515/// the late sample and counts a failure, exactly like the single-grab path. The
516/// spawned tasks outlive an `abort`, so they check `cancel` to stay quiet after stop.
517/// One camera's live per-layer selection state: where its frames live, how to pick the
518/// parked one, and a serialized [`ParkWriter`] that publishes picks (`park_*.jpg` +
519/// `parks.jsonl` + `latest_park.jpg`) into the same dir.
520struct LiveSelect {
521    cam_dir: PathBuf,
522    tuning: SelectTuning,
523    writer: Arc<Mutex<ParkWriter>>,
524}
525
526/// Grace after the last burst offset before selecting — lets the worker finish writing the
527/// burst's JPEGs to disk so [`select_layer_burst`](crate::captures::select_layer_burst) sees
528/// the whole burst.
529const FINALIZE_MARGIN_MS: u64 = 800;
530
531/// After a layer's burst settles, pick the parked frame per live-select camera and publish
532/// it (so the live park preview advances during a smooth capture). Spawned, delayed, and
533/// cancel-aware like the burst grabs; the heavy decode+select runs on the blocking pool and
534/// the per-camera ParkWriter serializes the write.
535fn schedule_finalize(
536    live: &Arc<Vec<LiveSelect>>,
537    frame_no: u64,
538    layer: i64,
539    max_offset: u64,
540    cancel: &Arc<AtomicBool>,
541) {
542    let live = live.clone();
543    let cancel = cancel.clone();
544    tokio::spawn(async move {
545        tokio::time::sleep(Duration::from_millis(max_offset + FINALIZE_MARGIN_MS)).await;
546        if cancel.load(Ordering::Relaxed) {
547            return; // the print stopped while this finalize was pending
548        }
549        for sel in live.iter() {
550            let cam_dir = sel.cam_dir.clone();
551            let tuning = sel.tuning;
552            let writer = sel.writer.clone();
553            // Decode + select off the async runtime; write under the per-camera lock.
554            let _ = tokio::task::spawn_blocking(move || {
555                if let Ok(Some((path, confidence))) =
556                    crate::captures::select_layer_burst(&cam_dir, layer, &tuning)
557                {
558                    let park = Park {
559                        idx: frame_no,
560                        t: layer as f64,
561                        left_mass: 0.0,
562                        sharpness: 0.0,
563                        confidence,
564                        replace: false,
565                    };
566                    let _ = writer.lock().unwrap().write(&park, &path);
567                }
568            })
569            .await;
570        }
571    });
572}
573
574fn schedule_burst(sink: &BurstSink, frame_no: u64, layer: i64, offsets: &[u64]) {
575    for &offset_ms in offsets {
576        let sink = sink.clone();
577        tokio::spawn(async move {
578            tokio::time::sleep(Duration::from_millis(offset_ms)).await;
579            if sink.cancel.load(Ordering::Relaxed) {
580                return; // the print stopped while this sample was pending
581            }
582            let name = burst_frame_name(frame_no, layer, offset_ms);
583            for (id, grab) in sink.cameras.iter() {
584                let path = sink.out_dir.join(id).join(&name);
585                if sink.tx.try_send((grab.clone(), path)).is_err() {
586                    // Worker busy/backlogged — skip this sample rather than block.
587                    let mut s = sink.status.lock().unwrap();
588                    s.failures += 1;
589                    s.last_error = Some("capture fell behind — frame skipped".to_string());
590                }
591            }
592        });
593    }
594}
595
596/// The capture task. The observe loop NEVER blocks on a frame grab: a slow or
597/// offline camera would otherwise stall it, and since `watch::Receiver` only
598/// keeps the latest value, intermediate layer updates would coalesce and be
599/// skipped. So observation just schedules capture jobs (non-blocking, bounded —
600/// dropped + counted if the grabbing worker can't keep up, rather than lagging
601/// the print or growing without bound); a worker grabs + writes off that path.
602/// Each layer fires a short [burst](schedule_burst) of grabs (one per offset)
603/// instead of a single one, to land a frame in the native park window.
604#[allow(clippy::too_many_arguments)]
605async fn run(
606    status: Arc<Mutex<TimelapseStatus>>,
607    mut rx: watch::Receiver<PrinterStatus>,
608    cameras: Vec<(String, FrameGrab)>,
609    out_dir: PathBuf,
610    every: u64,
611    burst_offsets: Vec<u64>,
612    selects: Vec<Option<SelectTuning>>,
613    cancel: Arc<AtomicBool>,
614) {
615    // wait=true: the capture may be started before the print is active; sit
616    // through idle/finished until it runs, then stop when the print ends.
617    let mut session = CaptureSession::new(every, true);
618    // Live per-layer selection publishers for cameras that have select tuning: each owns a
619    // serialized ParkWriter into out_dir/<id>/, so the live park preview shows the clean pick
620    // during the smooth capture. Empty (no tuning) → no live selection, classic smooth.
621    let live: Vec<LiveSelect> = cameras
622        .iter()
623        .enumerate()
624        .filter_map(|(i, (id, _))| {
625            selects.get(i).copied().flatten().map(|tuning| LiveSelect {
626                cam_dir: out_dir.join(id),
627                tuning,
628                writer: Arc::new(Mutex::new(ParkWriter::new(out_dir.join(id)))),
629            })
630        })
631        .collect();
632    let live = Arc::new(live);
633    let max_offset = burst_offsets.iter().copied().max().unwrap_or(0);
634    let cameras = Arc::new(cameras);
635    // Each layer enqueues one job per camera per burst offset; scale the bound so
636    // a layer's whole burst has headroom (samples are spread over time, but keep
637    // the same per-(camera,offset) backpressure margin as the single-grab case).
638    let bound = (4 * cameras.len() * burst_offsets.len().max(1)).max(8);
639    let (tx, mut jobs) = tokio::sync::mpsc::channel::<(FrameGrab, PathBuf)>(bound);
640
641    let wstatus = status.clone();
642    let worker = tokio::spawn(async move {
643        while let Some((grab, path)) = jobs.recv().await {
644            let res = tokio::task::spawn_blocking(move || grab()).await;
645            let mut s = wstatus.lock().unwrap();
646            match res {
647                Ok(Ok(bytes)) => match std::fs::write(&path, &bytes) {
648                    Ok(()) => s.frames += 1,
649                    Err(e) => {
650                        s.failures += 1;
651                        s.last_error = Some(format!("write {}: {e}", path.display()));
652                    }
653                },
654                Ok(Err(e)) => {
655                    s.failures += 1;
656                    s.last_error = Some(e);
657                }
658                Err(_) => {
659                    s.failures += 1;
660                    s.last_error = Some("frame grab task failed".to_string());
661                }
662            }
663        }
664    });
665
666    let sink = BurstSink {
667        tx,
668        cameras,
669        status: status.clone(),
670        out_dir,
671        cancel,
672    };
673    loop {
674        let snap = rx.borrow_and_update().clone();
675        status.lock().unwrap().current_layer = snap.layer_num;
676        match session.observe(&snap) {
677            CaptureAction::Capture { frame_no, layer } => {
678                schedule_burst(&sink, frame_no, layer, &burst_offsets);
679                if !live.is_empty() {
680                    schedule_finalize(&live, frame_no, layer, max_offset, &sink.cancel);
681                }
682            }
683            CaptureAction::Stop => break,
684            CaptureAction::Continue => {}
685        }
686        if rx.changed().await.is_err() {
687            break; // the source (and the whole server) is gone
688        }
689    }
690    // Drop our sender so the channel closes once the last pending burst task (each
691    // holds a clone) finishes — then the worker drains the backlog and exits.
692    drop(sink);
693    let _ = worker.await;
694    status.lock().unwrap().running = false;
695}
696
697/// The plain capture: a frame from each camera every `interval_ms` while the
698/// print is active (head wherever it is — the "watch it print" look), independent
699/// of layers/park. Same non-blocking grab path as [`run`]; reacts to status
700/// changes between ticks so it stops promptly when the print ends.
701/// ffmpeg argv to encode the MJPEG stream (read from stdin as `mpjpeg`) into an
702/// h264 mp4 at `out`. Pure, so the command shape is unit-tested without ffmpeg.
703fn live_mp4_args(out: &std::path::Path) -> Vec<String> {
704    vec![
705        "-y".into(),
706        "-f".into(),
707        "mpjpeg".into(),
708        "-i".into(),
709        "-".into(),
710        "-c:v".into(),
711        "libx264".into(),
712        "-pix_fmt".into(),
713        "yuv420p".into(),
714        "-movflags".into(),
715        "+faststart".into(),
716        out.display().to_string(),
717    ]
718}
719
720/// Spawn one blocking recorder per stream camera. Each copies its MJPEG stream,
721/// reconnecting on drops (interruptible backoff), until `cancel` is set. When
722/// ffmpeg is on PATH it pipes the stream straight into ffmpeg → a compact h264
723/// `<id>/plain.mp4` (the whole print fits — the raw bytes are never stored); with
724/// no ffmpeg it falls back to the raw `<id>/plain.mjpeg` bounded by a disk cap.
725fn spawn_stream_recorders(
726    streams: Vec<(String, StreamOpen)>,
727    out_dir: &std::path::Path,
728    status: &Arc<Mutex<TimelapseStatus>>,
729    cancel: &Arc<AtomicBool>,
730) -> Vec<JoinHandle<()>> {
731    streams
732        .into_iter()
733        .map(|(id, open)| {
734            let dir = out_dir.join(&id);
735            let mp4 = dir.join("plain.mp4");
736            let mjpeg = dir.join("plain.mjpeg");
737            let cancel = cancel.clone();
738            let wstatus = status.clone();
739            tokio::task::spawn_blocking(move || {
740                let cancel_fn = || cancel.load(Ordering::Relaxed);
741                // Interruptible reconnect backoff: sleep in small chunks so a stop
742                // is noticed within ~50ms rather than after the full (up to 5s) wait.
743                let backoff = |attempt: u32| {
744                    let total_ms = (500u64 * u64::from(attempt)).min(5_000);
745                    let mut slept = 0u64;
746                    while slept < total_ms && !cancel.load(Ordering::Relaxed) {
747                        std::thread::sleep(Duration::from_millis(50));
748                        slept += 50;
749                    }
750                };
751
752                // Live mp4 (pipe through ffmpeg) when available; else raw mjpeg.
753                let ffmpeg = std::process::Command::new("ffmpeg")
754                    .args(live_mp4_args(&mp4))
755                    .stdin(std::process::Stdio::piped())
756                    .stdout(std::process::Stdio::null())
757                    .stderr(std::process::Stdio::null())
758                    .spawn();
759                // (stats, output path, encode_ok). For the raw path encode_ok is
760                // vacuously true (write errors are already counted by record_loop);
761                // for the ffmpeg path it's ffmpeg's exit status.
762                let (stats, target, encode_ok) = match ffmpeg {
763                    Ok(mut child) => {
764                        let mut stdin = child.stdin.take().expect("piped stdin");
765                        let stats = record_loop(
766                            &open,
767                            &mut stdin,
768                            &cancel_fn,
769                            MAX_STREAM_INPUT_BYTES,
770                            32 * 1024,
771                            &backoff,
772                        );
773                        drop(stdin); // EOF → ffmpeg finalizes the mp4
774                        // If ffmpeg exits non-zero (no libx264, unsupported stream,
775                        // disk error) the mp4 is missing/corrupt — surface it rather
776                        // than report a silent success. The streamed bytes are gone,
777                        // so we can't retroactively fall back to raw .mjpeg.
778                        let ok = child.wait().map(|s| s.success()).unwrap_or(false);
779                        (stats, mp4, ok)
780                    }
781                    Err(_) => {
782                        let file = match std::fs::File::create(&mjpeg) {
783                            Ok(f) => f,
784                            Err(e) => {
785                                let mut s = wstatus.lock().unwrap();
786                                s.failures += 1;
787                                s.last_error = Some(format!("create {}: {e}", mjpeg.display()));
788                                return;
789                            }
790                        };
791                        let mut sink = std::io::BufWriter::new(file);
792                        let stats = record_loop(
793                            &open,
794                            &mut sink,
795                            &cancel_fn,
796                            MAX_STREAM_BYTES,
797                            32 * 1024,
798                            &backoff,
799                        );
800                        let _ = sink.flush();
801                        (stats, mjpeg, true)
802                    }
803                };
804                let mut s = wstatus.lock().unwrap();
805                s.failures += u64::from(stats.failures);
806                if stats.bytes == 0 {
807                    s.last_error = Some(format!(
808                        "stream {id}: no data recorded ({})",
809                        target.display()
810                    ));
811                } else if !encode_ok {
812                    s.failures += 1;
813                    s.last_error = Some(format!(
814                        "stream {id}: ffmpeg failed to encode {} (missing libx264, or bad stream)",
815                        target.display()
816                    ));
817                }
818            })
819        })
820        .collect()
821}
822
823async fn run_plain(
824    status: Arc<Mutex<TimelapseStatus>>,
825    mut rx: watch::Receiver<PrinterStatus>,
826    cameras: Vec<PlainCapture>,
827    out_dir: PathBuf,
828    interval_ms: u64,
829    cancel: Arc<AtomicBool>,
830) {
831    // Split by strategy: snapshot cameras tick on the interval; stream cameras get
832    // a long-lived blocking recorder each (the actual video, not samples).
833    let mut samples: Vec<(String, FrameGrab)> = Vec::new();
834    let mut streams: Vec<(String, StreamOpen)> = Vec::new();
835    for cap in cameras {
836        match cap {
837            PlainCapture::Sample { id, grab } => samples.push((id, grab)),
838            PlainCapture::Stream { id, open } => streams.push((id, open)),
839        }
840    }
841
842    // Stream recorders are spawned LAZILY — only once the print is actually active
843    // (the first `Capture`), like the sampled cameras — so we never record idle /
844    // pre-print video (or burn the byte cap on a print that never starts). Each
845    // then runs until `cancel` (print-end here, or stop_slot). They're blocking, so
846    // they watch the flag — they can't be `abort`ed like the async task.
847    let mut streams = streams;
848    let mut stream_workers: Vec<JoinHandle<()>> = Vec::new();
849
850    let mut activity = PrintActivitySession::new(true);
851    let bound = (4 * samples.len()).max(4);
852    let (tx, mut jobs) = tokio::sync::mpsc::channel::<(FrameGrab, PathBuf)>(bound);
853
854    let wstatus = status.clone();
855    let worker = tokio::spawn(async move {
856        while let Some((grab, path)) = jobs.recv().await {
857            let res = tokio::task::spawn_blocking(move || grab()).await;
858            let mut s = wstatus.lock().unwrap();
859            match res {
860                Ok(Ok(bytes)) => match std::fs::write(&path, &bytes) {
861                    Ok(()) => s.frames += 1,
862                    Err(e) => {
863                        s.failures += 1;
864                        s.last_error = Some(format!("write {}: {e}", path.display()));
865                    }
866                },
867                Ok(Err(e)) => {
868                    s.failures += 1;
869                    s.last_error = Some(e);
870                }
871                Err(_) => {
872                    s.failures += 1;
873                    s.last_error = Some("frame grab task failed".to_string());
874                }
875            }
876        }
877    });
878
879    let mut frame_no: u64 = 0;
880    let mut ticker = tokio::time::interval(std::time::Duration::from_millis(interval_ms));
881    // A slow grab batch shouldn't make the next ticks fire back-to-back to catch
882    // up; just resume the cadence from now.
883    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
884
885    loop {
886        tokio::select! {
887            _ = ticker.tick() => {
888                let snap = rx.borrow().clone();
889                status.lock().unwrap().current_layer = snap.layer_num;
890                match activity.observe(&snap) {
891                    ActivityAction::Capture => {
892                        // First active tick → start the stream recorders (lazily, so
893                        // pre-print idle isn't recorded). `take` empties `streams`, so
894                        // this runs exactly once.
895                        if !streams.is_empty() {
896                            stream_workers = spawn_stream_recorders(
897                                std::mem::take(&mut streams),
898                                &out_dir,
899                                &status,
900                                &cancel,
901                            );
902                        }
903                        frame_no += 1;
904                        let name = format!("frame_{frame_no:06}.jpg");
905                        for (id, grab) in &samples {
906                            let path = out_dir.join(id).join(&name);
907                            if tx.try_send((grab.clone(), path)).is_err() {
908                                let mut s = status.lock().unwrap();
909                                s.failures += 1;
910                                s.last_error = Some("capture fell behind — frame skipped".to_string());
911                            }
912                        }
913                    }
914                    ActivityAction::Idle => {}
915                    ActivityAction::Stop => break,
916                }
917            }
918            // Between ticks, notice the print ending (or the source going away) so
919            // we don't keep capturing a finished print for up to one interval.
920            changed = rx.changed() => {
921                if changed.is_err() {
922                    break;
923                }
924                let snap = rx.borrow().clone();
925                if activity.observe(&snap) == ActivityAction::Stop {
926                    break;
927                }
928            }
929        }
930    }
931    drop(tx);
932    let _ = worker.await;
933    // Tell the stream recorders to stop (print ended), then let them flush + exit.
934    cancel.store(true, Ordering::Relaxed);
935    for w in stream_workers {
936        let _ = w.await;
937    }
938    status.lock().unwrap().running = false;
939}
940
941/// The live park-preview slot's lifecycle: wait through idle (armed before the print),
942/// then on the first active status LAZILY spawn one worker per camera (via `spawn_worker`)
943/// — exactly once — and stop at FINISH/cancel, setting `cancel` so the blocking ffmpeg
944/// supervisors exit. It has no layer logic of its own: park timing comes from the camera
945/// stream (not MQTT), so this only gates start/stop on the print being active.
946async fn run_park(
947    status: Arc<Mutex<TimelapseStatus>>,
948    mut rx: watch::Receiver<PrinterStatus>,
949    captures: Vec<ParkCapture>,
950    out_dir: PathBuf,
951    cancel: Arc<AtomicBool>,
952    spawn_worker: ParkSpawn,
953) {
954    let mut activity = PrintActivitySession::new(true);
955    let mut pending = Some(captures); // spawned once, on the first active tick
956    let mut workers: Vec<JoinHandle<()>> = Vec::new();
957    loop {
958        let snap = rx.borrow_and_update().clone();
959        status.lock().unwrap().current_layer = snap.layer_num;
960        match activity.observe(&snap) {
961            ActivityAction::Capture => {
962                if let Some(caps) = pending.take() {
963                    for cap in caps {
964                        workers.push(spawn_worker(
965                            cap,
966                            out_dir.clone(),
967                            cancel.clone(),
968                            status.clone(),
969                        ));
970                    }
971                }
972            }
973            ActivityAction::Idle => {}
974            ActivityAction::Stop => break,
975        }
976        if rx.changed().await.is_err() {
977            break; // the source (and the whole server) is gone
978        }
979    }
980    // Tell the blocking ffmpeg supervisors to stop (the print ended), then let them exit.
981    cancel.store(true, Ordering::Relaxed);
982    for w in workers {
983        let _ = w.await;
984    }
985    status.lock().unwrap().running = false;
986}
987
988/// The dense-stream segmented slot's lifecycle: like [`run_park`], but it OWNS the live
989/// print layer. Each status tick it stores `layer_num` into a shared `Arc<AtomicI64>`
990/// (`-1` until the first reported layer) that every worker reads to segment its stream —
991/// the one piece the camera stream can't supply itself. It still lazily spawns one worker
992/// per camera on the first active status and stops at FINISH/cancel.
993async fn run_segment(
994    status: Arc<Mutex<TimelapseStatus>>,
995    mut rx: watch::Receiver<PrinterStatus>,
996    captures: Vec<SegmentCapture>,
997    out_dir: PathBuf,
998    cancel: Arc<AtomicBool>,
999    spawn_worker: SegmentSpawn,
1000) {
1001    let current_layer = Arc::new(AtomicI64::new(-1));
1002    let mut activity = PrintActivitySession::new(true);
1003    let mut pending = Some(captures); // spawned once, on the first active tick
1004    let mut workers: Vec<JoinHandle<()>> = Vec::new();
1005    loop {
1006        let snap = rx.borrow_and_update().clone();
1007        // Feed the live layer BEFORE (maybe) spawning workers, so a worker never reads the
1008        // initial -1 once a layer is known.
1009        if let Some(l) = snap.layer_num {
1010            current_layer.store(l, Ordering::Relaxed);
1011        }
1012        status.lock().unwrap().current_layer = snap.layer_num;
1013        match activity.observe(&snap) {
1014            ActivityAction::Capture => {
1015                if let Some(caps) = pending.take() {
1016                    for cap in caps {
1017                        workers.push(spawn_worker(
1018                            cap,
1019                            out_dir.clone(),
1020                            current_layer.clone(),
1021                            cancel.clone(),
1022                            status.clone(),
1023                        ));
1024                    }
1025                }
1026            }
1027            ActivityAction::Idle => {}
1028            ActivityAction::Stop => break,
1029        }
1030        if rx.changed().await.is_err() {
1031            break; // the source (and the whole server) is gone
1032        }
1033    }
1034    // Tell the blocking ffmpeg supervisors to stop (the print ended), then let them exit.
1035    cancel.store(true, Ordering::Relaxed);
1036    for w in workers {
1037        let _ = w.await;
1038    }
1039    status.lock().unwrap().running = false;
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044    use super::*;
1045    use crate::core::park::ParkTuning;
1046    use crate::core::status::PrinterStatus;
1047    use std::sync::atomic::AtomicUsize;
1048
1049    fn st(state: &str, layer: Option<i64>) -> PrinterStatus {
1050        PrinterStatus {
1051            gcode_state: Some(state.to_string()),
1052            layer_num: layer,
1053            ..Default::default()
1054        }
1055    }
1056
1057    fn one(id: &str, grab: FrameGrab) -> Vec<(String, FrameGrab)> {
1058        vec![(id.to_string(), grab)]
1059    }
1060
1061    /// One snapshot-only camera for a plain run.
1062    fn sample(id: &str, grab: FrameGrab) -> Vec<PlainCapture> {
1063        vec![PlainCapture::Sample {
1064            id: id.to_string(),
1065            grab,
1066        }]
1067    }
1068
1069    // A capture driven by a fake status channel + a fake in-memory camera, end to
1070    // end through the manager — no MQTT, no real camera, no network.
1071    #[tokio::test]
1072    async fn runs_a_capture_from_a_watch_feed_writing_one_frame_per_layer() {
1073        let dir = std::env::temp_dir().join(format!("bambu-tl-test-{}", std::process::id()));
1074        let _ = std::fs::remove_dir_all(&dir);
1075        let (tx, rx) = watch::channel(st("IDLE", None));
1076        let grab: FrameGrab = Arc::new(|| Ok(vec![0xff, 0xd8, 0xff, 0x42]));
1077        let mgr = TimelapseManager::default();
1078        mgr.start_smooth(one("ext-0", grab), 1, vec![0], rx, dir.clone())
1079            .unwrap();
1080
1081        // Drive: print starts and advances three layers, then finishes.
1082        for s in [
1083            st("RUNNING", Some(1)),
1084            st("RUNNING", Some(2)),
1085            st("RUNNING", Some(3)),
1086            st("FINISH", Some(3)),
1087        ] {
1088            tx.send(s).unwrap();
1089            tokio::time::sleep(std::time::Duration::from_millis(40)).await;
1090        }
1091        tokio::time::sleep(std::time::Duration::from_millis(80)).await;
1092
1093        let s = mgr.status_smooth();
1094        assert!(
1095            !s.running,
1096            "capture should auto-stop when the print finishes"
1097        );
1098        assert_eq!(s.frames, 3, "one frame per advancing layer");
1099        assert_eq!(s.failures, 0);
1100        let n = std::fs::read_dir(dir.join("ext-0")).unwrap().count();
1101        assert_eq!(n, 3, "three JPEG files written under the camera's subdir");
1102        let _ = std::fs::remove_dir_all(&dir);
1103    }
1104
1105    #[tokio::test]
1106    async fn captures_every_camera_once_per_layer_into_per_camera_subdirs() {
1107        let dir = std::env::temp_dir().join(format!("bambu-tl-multi-{}", std::process::id()));
1108        let _ = std::fs::remove_dir_all(&dir);
1109        let (tx, rx) = watch::channel(st("IDLE", None));
1110        let g: FrameGrab = Arc::new(|| Ok(vec![0xff, 0xd8, 0xff, 0x01]));
1111        let mgr = TimelapseManager::default();
1112        mgr.start_smooth(
1113            vec![("ext-0".into(), g.clone()), ("ext-1".into(), g)],
1114            1,
1115            vec![0],
1116            rx,
1117            dir.clone(),
1118        )
1119        .unwrap();
1120        for s in [
1121            st("RUNNING", Some(1)),
1122            st("RUNNING", Some(2)),
1123            st("FINISH", Some(2)),
1124        ] {
1125            tx.send(s).unwrap();
1126            tokio::time::sleep(std::time::Duration::from_millis(40)).await;
1127        }
1128        tokio::time::sleep(std::time::Duration::from_millis(80)).await;
1129
1130        let s = mgr.status_smooth();
1131        assert_eq!(s.cameras, vec!["ext-0".to_string(), "ext-1".to_string()]);
1132        assert_eq!(s.frames, 4, "2 layers × 2 cameras");
1133        assert_eq!(s.failures, 0);
1134        assert_eq!(std::fs::read_dir(dir.join("ext-0")).unwrap().count(), 2);
1135        assert_eq!(std::fs::read_dir(dir.join("ext-1")).unwrap().count(), 2);
1136        let _ = std::fs::remove_dir_all(&dir);
1137    }
1138
1139    #[tokio::test]
1140    async fn start_twice_is_rejected_until_stopped() {
1141        let dir = std::env::temp_dir().join(format!("bambu-tl-test2-{}", std::process::id()));
1142        let (_tx, rx) = watch::channel(st("RUNNING", Some(0)));
1143        let grab: FrameGrab = Arc::new(|| Ok(vec![0xff, 0xd8, 0xff]));
1144        let mgr = TimelapseManager::default();
1145        mgr.start_smooth(
1146            one("ext-0", grab.clone()),
1147            1,
1148            vec![0],
1149            rx.clone(),
1150            dir.clone(),
1151        )
1152        .unwrap();
1153        assert!(
1154            mgr.start_smooth(one("ext-1", grab), 1, vec![0], rx, dir.clone())
1155                .is_err()
1156        );
1157        assert!(mgr.stop_smooth());
1158        let _ = std::fs::remove_dir_all(&dir);
1159    }
1160
1161    #[tokio::test]
1162    async fn start_with_no_cameras_is_rejected() {
1163        let dir = std::env::temp_dir().join(format!("bambu-tl-empty-{}", std::process::id()));
1164        let (_tx, rx) = watch::channel(st("RUNNING", Some(0)));
1165        let mgr = TimelapseManager::default();
1166        assert!(
1167            mgr.start_smooth(vec![], 1, vec![0], rx, dir).is_err(),
1168            "need at least one camera"
1169        );
1170    }
1171
1172    #[tokio::test]
1173    async fn a_failing_grab_counts_failures_and_keeps_going() {
1174        let dir = std::env::temp_dir().join(format!("bambu-tl-test3-{}", std::process::id()));
1175        let _ = std::fs::remove_dir_all(&dir);
1176        let (tx, rx) = watch::channel(st("RUNNING", Some(0)));
1177        let grab: FrameGrab = Arc::new(|| Err("camera offline".to_string()));
1178        let mgr = TimelapseManager::default();
1179        mgr.start_smooth(one("ext-0", grab), 1, vec![0], rx, dir.clone())
1180            .unwrap();
1181        for s in [
1182            st("RUNNING", Some(1)),
1183            st("RUNNING", Some(2)),
1184            st("FINISH", Some(2)),
1185        ] {
1186            tx.send(s).unwrap();
1187            tokio::time::sleep(std::time::Duration::from_millis(40)).await;
1188        }
1189        tokio::time::sleep(std::time::Duration::from_millis(80)).await;
1190        let s = mgr.status_smooth();
1191        assert!(s.failures >= 2, "grab failures are counted");
1192        assert_eq!(s.frames, 0, "no files on failure");
1193        assert!(s.last_error.is_some());
1194        let _ = std::fs::remove_dir_all(&dir);
1195    }
1196
1197    // ── smooth park-capture burst ──
1198    #[test]
1199    fn burst_frame_name_tags_frame_layer_and_offset() {
1200        assert_eq!(
1201            super::burst_frame_name(1, 5, 800),
1202            "frame_000001_layer_00005_t0800.jpg"
1203        );
1204        assert_eq!(
1205            super::burst_frame_name(12, 240, 0),
1206            "frame_000012_layer_00240_t0000.jpg"
1207        );
1208    }
1209
1210    #[test]
1211    fn normalize_burst_offsets_sorts_dedups_and_defaults_empty() {
1212        // Duplicates would collide on the same `_tNNNN.jpg` filename.
1213        assert_eq!(
1214            super::normalize_burst_offsets(vec![800, 400, 800, 600]),
1215            vec![400, 600, 800]
1216        );
1217        assert_eq!(super::normalize_burst_offsets(vec![500, 500]), vec![500]);
1218        assert_eq!(super::normalize_burst_offsets(vec![]), vec![0]);
1219    }
1220
1221    // The burst must enqueue one grab per offset, each at its own delay after the
1222    // layer edge — never all at once at the edge (the bug). Paused time + advance
1223    // checks the schedule without real sleeps or a camera.
1224    #[tokio::test(start_paused = true)]
1225    async fn burst_enqueues_one_grab_per_offset_at_its_due_time() {
1226        let (tx, mut rx) = tokio::sync::mpsc::channel::<(FrameGrab, PathBuf)>(64);
1227        let g: FrameGrab = Arc::new(|| Ok(vec![0xff, 0xd8, 0xff]));
1228        let cameras = Arc::new(vec![("ext-0".to_string(), g)]);
1229        let status = Arc::new(Mutex::new(TimelapseStatus::default()));
1230        let cancel = Arc::new(AtomicBool::new(false));
1231        let sink = super::BurstSink {
1232            tx,
1233            cameras,
1234            status,
1235            out_dir: std::path::PathBuf::from("/cap"),
1236            cancel,
1237        };
1238        super::schedule_burst(&sink, 1, 5, &[10, 30]);
1239        tokio::task::yield_now().await; // let the spawned tasks arm their timers at t=0
1240
1241        assert!(
1242            rx.try_recv().is_err(),
1243            "nothing is due before the first offset"
1244        );
1245        tokio::time::advance(Duration::from_millis(10)).await;
1246        tokio::task::yield_now().await;
1247        let (_g, p) = rx.try_recv().expect("first sample due at 10ms");
1248        assert!(
1249            p.ends_with("frame_000001_layer_00005_t0010.jpg"),
1250            "{}",
1251            p.display()
1252        );
1253        assert!(rx.try_recv().is_err(), "the 30ms sample is not due yet");
1254
1255        tokio::time::advance(Duration::from_millis(20)).await;
1256        tokio::task::yield_now().await;
1257        let (_g, p) = rx.try_recv().expect("second sample due at 30ms");
1258        assert!(
1259            p.ends_with("frame_000001_layer_00005_t0030.jpg"),
1260            "{}",
1261            p.display()
1262        );
1263    }
1264
1265    // A burst scheduled before the run is stopped must not grab afterwards: the
1266    // delayed tasks outlive the abort, so they honor `cancel`.
1267    #[tokio::test(start_paused = true)]
1268    async fn a_cancelled_burst_enqueues_nothing() {
1269        let (tx, mut rx) = tokio::sync::mpsc::channel::<(FrameGrab, PathBuf)>(8);
1270        let g: FrameGrab = Arc::new(|| Ok(vec![0xff, 0xd8, 0xff]));
1271        let cameras = Arc::new(vec![("ext-0".to_string(), g)]);
1272        let status = Arc::new(Mutex::new(TimelapseStatus::default()));
1273        let cancel = Arc::new(AtomicBool::new(true)); // already stopped
1274        let sink = super::BurstSink {
1275            tx,
1276            cameras,
1277            status,
1278            out_dir: std::path::PathBuf::from("/cap"),
1279            cancel,
1280        };
1281        super::schedule_burst(&sink, 1, 5, &[10]);
1282        tokio::task::yield_now().await;
1283        tokio::time::advance(Duration::from_millis(20)).await;
1284        tokio::task::yield_now().await;
1285        assert!(
1286            rx.try_recv().is_err(),
1287            "a burst that fires after stop must not grab"
1288        );
1289    }
1290
1291    // ── plain (time-sampled) capture ──
1292    #[tokio::test]
1293    async fn plain_samples_frames_on_an_interval_while_printing() {
1294        let dir = std::env::temp_dir().join(format!("bambu-tl-plain-{}", std::process::id()));
1295        let _ = std::fs::remove_dir_all(&dir);
1296        let (tx, rx) = watch::channel(st("IDLE", None));
1297        let grab: FrameGrab = Arc::new(|| Ok(vec![0xff, 0xd8, 0xff, 0x09]));
1298        let mgr = TimelapseManager::default();
1299        mgr.start_plain(sample("ext-0", grab), 20, rx, dir.clone())
1300            .unwrap();
1301
1302        // Idle → nothing is sampled (it waits for the print like the smooth one).
1303        tokio::time::sleep(std::time::Duration::from_millis(60)).await;
1304        assert_eq!(
1305            mgr.status_plain().frames,
1306            0,
1307            "no sampling before the print is active"
1308        );
1309
1310        // Printing → frames accumulate on the ~20ms clock, NOT per layer (the
1311        // layer never changes here, yet several frames land).
1312        tx.send(st("RUNNING", Some(1))).unwrap();
1313        tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1314        let mid = mgr.status_plain().frames;
1315        assert!(
1316            mid >= 2,
1317            "plain samples on its own clock while printing (got {mid})"
1318        );
1319
1320        // Finishing stops it promptly (the changed-feed path, not a whole interval).
1321        tx.send(st("FINISH", Some(1))).unwrap();
1322        tokio::time::sleep(std::time::Duration::from_millis(80)).await;
1323        assert!(
1324            !mgr.status_plain().running,
1325            "plain stops when the print finishes"
1326        );
1327        let _ = std::fs::remove_dir_all(&dir);
1328    }
1329
1330    #[tokio::test]
1331    async fn plain_stream_recorder_starts_and_stops_with_the_print() {
1332        // The recorder is a blocking worker driven by `cancel`; verify the
1333        // lifecycle (spawns once active, exits cleanly when the print finishes).
1334        // The output is ffmpeg-mp4 when ffmpeg is present, raw .mjpeg otherwise, so
1335        // this asserts the cancellation, not the bytes (record_loop's copy is
1336        // unit-tested in stream_record; the real encode is verified on-device).
1337        use crate::server::camera::{OpenedCameraStream, StreamOpen};
1338        let dir = std::env::temp_dir().join(format!("bambu-tl-stream-{}", std::process::id()));
1339        let _ = std::fs::remove_dir_all(&dir);
1340        let (tx, rx) = watch::channel(st("RUNNING", Some(1)));
1341        let open: StreamOpen = Arc::new(|| {
1342            Ok(OpenedCameraStream {
1343                content_type: "multipart/x-mixed-replace".to_string(),
1344                reader: Box::new(std::io::Cursor::new(b"JPEGDATA".to_vec())),
1345            })
1346        });
1347        let caps = vec![PlainCapture::Stream {
1348            id: "ext-1".to_string(),
1349            open,
1350        }];
1351        let mgr = TimelapseManager::default();
1352        mgr.start_plain(caps, 20, rx, dir.clone()).unwrap();
1353        assert!(dir.join("ext-1").is_dir(), "per-camera dir created");
1354
1355        tokio::time::sleep(std::time::Duration::from_millis(80)).await;
1356        tx.send(st("FINISH", Some(1))).unwrap();
1357        tokio::time::sleep(std::time::Duration::from_millis(250)).await;
1358        assert!(
1359            !mgr.status_plain().running,
1360            "stream recorder stops cleanly when the print finishes"
1361        );
1362        let _ = std::fs::remove_dir_all(&dir);
1363    }
1364
1365    #[test]
1366    fn live_mp4_args_pipe_mpjpeg_stdin_to_h264() {
1367        let args = super::live_mp4_args(std::path::Path::new("/cap/ext-1/plain.mp4"));
1368        let joined = args.join(" ");
1369        assert!(joined.contains("-f mpjpeg"), "{joined}");
1370        assert!(
1371            joined.contains("-i -"),
1372            "reads the stream from stdin: {joined}"
1373        );
1374        assert!(joined.contains("libx264"));
1375        assert!(joined.trim_end().ends_with("/cap/ext-1/plain.mp4"));
1376    }
1377
1378    #[tokio::test]
1379    async fn smooth_and_plain_run_concurrently_and_stop_independently() {
1380        let dir = std::env::temp_dir().join(format!("bambu-tl-both-{}", std::process::id()));
1381        let _ = std::fs::remove_dir_all(&dir);
1382        let (tx, rx) = watch::channel(st("IDLE", None));
1383        let g: FrameGrab = Arc::new(|| Ok(vec![0xff, 0xd8, 0xff, 0x01]));
1384        let mgr = TimelapseManager::default();
1385        // Different slots → neither rejects the other (unlike start-twice).
1386        mgr.start_smooth(
1387            one("ext-0", g.clone()),
1388            1,
1389            vec![0],
1390            rx.clone(),
1391            dir.join("smooth"),
1392        )
1393        .unwrap();
1394        mgr.start_plain(sample("ext-0", g), 20, rx, dir.join("plain"))
1395            .unwrap();
1396        assert!(mgr.status_smooth().running && mgr.status_plain().running);
1397
1398        tx.send(st("RUNNING", Some(1))).unwrap();
1399        tokio::time::sleep(std::time::Duration::from_millis(60)).await;
1400        tx.send(st("RUNNING", Some(2))).unwrap();
1401        tokio::time::sleep(std::time::Duration::from_millis(60)).await;
1402        assert!(mgr.status_smooth().frames >= 1, "smooth captured layers");
1403        assert!(mgr.status_plain().frames >= 2, "plain sampled its interval");
1404
1405        // Stopping one leaves the other running.
1406        assert!(mgr.stop_smooth());
1407        assert!(!mgr.status_smooth().running);
1408        assert!(
1409            mgr.status_plain().running,
1410            "plain keeps running after smooth stops"
1411        );
1412        assert!(mgr.stop_plain());
1413        let _ = std::fs::remove_dir_all(&dir);
1414    }
1415
1416    // ── live park-preview slot (injected fake worker, no ffmpeg) ──
1417    fn park_cap(id: &str) -> ParkCapture {
1418        ParkCapture {
1419            id: id.to_string(),
1420            stream_url: "http://cam/stream".to_string(),
1421            tuning: ParkTuning {
1422                fps: 4.0,
1423                left_frac: 0.33,
1424                ema_seconds: 30.0,
1425                abs_floor: 1500.0,
1426                mad_k: 6.0,
1427                merge_gap_s: 1.2,
1428                max_island_s: 3.0,
1429                min_sep_s: 3.0,
1430                candidate_frac: 0.75,
1431                warmup_s: 4.0,
1432                baseline_s: 90.0,
1433            },
1434        }
1435    }
1436
1437    #[tokio::test]
1438    async fn park_spawns_one_worker_per_camera_on_active_and_stops_at_finish() {
1439        let dir = std::env::temp_dir().join(format!("bambu-park-slot-{}", std::process::id()));
1440        let _ = std::fs::remove_dir_all(&dir);
1441        let (tx, rx) = watch::channel(st("IDLE", None));
1442        let spawned = Arc::new(AtomicUsize::new(0));
1443        let spawn_worker: ParkSpawn = {
1444            let spawned = spawned.clone();
1445            Arc::new(
1446                move |_cap, _dir, cancel: Arc<AtomicBool>, status: Arc<Mutex<TimelapseStatus>>| {
1447                    spawned.fetch_add(1, Ordering::SeqCst);
1448                    tokio::task::spawn_blocking(move || {
1449                        status.lock().unwrap().frames += 1; // a fake "park"
1450                        while !cancel.load(Ordering::Relaxed) {
1451                            std::thread::sleep(std::time::Duration::from_millis(10));
1452                        }
1453                    })
1454                },
1455            )
1456        };
1457        let mgr = TimelapseManager::default();
1458        mgr.start_park(
1459            vec![park_cap("ext-0"), park_cap("ext-1")],
1460            rx,
1461            dir.clone(),
1462            spawn_worker,
1463        )
1464        .unwrap();
1465
1466        // Idle → nothing spawned yet (armed, waiting for the print).
1467        tokio::time::sleep(std::time::Duration::from_millis(40)).await;
1468        assert_eq!(spawned.load(Ordering::SeqCst), 0, "no workers while idle");
1469
1470        // Active → one worker per camera, exactly once.
1471        tx.send(st("RUNNING", Some(1))).unwrap();
1472        tokio::time::sleep(std::time::Duration::from_millis(60)).await;
1473        assert_eq!(spawned.load(Ordering::SeqCst), 2, "one per camera");
1474        tx.send(st("RUNNING", Some(2))).unwrap(); // another active tick
1475        tokio::time::sleep(std::time::Duration::from_millis(40)).await;
1476        assert_eq!(
1477            spawned.load(Ordering::SeqCst),
1478            2,
1479            "spawned once, not per tick"
1480        );
1481        assert_eq!(mgr.status_park().frames, 2);
1482
1483        // Finish → cancel → the fake workers exit → the slot stops.
1484        tx.send(st("FINISH", Some(2))).unwrap();
1485        tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1486        assert!(
1487            !mgr.status_park().running,
1488            "park stops when the print finishes"
1489        );
1490        let _ = std::fs::remove_dir_all(&dir);
1491    }
1492
1493    #[tokio::test]
1494    async fn park_with_no_cameras_is_rejected() {
1495        let dir = std::env::temp_dir().join(format!("bambu-park-empty-{}", std::process::id()));
1496        let (_tx, rx) = watch::channel(st("RUNNING", Some(0)));
1497        let mgr = TimelapseManager::default();
1498        let noop: ParkSpawn = Arc::new(|_, _, _, _| tokio::task::spawn_blocking(|| {}));
1499        assert!(
1500            mgr.start_park(vec![], rx, dir, noop).is_err(),
1501            "need at least one camera"
1502        );
1503    }
1504
1505    // ── dense-stream segmented slot (injected fake worker, no ffmpeg) ──
1506    fn segment_cap(id: &str) -> SegmentCapture {
1507        SegmentCapture {
1508            id: id.to_string(),
1509            stream_url: "http://cam/stream".to_string(),
1510            fps: 10.0,
1511            window_ms: 3000,
1512            select_tuning: SelectTuning {
1513                left_frac: 0.33,
1514                min_outlier: 2.5,
1515                min_left_density: 3.0,
1516                select_candidate_frac: 0.6,
1517                min_confidence: 0.40,
1518            },
1519        }
1520    }
1521
1522    #[tokio::test]
1523    async fn segment_spawns_per_camera_and_feeds_the_live_layer() {
1524        let dir = std::env::temp_dir().join(format!("bambu-seg-slot-{}", std::process::id()));
1525        let _ = std::fs::remove_dir_all(&dir);
1526        let (tx, rx) = watch::channel(st("IDLE", None));
1527        let spawned = Arc::new(AtomicUsize::new(0));
1528        // Each fake worker captures the SHARED layer atomic; the test reads it back to prove
1529        // the lifecycle feeds MQTT layer_num through to the worker.
1530        let seen_layer = Arc::new(AtomicI64::new(i64::MIN));
1531        let spawn_worker: SegmentSpawn = {
1532            let (spawned, seen_layer) = (spawned.clone(), seen_layer.clone());
1533            Arc::new(
1534                move |_cap,
1535                      _dir,
1536                      current_layer: Arc<AtomicI64>,
1537                      cancel: Arc<AtomicBool>,
1538                      status: Arc<Mutex<TimelapseStatus>>| {
1539                    spawned.fetch_add(1, Ordering::SeqCst);
1540                    status.lock().unwrap().frames += 1; // a fake "park"
1541                    let seen_layer = seen_layer.clone();
1542                    tokio::task::spawn_blocking(move || {
1543                        while !cancel.load(Ordering::Relaxed) {
1544                            // Mirror what run_segment_camera does: read the live layer.
1545                            seen_layer
1546                                .store(current_layer.load(Ordering::Relaxed), Ordering::SeqCst);
1547                            std::thread::sleep(std::time::Duration::from_millis(5));
1548                        }
1549                    })
1550                },
1551            )
1552        };
1553        let mgr = TimelapseManager::default();
1554        mgr.start_segment(
1555            vec![segment_cap("ext-0"), segment_cap("ext-1")],
1556            rx,
1557            dir.clone(),
1558            spawn_worker,
1559        )
1560        .unwrap();
1561
1562        // Idle → nothing spawned yet (armed, waiting for the print).
1563        tokio::time::sleep(std::time::Duration::from_millis(40)).await;
1564        assert_eq!(spawned.load(Ordering::SeqCst), 0, "no workers while idle");
1565
1566        // Active + advancing layers → one worker per camera (once), and the live layer
1567        // propagates to the workers.
1568        tx.send(st("RUNNING", Some(7))).unwrap();
1569        tokio::time::sleep(std::time::Duration::from_millis(40)).await;
1570        tx.send(st("RUNNING", Some(8))).unwrap();
1571        tokio::time::sleep(std::time::Duration::from_millis(40)).await;
1572        assert_eq!(
1573            spawned.load(Ordering::SeqCst),
1574            2,
1575            "one per camera, spawned once"
1576        );
1577        assert_eq!(mgr.status_segment().frames, 2);
1578        assert_eq!(
1579            seen_layer.load(Ordering::SeqCst),
1580            8,
1581            "the worker reads the latest MQTT layer through the shared atomic"
1582        );
1583
1584        // Finish → cancel → the fake workers exit → the slot stops.
1585        tx.send(st("FINISH", Some(8))).unwrap();
1586        tokio::time::sleep(std::time::Duration::from_millis(120)).await;
1587        assert!(
1588            !mgr.status_segment().running,
1589            "segment stops when the print finishes"
1590        );
1591        let _ = std::fs::remove_dir_all(&dir);
1592    }
1593
1594    #[tokio::test]
1595    async fn segment_with_no_cameras_is_rejected() {
1596        let dir = std::env::temp_dir().join(format!("bambu-seg-empty-{}", std::process::id()));
1597        let (_tx, rx) = watch::channel(st("RUNNING", Some(0)));
1598        let mgr = TimelapseManager::default();
1599        let noop: SegmentSpawn = Arc::new(|_, _, _, _, _| tokio::task::spawn_blocking(|| {}));
1600        assert!(
1601            mgr.start_segment(vec![], rx, dir, noop).is_err(),
1602            "need at least one camera"
1603        );
1604    }
1605}