1use crate::blocks::LiveStream;
92use crate::declare::{AlignFrame, Dense, Features, Plan};
93use crate::densewrap::{make_ba_backend, make_match_backend, make_sift_backend, make_sparse_backend, parse_profile};
94use crate::events::{Command, Event, EventKind, Frame, ZoneRange};
95use crate::live::DisplayFeed;
96use crate::pipeline::{Pipeline, QueuePolicy, Summary};
97use crate::post::LiveOptions;
98use crate::session::{FrameSet, Input, Session, SessionConfig};
99use crate::sinks::{self, DefaultSinks, SinkOptions, SinkSet};
100use cumulus3d_core::io::ply::PointCloud;
101use cumulus3d_core::io::GpsRecord;
102use std::collections::{BTreeMap, HashMap, VecDeque};
103use std::fmt;
104use std::path::{Path, PathBuf};
105use std::sync::mpsc::Receiver;
106use std::sync::{Arc, Condvar, Mutex, MutexGuard};
107use std::thread::JoinHandle;
108use std::time::SystemTime;
109
110#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
116pub struct FrameKey {
117 pub source_id: String,
119 pub sequence: u64,
121 pub revision: u32,
123}
124
125impl FrameKey {
126 pub fn new(source_id: impl Into<String>, sequence: u64, revision: u32) -> Self {
128 Self { source_id: source_id.into(), sequence, revision }
129 }
130}
131
132impl fmt::Display for FrameKey {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 write!(f, "{}#{}r{}", self.source_id, self.sequence, self.revision)
135 }
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum EncodedFormat {
141 Jpeg,
143 Png,
145}
146
147impl EncodedFormat {
148 fn extension(self) -> &'static str {
149 match self {
150 EncodedFormat::Jpeg => "jpg",
151 EncodedFormat::Png => "png",
152 }
153 }
154}
155
156#[derive(Clone)]
158pub enum FramePayload {
159 File {
161 relative_name: String,
163 },
164 Rgb8 {
166 width: u32,
168 height: u32,
170 pixels: Arc<[u8]>,
172 },
173 Encoded {
175 bytes: Arc<[u8]>,
177 format: EncodedFormat,
179 },
180}
181
182impl fmt::Debug for FramePayload {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 match self {
185 FramePayload::File { relative_name } => f.debug_struct("File").field("relative_name", relative_name).finish(),
186 FramePayload::Rgb8 { width, height, .. } => f.debug_struct("Rgb8").field("width", width).field("height", height).finish(),
187 FramePayload::Encoded { bytes, format } => {
188 f.debug_struct("Encoded").field("bytes", &bytes.len()).field("format", format).finish()
189 }
190 }
191 }
192}
193
194#[derive(Clone, Debug)]
196pub struct LiveImage {
197 pub camera: String,
199 pub payload: FramePayload,
201}
202
203#[derive(Clone, Debug)]
205pub struct LiveFrameSet {
206 pub key: FrameKey,
208 pub timestamp: SystemTime,
210 pub images: Vec<LiveImage>,
212 pub gps: Vec<GpsRecord>,
215}
216
217impl LiveFrameSet {
218 pub fn new(key: FrameKey) -> Self {
220 Self { key, timestamp: SystemTime::now(), images: Vec::new(), gps: Vec::new() }
221 }
222 pub fn image(mut self, camera: impl Into<String>, payload: FramePayload) -> Self {
224 self.images.push(LiveImage { camera: camera.into(), payload });
225 self
226 }
227 pub fn file(self, camera: impl Into<String>, relative_name: impl Into<String>) -> Self {
229 self.image(camera, FramePayload::File { relative_name: relative_name.into() })
230 }
231 pub fn rgb8(self, camera: impl Into<String>, width: u32, height: u32, pixels: impl Into<Arc<[u8]>>) -> Self {
233 self.image(camera, FramePayload::Rgb8 { width, height, pixels: pixels.into() })
234 }
235 pub fn encoded(self, camera: impl Into<String>, bytes: impl Into<Arc<[u8]>>, format: EncodedFormat) -> Self {
237 self.image(camera, FramePayload::Encoded { bytes: bytes.into(), format })
238 }
239 pub fn gps_fix(mut self, camera: impl Into<String>, lat: f64, lon: f64, alt: f64) -> Self {
241 self.gps.push(GpsRecord { name: camera.into(), lat, lon, alt });
242 self
243 }
244 pub fn at(mut self, t: SystemTime) -> Self {
246 self.timestamp = t;
247 self
248 }
249}
250
251#[derive(Clone, Debug)]
253pub enum FrameState {
254 Ready(LiveFrameSet),
256 Pending {
258 key: FrameKey,
260 timestamp: SystemTime,
262 missing_cameras: Vec<String>,
264 },
265 End,
267}
268
269impl FrameState {
270 pub fn ready(set: LiveFrameSet) -> Self {
272 FrameState::Ready(set)
273 }
274 pub fn pending<S: Into<String>>(key: FrameKey, missing_cameras: impl IntoIterator<Item = S>) -> Self {
276 FrameState::Pending { key, timestamp: SystemTime::now(), missing_cameras: missing_cameras.into_iter().map(Into::into).collect() }
277 }
278}
279
280#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
286pub enum ReconcilePolicy {
287 #[default]
289 AppendOnly,
290 ReplayWindow {
294 positions: usize,
296 },
297 InvalidateAffectedZones,
302 Strict,
304}
305
306#[derive(Clone, Copy, Debug, PartialEq, Eq)]
308pub enum Backpressure {
309 Unbounded,
311 Block {
313 max_pending: usize,
315 },
316 KeepLatest {
319 max_pending: usize,
321 },
322}
323
324impl Default for Backpressure {
325 fn default() -> Self {
326 Backpressure::Block { max_pending: 8 }
327 }
328}
329
330#[derive(Clone, Debug, PartialEq, Eq)]
332pub enum IgnoreReason {
333 StaleRevision {
335 accepted: u32,
337 },
338 CorrectionNotApplied,
340 Late,
342 OutsideReplayWindow {
344 position: usize,
346 },
347 ForeignSource {
349 expected: String,
351 },
352 Closed,
354}
355
356impl fmt::Display for IgnoreReason {
357 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358 match self {
359 IgnoreReason::StaleRevision { accepted } => write!(f, "stale revision (accepted revision {accepted})"),
360 IgnoreReason::CorrectionNotApplied => write!(f, "correction not applied by this policy"),
361 IgnoreReason::Late => write!(f, "late arrival not inserted by this policy"),
362 IgnoreReason::OutsideReplayWindow { position } => write!(f, "outside replay window (position {position})"),
363 IgnoreReason::ForeignSource { expected } => write!(f, "foreign source (node source is {expected})"),
364 IgnoreReason::Closed => write!(f, "node closed"),
365 }
366 }
367}
368
369#[derive(Clone, Debug, PartialEq, Eq)]
371pub enum Outcome {
372 Appended {
374 position: usize,
376 skipped: u64,
378 },
379 Duplicate {
381 position: usize,
383 },
384 Waiting {
386 missing: Vec<String>,
388 },
389 Replayed {
391 from: usize,
393 positions: usize,
395 },
396 Invalidated {
398 position: usize,
400 zones: Vec<usize>,
402 },
403 Ignored(IgnoreReason),
405 Ended,
407}
408
409#[derive(Clone, Debug, PartialEq, Eq)]
411pub enum ComposeError {
412 Config(Vec<String>),
414 InvalidFrameSet(String),
416 Rejected(String),
418 Io(String),
420 Session(String),
422 UnknownNode(String),
424 DuplicateNode(String),
426 Closed,
428}
429
430impl fmt::Display for ComposeError {
431 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
432 match self {
433 ComposeError::Config(v) => write!(f, "invalid node configuration: {}", v.join("; ")),
434 ComposeError::InvalidFrameSet(m) => write!(f, "invalid frame set: {m}"),
435 ComposeError::Rejected(m) => write!(f, "rejected: {m}"),
436 ComposeError::Io(m) => write!(f, "i/o: {m}"),
437 ComposeError::Session(m) => write!(f, "session failed: {m}"),
438 ComposeError::UnknownNode(id) => write!(f, "no node {id:?} on this host"),
439 ComposeError::DuplicateNode(id) => write!(f, "node {id:?} already exists on this host"),
440 ComposeError::Closed => write!(f, "node already closed"),
441 }
442 }
443}
444
445impl std::error::Error for ComposeError {}
446
447#[derive(Clone, Debug, Default, PartialEq, Eq)]
449pub struct ComposeStats {
450 pub declared: u64,
452 pub appended: u64,
454 pub duplicates: u64,
456 pub waiting: u64,
458 pub skipped_sequences: u64,
460 pub replays: u64,
462 pub replayed_positions: u64,
464 pub invalidations: u64,
466 pub ignored: u64,
468}
469
470pub struct Closed {
472 pub session: Session,
474 pub summary: Summary,
476 pub stats: ComposeStats,
478}
479
480impl fmt::Debug for Closed {
481 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
482 f.debug_struct("Closed")
483 .field("positions", &self.session.positions())
484 .field("summary", &self.summary)
485 .field("stats", &self.stats)
486 .finish()
487 }
488}
489
490#[derive(Clone, Default)]
496pub struct CompositionHost {
497 nodes: Arc<Mutex<BTreeMap<String, ReconNode>>>,
498}
499
500impl fmt::Debug for CompositionHost {
501 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
502 f.debug_struct("CompositionHost").field("nodes", &self.ids()).finish()
503 }
504}
505
506impl CompositionHost {
507 pub fn new() -> Self {
509 Self::default()
510 }
511 pub fn node(&self, id: &str) -> Option<ReconNode> {
513 lock(&self.nodes).get(id).cloned()
514 }
515 pub fn ids(&self) -> Vec<String> {
517 lock(&self.nodes).keys().cloned().collect()
518 }
519 pub fn compose(&self, id: &str, state: FrameState) -> Result<Outcome, ComposeError> {
521 self.node(id).ok_or_else(|| ComposeError::UnknownNode(id.to_string()))?.compose(state)
522 }
523 pub fn close(&self, id: &str) -> Result<Closed, ComposeError> {
525 let node = lock(&self.nodes).remove(id).ok_or_else(|| ComposeError::UnknownNode(id.to_string()))?;
526 node.close_inner()
527 }
528 pub fn close_all(&self) -> Vec<(String, Result<Closed, ComposeError>)> {
530 let nodes = std::mem::take(&mut *lock(&self.nodes));
531 nodes.into_iter().map(|(id, n)| (id, n.close_inner())).collect()
532 }
533 fn register(&self, node: &ReconNode) -> Result<(), ComposeError> {
534 let mut m = lock(&self.nodes);
535 if m.contains_key(&node.id) {
536 return Err(ComposeError::DuplicateNode(node.id.clone()));
537 }
538 m.insert(node.id.clone(), node.clone());
539 Ok(())
540 }
541}
542
543pub fn reconstruct(host: &CompositionHost, id: &str, state: FrameState) -> Result<Outcome, ComposeError> {
546 host.compose(id, state)
547}
548
549type Attach = Box<dyn FnOnce(Pipeline<Session>) -> Pipeline<Session> + Send>;
554
555pub struct NodeBuilder {
560 id: String,
561 plan: Plan,
562 policy: ReconcilePolicy,
563 backpressure: Backpressure,
564 source: Option<String>,
565 spool: PathBuf,
566 output: Option<(PathBuf, SinkOptions)>,
567 attach: Vec<Attach>,
568 frames: bool,
569 deltas: Option<LiveOptions>,
570 stream_origin: Option<[f64; 3]>,
572 stream_only: bool,
574}
575
576impl fmt::Debug for NodeBuilder {
577 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
578 f.debug_struct("NodeBuilder").field("id", &self.id).field("policy", &self.policy).field("hooks", &self.attach.len()).finish()
579 }
580}
581
582impl ReconNode {
583 pub fn declare() -> NodeBuilder {
585 let mut plan = Plan::default();
586 plan.dense.enabled = false;
587 plan.gps = None;
588 NodeBuilder {
589 id: "default".to_string(),
590 plan,
591 policy: ReconcilePolicy::default(),
592 backpressure: Backpressure::default(),
593 source: None,
594 spool: PathBuf::from(".live"),
595 output: None,
596 attach: Vec::new(),
597 frames: false,
598 deltas: None,
599 stream_origin: None,
600 stream_only: false,
601 }
602 }
603}
604
605impl NodeBuilder {
606 pub fn id(mut self, id: impl Into<String>) -> Self {
608 self.id = id.into();
609 self
610 }
611 pub fn plan(mut self, plan: Plan) -> Self {
614 self.plan = plan;
615 self
616 }
617 pub fn images(mut self, dir: impl Into<PathBuf>) -> Self {
619 self.plan.input.images = dir.into();
620 self
621 }
622 pub fn cameras<S: Into<String>>(mut self, cams: impl IntoIterator<Item = S>) -> Self {
624 self.plan.input.cameras = cams.into_iter().map(Into::into).collect();
625 self
626 }
627 pub fn zones(mut self, span: usize, overlap: usize) -> Self {
629 self.plan.zones.span = span;
630 self.plan.zones.overlap = overlap;
631 self
632 }
633 pub fn align(mut self, frame: AlignFrame) -> Self {
635 self.plan.align.frame = frame;
636 self
637 }
638 pub fn fixed_enu_origin(mut self, on: bool) -> Self {
640 self.plan.align.fixed_origin = on;
641 self
642 }
643 pub fn features(mut self, f: Features) -> Self {
645 self.plan.features = f;
646 self
647 }
648 pub fn match_backend(mut self, name: &str) -> Self {
650 self.plan.matching.backend = name.to_string();
651 self
652 }
653 pub fn gpu(mut self) -> Self {
655 self.plan.features.backend = "cuda".into();
656 self.plan.matching.backend = "cuda".into();
657 self.plan.sparse.backend = "cuda".into();
658 self.plan.dense.backend = "cuda".into();
659 self.plan.sparse.ba_backend = "cuda".into();
660 self.plan.dense.undistort_backend = "cuda".into();
661 self
662 }
663 pub fn stable_display(mut self, on: bool) -> Self {
668 self.plan.sparse.stable_display = on;
669 self
670 }
671 pub fn display_deltas(mut self, opts: LiveOptions) -> Self {
678 self.deltas = Some(opts);
679 self
680 }
681 pub fn incremental_triangulation(mut self, on: bool) -> Self {
683 self.plan.sparse.incremental_triangulation = on;
684 self
685 }
686 pub fn settle(mut self, tol: f64, max_zone: usize) -> Self {
689 self.plan.sparse.settle_tol = tol;
690 self.plan.sparse.settle_max_zone = max_zone;
691 self
692 }
693 pub fn live_stride(mut self, stride: usize) -> Self {
695 self.plan.sinks.live_stride = stride;
696 self
697 }
698 pub fn stream_origin(mut self, lat: f64, lon: f64, alt: f64) -> Self {
701 self.stream_origin = Some([lat, lon, alt]);
702 self
703 }
704 pub fn stream_only(mut self, on: bool) -> Self {
707 self.stream_only = on;
708 self
709 }
710 pub fn dense(mut self, d: Dense) -> Self {
712 self.plan.dense = Dense { enabled: true, ..d };
713 self
714 }
715 pub fn no_dense(mut self) -> Self {
717 self.plan.dense.enabled = false;
718 self
719 }
720 pub fn seed(mut self, seed: u64) -> Self {
722 self.plan.seed = Some(seed);
723 self
724 }
725 pub fn reconcile(mut self, p: ReconcilePolicy) -> Self {
727 self.policy = p;
728 self
729 }
730 pub fn backpressure(mut self, b: Backpressure) -> Self {
732 self.backpressure = b;
733 self
734 }
735 pub fn source(mut self, id: impl Into<String>) -> Self {
737 self.source = Some(id.into());
738 self
739 }
740 pub fn spool(mut self, rel: impl Into<PathBuf>) -> Self {
742 self.spool = rel.into();
743 self
744 }
745 pub fn output(mut self, dir: impl Into<PathBuf>, opts: SinkOptions) -> Self {
748 self.output = Some((dir.into(), opts));
749 self
750 }
751 fn hook(mut self, f: impl FnOnce(Pipeline<Session>) -> Pipeline<Session> + Send + 'static) -> Self {
752 self.attach.push(Box::new(f));
753 self
754 }
755 pub fn on<F>(mut self, kind: EventKind, f: F) -> Self
757 where
758 F: FnMut(&Event) + Send + 'static,
759 {
760 self.frames |= kind == EventKind::FrameDecoded;
761 self.hook(move |p| p.on(kind, f))
762 }
763 pub fn on_event<F>(self, f: F) -> Self
765 where
766 F: FnMut(&Event) + Send + 'static,
767 {
768 self.hook(move |p| p.on_any(f))
769 }
770 pub fn on_frame<F>(mut self, f: F) -> Self
772 where
773 F: FnMut(&Arc<Frame>) + Send + 'static,
774 {
775 self.frames = true;
776 self.hook(move |p| p.on_frame(f))
777 }
778 pub fn on_zone_preview<F>(self, f: F) -> Self
780 where
781 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
782 {
783 self.hook(move |p| p.on_zone_preview(f))
784 }
785 pub fn on_zone_refined<F>(self, f: F) -> Self
787 where
788 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
789 {
790 self.hook(move |p| p.on_zone_refined(f))
791 }
792 pub fn on_message<F>(self, f: F) -> Self
794 where
795 F: FnMut(EventKind, &str) + Send + 'static,
796 {
797 self.hook(move |p| p.on_message(f))
798 }
799 pub fn event_policy(self, kind: EventKind, p: QueuePolicy) -> Self {
801 self.hook(move |pl| pl.policy(kind, p))
802 }
803 pub fn sync_hooks(self, on: bool) -> Self {
805 self.hook(move |pl| pl.sync(on))
806 }
807
808 pub fn build(self, host: &CompositionHost) -> Result<ReconNode, ComposeError> {
811 let NodeBuilder { id, plan, policy, backpressure, source, spool, output, attach, frames, deltas, stream_origin, stream_only } =
812 self;
813 let mut pr = Vec::new();
814 let cams = &plan.input.cameras;
815 if cams.is_empty() {
816 pr.push("cameras: empty".to_string());
817 }
818 let mut seen = std::collections::BTreeSet::new();
819 for c in cams {
820 if c.is_empty() || c.contains(['/', '\\']) {
821 pr.push(format!("cameras: invalid name {c:?}"));
822 } else if !seen.insert(c.as_str()) {
823 pr.push(format!("cameras: duplicate {c}"));
824 }
825 }
826 let z = plan.zones;
827 if z.span == 0 {
828 pr.push("zones.span: must be at least 1".into());
829 } else if z.overlap >= z.span {
830 pr.push(format!("zones.overlap: must be smaller than the span ({}), got {}", z.span, z.overlap));
831 }
832 if !plan.input.images.is_dir() {
833 pr.push(format!("images: folder not found: {}", plan.input.images.display()));
834 }
835 if spool.is_absolute() || spool.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
836 pr.push(format!("spool: must be a relative path inside the image folder, got {}", spool.display()));
837 }
838 if plan.sinks.live_stride == 0 {
839 pr.push("live_stride: must be at least 1 (1 = keep all)".into());
840 }
841 if plan.sparse.settle_tol.is_nan() || plan.sparse.settle_tol < 0.0 {
842 pr.push(format!("settle tolerance: must be >= 0, got {}", plan.sparse.settle_tol));
843 }
844 if let ReconcilePolicy::ReplayWindow { positions: 0 } = policy {
845 pr.push("reconcile: replay window must be at least 1 position".into());
846 }
847 match backpressure {
848 Backpressure::Block { max_pending: 0 } | Backpressure::KeepLatest { max_pending: 0 } => {
849 pr.push("backpressure: max_pending must be at least 1".into())
850 }
851 _ => {}
852 }
853 let sift = make_sift_backend(&plan.features.backend).map_err(|e| pr.push(format!("features.backend: {e}"))).ok();
854 let matcher = make_match_backend(&plan.matching.backend).map_err(|e| pr.push(format!("matching.backend: {e}"))).ok();
855 let ba_backend = make_ba_backend(&plan.sparse.ba_backend).map_err(|e| pr.push(format!("sparse.ba_backend: {e}"))).ok();
856 let sparse = make_sparse_backend(&plan.sparse.backend).map_err(|e| pr.push(format!("sparse.backend: {e}"))).ok();
857 if plan.dense.enabled {
858 if let Err(e) = parse_profile(&plan.dense.profile) {
859 pr.push(format!("dense.profile: {e}"));
860 }
861 }
862 let dense = if plan.dense.enabled { plan.dense.config().map_err(|e| pr.push(format!("dense: {e}"))).ok() } else { None };
863 if !pr.is_empty() {
864 return Err(ComposeError::Config(pr));
865 }
866 if host.node(&id).is_some() {
867 return Err(ComposeError::DuplicateNode(id));
868 }
869
870 let mut sc = SessionConfig::new(plan.input.images.clone());
871 sc.span = z.span;
872 sc.overlap = z.overlap;
873 sc.total_positions = None;
874 sc.incremental_triangulation = plan.sparse.incremental_triangulation;
875 sc.fixed_enu_origin = plan.align.fixed_origin;
876 sc.seed = plan.seed;
877 sc.extraction.sift.max_num_features = plan.features.max_num_features;
878 sc.extraction.reader.max_image_size = plan.features.max_image_size;
879 sc.sift = sift.expect("checked");
880 sc.matcher = Arc::from(matcher.expect("checked"));
881 sc.ba_backend = ba_backend.expect("checked");
882 sc.sparse = sparse.expect("checked");
883 sc.dense = dense;
884 sc.history = match policy {
885 ReconcilePolicy::ReplayWindow { positions } => positions + 1,
886 _ => 0,
887 };
888 sc.decode_frames = frames;
889 sc.stable_display = plan.sparse.stable_display;
890 sc.settle_tol = plan.sparse.settle_tol;
891 sc.settle_max_zone = plan.sparse.settle_max_zone;
892 if deltas.as_ref().is_some_and(|o| o.stride == 0) {
893 return Err(ComposeError::Config(vec!["display_deltas: stride must be at least 1".into()]));
894 }
895
896 let origin = Arc::new(Mutex::new(None));
897 let mut pl = Pipeline::new(Session::new(sc));
898 if let Some(opts) = deltas {
899 let mut feed = DisplayFeed::new(opts).with_origin(Arc::clone(&origin));
900 pl = pl.derive(move |e| feed.handle(e).into_iter().collect());
901 }
902 if let Some((dir, opts)) = &output {
903 let io = |e: std::io::Error| ComposeError::Io(format!("{}: {e}", dir.display()));
904 pl = if plan.sparse.stable_display {
905 let set = SinkSet {
907 models: opts.save_models && !stream_only,
908 zone_ply: !stream_only,
909 live_files: !stream_only,
910 live_stride: plan.sinks.live_stride,
911 live_stream: Some(LiveStream { enu: stream_origin.is_some(), origin_gps: stream_origin }),
912 ..SinkSet::all()
913 };
914 pl.on_any(DefaultSinks::with_set(dir, opts.echo, set).map_err(io)?.into_hook())
915 } else {
916 sinks::attach(pl, dir, opts).map_err(io)?
917 };
918 }
919 for a in attach {
920 pl = a(pl);
921 }
922 let core = Core {
923 cameras: plan.input.cameras.clone(),
924 image_root: plan.input.images.clone(),
925 spool,
926 policy,
927 source,
928 accepted: BTreeMap::new(),
929 order: Vec::new(),
930 recent: VecDeque::new(),
931 waiting: BTreeMap::new(),
932 stats: ComposeStats::default(),
933 pipeline: Some(pl),
934 ended: None,
935 origin,
936 };
937 let node = ReconNode { id, backpressure, core: Arc::new(Mutex::new(core)) };
938 host.register(&node)?;
939 Ok(node)
940 }
941}
942
943#[derive(Clone)]
950pub struct ReconNode {
951 id: String,
952 backpressure: Backpressure,
953 core: Arc<Mutex<Core>>,
954}
955
956impl fmt::Debug for ReconNode {
957 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
958 f.debug_struct("ReconNode").field("id", &self.id).finish()
959 }
960}
961
962#[derive(Clone, Copy, Debug)]
963struct Accepted {
964 revision: u32,
965 position: usize,
966}
967
968struct Core {
969 cameras: Vec<String>,
970 image_root: PathBuf,
971 spool: PathBuf,
972 policy: ReconcilePolicy,
973 source: Option<String>,
974 accepted: BTreeMap<u64, Accepted>,
976 order: Vec<u64>,
978 recent: VecDeque<FrameSet>,
980 waiting: BTreeMap<u64, Vec<String>>,
982 stats: ComposeStats,
983 pipeline: Option<Pipeline<Session>>,
984 ended: Option<Closed>,
986 origin: Arc<Mutex<Option<[f64; 3]>>>,
988}
989
990impl ReconNode {
991 pub fn id(&self) -> &str {
993 &self.id
994 }
995
996 pub fn compose(&self, state: FrameState) -> Result<Outcome, ComposeError> {
998 lock(&self.core).compose(state)
999 }
1000
1001 pub fn compose_all(&self, states: impl IntoIterator<Item = FrameState>) -> Result<Vec<Outcome>, ComposeError> {
1003 states.into_iter().map(|s| self.compose(s)).collect()
1004 }
1005
1006 pub fn poll(&self) -> usize {
1008 lock(&self.core).pipeline.as_mut().map_or(0, |p| p.poll())
1009 }
1010
1011 pub fn subscribe(&self) -> Option<Receiver<Event>> {
1013 lock(&self.core).pipeline.as_ref().map(|p| p.subscribe())
1014 }
1015
1016 pub fn stats(&self) -> ComposeStats {
1018 lock(&self.core).stats.clone()
1019 }
1020
1021 pub fn positions(&self) -> usize {
1023 lock(&self.core).order.len()
1024 }
1025
1026 pub fn journal(&self) -> Vec<(u64, u32)> {
1028 let c = lock(&self.core);
1029 c.order.iter().map(|s| (*s, c.accepted[s].revision)).collect()
1030 }
1031
1032 pub fn waiting(&self) -> Vec<(u64, Vec<String>)> {
1034 lock(&self.core).waiting.iter().map(|(k, v)| (*k, v.clone())).collect()
1035 }
1036
1037 pub fn with_session<T>(&self, f: impl FnOnce(&Session) -> T) -> Option<T> {
1039 lock(&self.core).pipeline.as_ref().map(|p| f(p.reducer()))
1040 }
1041
1042 pub fn close(&self) -> Result<Closed, ComposeError> {
1045 self.close_inner()
1046 }
1047
1048 fn close_inner(&self) -> Result<Closed, ComposeError> {
1049 let mut c = lock(&self.core);
1050 if let Some(done) = c.ended.take() {
1051 return Ok(done);
1052 }
1053 c.finish().ok_or(ComposeError::Closed)
1054 }
1055
1056 pub fn spawn(&self) -> ComposeHandle {
1059 let queue = Arc::new(Queue { st: Mutex::new(QueueState::default()), cv: Condvar::new() });
1060 let (q, node) = (Arc::clone(&queue), self.clone());
1061 let worker = std::thread::Builder::new()
1062 .name(format!("compose-{}", self.id))
1063 .spawn(move || {
1064 let mut outcomes = Vec::new();
1065 loop {
1066 let next = {
1067 let mut st = lock(&q.st);
1068 loop {
1069 if let Some(s) = st.items.pop_front() {
1070 q.cv.notify_all();
1071 break Some(s);
1072 }
1073 if st.closed {
1074 break None;
1075 }
1076 st = q.cv.wait(st).unwrap_or_else(|p| p.into_inner());
1077 }
1078 };
1079 let Some(state) = next else { break };
1080 let end = matches!(state, FrameState::End);
1081 let r = node.compose(state);
1082 lock(&q.st).processed += 1;
1083 let fatal = matches!(r, Err(ComposeError::Session(_)));
1084 outcomes.push(r);
1085 if end || fatal {
1086 break;
1087 }
1088 }
1089 let mut st = lock(&q.st);
1090 st.closed = true;
1091 st.items.clear();
1092 q.cv.notify_all();
1093 outcomes
1094 })
1095 .expect("compose worker thread");
1096 ComposeHandle { node: self.clone(), queue, policy: self.backpressure, worker: Some(worker) }
1097 }
1098}
1099
1100impl Core {
1101 fn pl(&mut self) -> &mut Pipeline<Session> {
1102 self.pipeline.as_mut().expect("open pipeline")
1103 }
1104
1105 fn warn(&mut self, message: String) {
1106 if let Some(p) = self.pipeline.as_mut() {
1107 p.reducer().emit_external(|meta| Event::Warning { meta, message });
1108 p.poll();
1109 }
1110 }
1111
1112 fn ignore(&mut self, key: &FrameKey, reason: IgnoreReason) -> Result<Outcome, ComposeError> {
1113 if self.policy == ReconcilePolicy::Strict && reason != IgnoreReason::Closed {
1114 return Err(ComposeError::Rejected(format!("{key}: {reason}")));
1115 }
1116 self.stats.ignored += 1;
1117 self.warn(format!("compose: {key} ignored: {reason}"));
1118 Ok(Outcome::Ignored(reason))
1119 }
1120
1121 fn compose(&mut self, state: FrameState) -> Result<Outcome, ComposeError> {
1122 self.stats.declared += 1;
1123 if self.pipeline.is_none() {
1124 return match state {
1125 FrameState::End => Ok(Outcome::Ended),
1126 _ => Ok(Outcome::Ignored(IgnoreReason::Closed)),
1127 };
1128 }
1129 match state {
1130 FrameState::End => {
1131 let done = self.finish().ok_or(ComposeError::Closed)?;
1132 self.ended = Some(done);
1133 Ok(Outcome::Ended)
1134 }
1135 FrameState::Pending { key, missing_cameras, .. } => {
1136 if let Some(r) = self.check_source(&key) {
1137 return r;
1138 }
1139 self.stats.waiting += 1;
1140 if !self.accepted.contains_key(&key.sequence) {
1141 self.waiting.insert(key.sequence, missing_cameras.clone());
1142 }
1143 Ok(Outcome::Waiting { missing: missing_cameras })
1144 }
1145 FrameState::Ready(set) => self.ready(set),
1146 }
1147 }
1148
1149 fn check_source(&mut self, key: &FrameKey) -> Option<Result<Outcome, ComposeError>> {
1150 match &self.source {
1151 None => {
1152 self.source = Some(key.source_id.clone());
1153 None
1154 }
1155 Some(s) if *s == key.source_id => None,
1156 Some(s) => {
1157 let expected = s.clone();
1158 Some(self.ignore(key, IgnoreReason::ForeignSource { expected }))
1159 }
1160 }
1161 }
1162
1163 fn ready(&mut self, set: LiveFrameSet) -> Result<Outcome, ComposeError> {
1164 let key = set.key.clone();
1165 let mut by_cam: HashMap<&str, &LiveImage> = HashMap::new();
1167 for im in &set.images {
1168 if !self.cameras.contains(&im.camera) {
1169 return Err(ComposeError::InvalidFrameSet(format!("{key}: unknown camera {}", im.camera)));
1170 }
1171 if by_cam.insert(im.camera.as_str(), im).is_some() {
1172 return Err(ComposeError::InvalidFrameSet(format!("{key}: camera {} given twice", im.camera)));
1173 }
1174 }
1175 if let Some(r) = self.check_source(&key) {
1176 return r;
1177 }
1178 let missing: Vec<String> = self.cameras.iter().filter(|c| !by_cam.contains_key(c.as_str())).cloned().collect();
1179 if !missing.is_empty() && !self.accepted.contains_key(&key.sequence) {
1180 self.stats.waiting += 1;
1181 self.waiting.insert(key.sequence, missing.clone());
1182 return Ok(Outcome::Waiting { missing });
1183 }
1184
1185 if let Some(acc) = self.accepted.get(&key.sequence).copied() {
1186 if key.revision == acc.revision {
1187 self.stats.duplicates += 1;
1188 return Ok(Outcome::Duplicate { position: acc.position });
1189 }
1190 if key.revision < acc.revision {
1191 return self.ignore(&key, IgnoreReason::StaleRevision { accepted: acc.revision });
1192 }
1193 if !missing.is_empty() {
1194 return Err(ComposeError::InvalidFrameSet(format!("{key}: correction misses cameras {}", missing.join(", "))));
1195 }
1196 return self.correct(set, acc);
1197 }
1198
1199 let newest = self.order.last().copied();
1200 match newest {
1201 Some(n) if key.sequence < n => self.late(set),
1202 _ => {
1203 let skipped = newest.map_or(0, |n| key.sequence - n - 1);
1204 if skipped > 0 && self.policy == ReconcilePolicy::Strict {
1205 return Err(ComposeError::Rejected(format!("{key}: {skipped} sequence(s) missing before it")));
1206 }
1207 let fs = self.resolve(&set, None)?;
1208 self.waiting.remove(&key.sequence);
1209 let position = self.order.len();
1210 self.push(fs)?;
1211 self.order.push(key.sequence);
1212 self.accepted.insert(key.sequence, Accepted { revision: key.revision, position });
1213 self.stats.appended += 1;
1214 if skipped > 0 {
1215 self.stats.skipped_sequences += skipped;
1216 self.warn(format!("compose: {key} appended at position {position} after {skipped} missing sequence(s)"));
1217 }
1218 Ok(Outcome::Appended { position, skipped })
1219 }
1220 }
1221 }
1222
1223 fn late(&mut self, set: LiveFrameSet) -> Result<Outcome, ComposeError> {
1225 let key = set.key.clone();
1226 let ReconcilePolicy::ReplayWindow { positions: w } = self.policy else {
1227 return self.ignore(&key, IgnoreReason::Late);
1228 };
1229 let p = self.order.partition_point(|&s| s < key.sequence);
1230 if !self.in_window(p, w) {
1231 return self.ignore(&key, IgnoreReason::OutsideReplayWindow { position: p });
1232 }
1233 let fs = self.resolve(&set, None)?;
1234 let tail_sets: Vec<FrameSet> = self.recent.iter().skip(self.recent.len() - (self.order.len() - p)).cloned().collect();
1235 let mut tail = vec![fs];
1236 tail.extend(tail_sets);
1237 let mut seqs = vec![key.sequence];
1238 seqs.extend(self.order[p..].iter().copied());
1239 self.waiting.remove(&key.sequence);
1240 let n = self.replay(p, tail, &seqs, (key.sequence, key.revision))?;
1241 self.warn(format!("compose: late {key} inserted at position {p}, replayed {n} position(s)"));
1242 Ok(Outcome::Replayed { from: p, positions: n })
1243 }
1244
1245 fn correct(&mut self, set: LiveFrameSet, acc: Accepted) -> Result<Outcome, ComposeError> {
1247 let key = set.key.clone();
1248 let p = acc.position;
1249 match self.policy {
1250 ReconcilePolicy::AppendOnly | ReconcilePolicy::Strict => self.ignore(&key, IgnoreReason::CorrectionNotApplied),
1251 ReconcilePolicy::ReplayWindow { positions: w } => {
1252 if !self.in_window(p, w) {
1253 return self.ignore(&key, IgnoreReason::OutsideReplayWindow { position: p });
1254 }
1255 let fs = self.resolve(&set, None)?;
1256 let start = self.recent.len() - (self.order.len() - p);
1257 let mut tail: Vec<FrameSet> = self.recent.iter().skip(start).cloned().collect();
1258 tail[0] = fs;
1259 let seqs: Vec<u64> = self.order[p..].to_vec();
1260 let n = self.replay(p, tail, &seqs, (key.sequence, key.revision))?;
1261 self.warn(format!("compose: correction {key} applied from position {p}, replayed {n} position(s)"));
1262 Ok(Outcome::Replayed { from: p, positions: n })
1263 }
1264 ReconcilePolicy::InvalidateAffectedZones => {
1265 let names: Vec<String> = self.pl().reducer().frames()[p].iter().map(|f| f.name.clone()).collect();
1267 self.resolve(&set, Some(&names))?;
1268 let zones: Vec<usize> =
1269 self.pl().reducer().arrived_zones().into_iter().filter(|z| z.lo <= p && p < z.hi).map(|z| z.zone).collect();
1270 for &z in &zones {
1271 self.pl().command(Command::InvalidateZone(z));
1272 }
1273 self.fail_check()?;
1274 self.accepted.insert(key.sequence, Accepted { revision: key.revision, position: p });
1275 self.stats.invalidations += 1;
1276 Ok(Outcome::Invalidated { position: p, zones })
1277 }
1278 }
1279 }
1280
1281 fn in_window(&self, p: usize, w: usize) -> bool {
1283 let npos = self.order.len();
1284 npos - p <= w && npos - p <= self.recent.len()
1285 }
1286
1287 fn replay(&mut self, p: usize, tail: Vec<FrameSet>, seqs: &[u64], changed: (u64, u32)) -> Result<usize, ComposeError> {
1290 let npos = self.order.len();
1291 self.pl().command(Command::ResetFrom(p));
1292 if self.pl().reducer().positions() != p {
1293 return Err(ComposeError::Session(format!("rewind to position {p} was refused by the session")));
1294 }
1295 let revs: HashMap<u64, u32> = self.order[p..].iter().map(|s| (*s, self.accepted[s].revision)).collect();
1296 for s in &self.order[p..] {
1297 self.accepted.remove(s);
1298 }
1299 self.order.truncate(p);
1300 let keep = self.recent.len() - (npos - p);
1301 self.recent.truncate(keep);
1302 let n = tail.len();
1303 for (fs, &seq) in tail.into_iter().zip(seqs) {
1304 let revision = if seq == changed.0 { changed.1 } else { revs[&seq] };
1305 let position = self.order.len();
1306 self.push(fs)?;
1307 self.order.push(seq);
1308 self.accepted.insert(seq, Accepted { revision, position });
1309 }
1310 self.stats.replays += 1;
1311 self.stats.replayed_positions += n as u64;
1312 Ok(n)
1313 }
1314
1315 fn push(&mut self, fs: FrameSet) -> Result<(), ComposeError> {
1317 let cap = match self.policy {
1318 ReconcilePolicy::ReplayWindow { positions } => positions,
1319 _ => 0,
1320 };
1321 if cap > 0 {
1322 self.recent.push_back(fs.clone());
1323 while self.recent.len() > cap {
1324 self.recent.pop_front();
1325 }
1326 }
1327 if let Some(g) = fs.gps.first() {
1328 lock(&self.origin).get_or_insert([g.lat, g.lon, g.alt]);
1329 }
1330 self.pl().push(Input::Frames(fs));
1331 self.fail_check()
1332 }
1333
1334 fn fail_check(&mut self) -> Result<(), ComposeError> {
1335 match self.pl().reducer().failed() {
1336 Some(e) => Err(ComposeError::Session(e.to_string())),
1337 None => Ok(()),
1338 }
1339 }
1340
1341 fn resolve(&self, set: &LiveFrameSet, names: Option<&[String]>) -> Result<FrameSet, ComposeError> {
1344 let key = &set.key;
1345 let mut fs = FrameSet::default();
1346 for (i, cam) in self.cameras.iter().enumerate() {
1347 let im = set.images.iter().find(|x| &x.camera == cam).expect("complete set");
1348 let forced = names.map(|n| n[i].clone());
1349 let name = match &im.payload {
1350 FramePayload::File { relative_name } => {
1351 let src = self.image_root.join(relative_name);
1352 if !src.is_file() {
1353 return Err(ComposeError::InvalidFrameSet(format!("{key}: file not found: {}", src.display())));
1354 }
1355 match forced {
1356 Some(n) if n != *relative_name => {
1357 let bytes = std::fs::read(&src).map_err(|e| ComposeError::Io(format!("{}: {e}", src.display())))?;
1358 self.store_encoded(&n, &bytes, ext_of(relative_name))?;
1359 n
1360 }
1361 _ => relative_name.clone(),
1362 }
1363 }
1364 FramePayload::Rgb8 { width, height, pixels } => {
1365 if pixels.len() != *width as usize * *height as usize * 3 || *width == 0 || *height == 0 {
1366 return Err(ComposeError::InvalidFrameSet(format!(
1367 "{key}: camera {cam}: {} bytes for {width}x{height} RGB8",
1368 pixels.len()
1369 )));
1370 }
1371 let n = forced.unwrap_or_else(|| self.spool_name(key, cam, "png"));
1372 let img = image::RgbImage::from_raw(*width, *height, pixels.to_vec()).expect("size checked");
1373 self.store_image(&n, &image::DynamicImage::ImageRgb8(img))?;
1374 n
1375 }
1376 FramePayload::Encoded { bytes, format } => {
1377 let n = forced.unwrap_or_else(|| self.spool_name(key, cam, format.extension()));
1378 self.store_encoded(&n, bytes, Some(format.extension()))?;
1379 n
1380 }
1381 };
1382 fs.images.push(crate::session::FrameImage { camera: cam.clone(), name });
1383 }
1384 for g in &set.gps {
1385 let mut g = g.clone();
1386 if let Some(i) = self.cameras.iter().position(|c| *c == g.name) {
1387 g.name = fs.images[i].name.clone();
1388 }
1389 fs.gps.push(g);
1390 }
1391 Ok(fs)
1392 }
1393
1394 fn spool_name(&self, key: &FrameKey, cam: &str, ext: &str) -> String {
1395 let src: String = key.source_id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
1396 let p = self.spool.join(cam).join(format!("{src}_{:010}.{ext}", key.sequence));
1397 p.to_string_lossy().replace('\\', "/")
1398 }
1399
1400 fn store_encoded(&self, name: &str, bytes: &[u8], ext: Option<&str>) -> Result<(), ComposeError> {
1402 if ext.is_some() && ext == ext_of(name) {
1403 return self.atomic_write(name, |path| std::fs::write(path, bytes).map_err(|e| e.to_string()));
1404 }
1405 let img = image::load_from_memory(bytes).map_err(|e| ComposeError::InvalidFrameSet(format!("{name}: {e}")))?;
1406 self.store_image(name, &img)
1407 }
1408
1409 fn store_image(&self, name: &str, img: &image::DynamicImage) -> Result<(), ComposeError> {
1410 let fmt = image::ImageFormat::from_path(name).map_err(|e| ComposeError::Io(format!("{name}: {e}")))?;
1411 self.atomic_write(name, |path| img.save_with_format(path, fmt).map_err(|e| e.to_string()))
1412 }
1413
1414 fn atomic_write(&self, name: &str, write: impl FnOnce(&Path) -> Result<(), String>) -> Result<(), ComposeError> {
1416 let dst = self.image_root.join(name);
1417 let dir = dst.parent().unwrap_or(&self.image_root);
1418 std::fs::create_dir_all(dir).map_err(|e| ComposeError::Io(format!("{}: {e}", dir.display())))?;
1419 let file = dst.file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
1420 let ext = ext_of(name).unwrap_or("tmp");
1421 let tmp = dir.join(format!(".{file}.partial.{ext}"));
1422 write(&tmp).map_err(|e| ComposeError::Io(format!("{}: {e}", tmp.display())))?;
1423 std::fs::rename(&tmp, &dst).map_err(|e| ComposeError::Io(format!("{}: {e}", dst.display())))
1424 }
1425
1426 fn finish(&mut self) -> Option<Closed> {
1427 let pl = self.pipeline.take()?;
1428 let (session, summary) = pl.finish();
1429 Some(Closed { session, summary, stats: self.stats.clone() })
1430 }
1431}
1432
1433fn ext_of(name: &str) -> Option<&str> {
1434 Path::new(name).extension().and_then(|e| e.to_str())
1435}
1436
1437fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
1438 m.lock().unwrap_or_else(|p| p.into_inner())
1439}
1440
1441#[derive(Default)]
1446struct QueueState {
1447 items: VecDeque<FrameState>,
1448 closed: bool,
1449 submitted: u64,
1450 dropped: u64,
1451 processed: u64,
1452 max_depth: usize,
1453}
1454
1455struct Queue {
1456 st: Mutex<QueueState>,
1457 cv: Condvar,
1458}
1459
1460#[derive(Debug)]
1462pub enum SendError {
1463 Full(FrameState),
1465 Closed(FrameState),
1467}
1468
1469impl fmt::Display for SendError {
1470 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1471 match self {
1472 SendError::Full(_) => write!(f, "compose queue full"),
1473 SendError::Closed(_) => write!(f, "compose worker stopped"),
1474 }
1475 }
1476}
1477
1478impl std::error::Error for SendError {}
1479
1480#[derive(Clone, Debug, Default, PartialEq, Eq)]
1482pub struct QueueStats {
1483 pub submitted: u64,
1485 pub dropped: u64,
1487 pub processed: u64,
1489 pub depth: usize,
1491 pub max_depth: usize,
1493}
1494
1495pub struct ComposeHandle {
1497 node: ReconNode,
1498 queue: Arc<Queue>,
1499 policy: Backpressure,
1500 worker: Option<JoinHandle<Vec<Result<Outcome, ComposeError>>>>,
1501}
1502
1503impl fmt::Debug for ComposeHandle {
1504 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1505 f.debug_struct("ComposeHandle").field("node", &self.node.id).field("policy", &self.policy).field("stats", &self.stats()).finish()
1506 }
1507}
1508
1509impl ComposeHandle {
1510 pub fn node(&self) -> &ReconNode {
1512 &self.node
1513 }
1514
1515 pub fn send(&self, state: FrameState) -> Result<(), SendError> {
1517 self.enqueue(state, true)
1518 }
1519
1520 pub fn try_send(&self, state: FrameState) -> Result<(), SendError> {
1522 self.enqueue(state, false)
1523 }
1524
1525 pub fn send_all(&self, states: impl IntoIterator<Item = FrameState>) -> Result<(), SendError> {
1527 states.into_iter().try_for_each(|s| self.send(s))
1528 }
1529
1530 fn enqueue(&self, state: FrameState, wait: bool) -> Result<(), SendError> {
1531 let q = &self.queue;
1532 let mut st = lock(&q.st);
1533 if st.closed {
1534 return Err(SendError::Closed(state));
1535 }
1536 let end = matches!(state, FrameState::End);
1538 match self.policy {
1539 Backpressure::Unbounded => {}
1540 Backpressure::Block { max_pending } => {
1541 while !end && st.items.len() >= max_pending && !st.closed {
1542 if !wait {
1543 return Err(SendError::Full(state));
1544 }
1545 st = q.cv.wait(st).unwrap_or_else(|p| p.into_inner());
1546 }
1547 if st.closed {
1548 return Err(SendError::Closed(state));
1549 }
1550 }
1551 Backpressure::KeepLatest { max_pending } => {
1552 while !end && st.items.len() >= max_pending {
1553 match st.items.iter().position(|s| matches!(s, FrameState::Ready(_))) {
1554 Some(i) => {
1555 st.items.remove(i);
1556 st.dropped += 1;
1557 }
1558 None => {
1559 st.items.pop_front();
1560 }
1561 }
1562 }
1563 }
1564 }
1565 st.items.push_back(state);
1566 st.submitted += 1;
1567 st.max_depth = st.max_depth.max(st.items.len());
1568 q.cv.notify_all();
1569 Ok(())
1570 }
1571
1572 pub fn stats(&self) -> QueueStats {
1574 let st = lock(&self.queue.st);
1575 QueueStats { submitted: st.submitted, dropped: st.dropped, processed: st.processed, depth: st.items.len(), max_depth: st.max_depth }
1576 }
1577
1578 pub fn close(mut self) -> (Vec<Result<Outcome, ComposeError>>, Result<Closed, ComposeError>) {
1581 let _ = self.enqueue(FrameState::End, false);
1582 let outcomes = self.worker.take().map(|w| w.join().unwrap_or_default()).unwrap_or_default();
1583 (outcomes, self.node.close_inner())
1584 }
1585}
1586
1587impl Drop for ComposeHandle {
1588 fn drop(&mut self) {
1589 if let Some(w) = self.worker.take() {
1590 {
1591 let mut st = lock(&self.queue.st);
1592 st.closed = true;
1593 self.queue.cv.notify_all();
1594 }
1595 let _ = w.join();
1596 }
1597 }
1598}
1599
1600#[cfg(test)]
1601mod tests {
1602 use super::*;
1603
1604 #[test]
1605 fn spool_names_are_sanitized_and_stable() {
1606 let c = Core {
1607 cameras: vec!["camF".into()],
1608 image_root: PathBuf::from("/tmp/x"),
1609 spool: PathBuf::from(".live"),
1610 policy: ReconcilePolicy::AppendOnly,
1611 source: None,
1612 accepted: BTreeMap::new(),
1613 order: Vec::new(),
1614 recent: VecDeque::new(),
1615 waiting: BTreeMap::new(),
1616 stats: ComposeStats::default(),
1617 pipeline: None,
1618 ended: None,
1619 origin: Arc::new(Mutex::new(None)),
1620 };
1621 let k = FrameKey::new("rtsp://drone 1", 42, 3);
1622 assert_eq!(c.spool_name(&k, "camF", "png"), ".live/camF/rtsp___drone_1_0000000042.png");
1623 }
1624
1625 #[test]
1626 fn key_display() {
1627 assert_eq!(FrameKey::new("d", 7, 1).to_string(), "d#7r1");
1628 }
1629}