1use 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
30pub type ParkSpawn = Arc<
34 dyn Fn(ParkCapture, PathBuf, Arc<AtomicBool>, Arc<Mutex<TimelapseStatus>>) -> JoinHandle<()>
35 + Send
36 + Sync,
37>;
38
39pub 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
56pub 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
73pub 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 ¤t_layer,
92 &cancel,
93 &mut on_park,
94 );
95 report_park_run(&cap.id, &cap.stream_url, outcome, &status);
96 })
97 },
98 )
99}
100
101fn 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
118fn 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
141pub type FrameGrab = Arc<dyn Fn() -> Result<Vec<u8>, String> + Send + Sync>;
145
146const MAX_STREAM_BYTES: u64 = 2 * 1024 * 1024 * 1024;
149
150const MAX_STREAM_INPUT_BYTES: u64 = 64 * 1024 * 1024 * 1024;
154
155pub 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#[derive(Clone, Default)]
175pub struct TimelapseStatus {
176 pub running: bool,
177 pub mode: &'static str,
179 pub cameras: Vec<String>,
182 pub every: u64,
184 pub interval_ms: Option<u64>,
186 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 "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 status: Arc<Mutex<TimelapseStatus>>,
218 handle: Option<JoinHandle<()>>,
219 cancel: Arc<AtomicBool>,
222}
223
224#[derive(Default)]
229pub struct TimelapseManager {
230 smooth: Mutex<Inner>,
231 plain: Mutex<Inner>,
232 park: Mutex<Inner>,
235 segment: Mutex<Inner>,
239}
240
241impl TimelapseManager {
242 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 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 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 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 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 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 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 pub fn stop_smooth(&self) -> bool {
389 stop_slot(&self.smooth)
390 }
391 pub fn stop_plain(&self) -> bool {
393 stop_slot(&self.plain)
394 }
395 pub fn stop_park(&self) -> bool {
397 stop_slot(&self.park)
398 }
399 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
418fn 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 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
468pub const DEFAULT_SMOOTH_BURST_MS: &[u64] = &[
480 100, 300, 500, 700, 900, 1100, 1300, 1500, 1700, 1900, 2100, 2300, 2500, 2700, 2900,
481];
482
483fn 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
489fn 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#[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
511struct LiveSelect {
521 cam_dir: PathBuf,
522 tuning: SelectTuning,
523 writer: Arc<Mutex<ParkWriter>>,
524}
525
526const FINALIZE_MARGIN_MS: u64 = 800;
530
531fn 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; }
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 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; }
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 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#[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 let mut session = CaptureSession::new(every, true);
618 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 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; }
689 }
690 drop(sink);
693 let _ = worker.await;
694 status.lock().unwrap().running = false;
695}
696
697fn 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
720fn 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 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 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 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); 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 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 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 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 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 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 cancel.store(true, Ordering::Relaxed);
935 for w in stream_workers {
936 let _ = w.await;
937 }
938 status.lock().unwrap().running = false;
939}
940
941async 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); 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; }
979 }
980 cancel.store(true, Ordering::Relaxed);
982 for w in workers {
983 let _ = w.await;
984 }
985 status.lock().unwrap().running = false;
986}
987
988async 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); let mut workers: Vec<JoinHandle<()>> = Vec::new();
1005 loop {
1006 let snap = rx.borrow_and_update().clone();
1007 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; }
1033 }
1034 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 fn sample(id: &str, grab: FrameGrab) -> Vec<PlainCapture> {
1063 vec![PlainCapture::Sample {
1064 id: id.to_string(),
1065 grab,
1066 }]
1067 }
1068
1069 #[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 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 #[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 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 #[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; 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 #[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)); 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 #[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 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 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 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 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 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 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 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; 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 tokio::time::sleep(std::time::Duration::from_millis(40)).await;
1468 assert_eq!(spawned.load(Ordering::SeqCst), 0, "no workers while idle");
1469
1470 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(); 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 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 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 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; let seen_layer = seen_layer.clone();
1542 tokio::task::spawn_blocking(move || {
1543 while !cancel.load(Ordering::Relaxed) {
1544 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 tokio::time::sleep(std::time::Duration::from_millis(40)).await;
1564 assert_eq!(spawned.load(Ordering::SeqCst), 0, "no workers while idle");
1565
1566 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 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}