1use crate::declare::{AlignFrame, Dense, Features, Plan};
92use crate::densewrap::{make_match_backend, make_sift_backend, parse_profile};
93use crate::events::{Command, Event, EventKind, Frame, ZoneRange};
94use crate::pipeline::{Pipeline, QueuePolicy, Summary};
95use crate::session::{FrameSet, Input, Session, SessionConfig};
96use crate::sinks::{self, SinkOptions};
97use cumulus3d_core::io::ply::PointCloud;
98use cumulus3d_core::io::GpsRecord;
99use std::collections::{BTreeMap, HashMap, VecDeque};
100use std::fmt;
101use std::path::{Path, PathBuf};
102use std::sync::mpsc::Receiver;
103use std::sync::{Arc, Condvar, Mutex, MutexGuard};
104use std::thread::JoinHandle;
105use std::time::SystemTime;
106
107#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
113pub struct FrameKey {
114 pub source_id: String,
116 pub sequence: u64,
118 pub revision: u32,
120}
121
122impl FrameKey {
123 pub fn new(source_id: impl Into<String>, sequence: u64, revision: u32) -> Self {
125 Self { source_id: source_id.into(), sequence, revision }
126 }
127}
128
129impl fmt::Display for FrameKey {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 write!(f, "{}#{}r{}", self.source_id, self.sequence, self.revision)
132 }
133}
134
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
137pub enum EncodedFormat {
138 Jpeg,
140 Png,
142}
143
144impl EncodedFormat {
145 fn extension(self) -> &'static str {
146 match self {
147 EncodedFormat::Jpeg => "jpg",
148 EncodedFormat::Png => "png",
149 }
150 }
151}
152
153#[derive(Clone)]
155pub enum FramePayload {
156 File {
158 relative_name: String,
160 },
161 Rgb8 {
163 width: u32,
165 height: u32,
167 pixels: Arc<[u8]>,
169 },
170 Encoded {
172 bytes: Arc<[u8]>,
174 format: EncodedFormat,
176 },
177}
178
179impl fmt::Debug for FramePayload {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 match self {
182 FramePayload::File { relative_name } => f.debug_struct("File").field("relative_name", relative_name).finish(),
183 FramePayload::Rgb8 { width, height, .. } => f.debug_struct("Rgb8").field("width", width).field("height", height).finish(),
184 FramePayload::Encoded { bytes, format } => {
185 f.debug_struct("Encoded").field("bytes", &bytes.len()).field("format", format).finish()
186 }
187 }
188 }
189}
190
191#[derive(Clone, Debug)]
193pub struct LiveImage {
194 pub camera: String,
196 pub payload: FramePayload,
198}
199
200#[derive(Clone, Debug)]
202pub struct LiveFrameSet {
203 pub key: FrameKey,
205 pub timestamp: SystemTime,
207 pub images: Vec<LiveImage>,
209 pub gps: Vec<GpsRecord>,
212}
213
214impl LiveFrameSet {
215 pub fn new(key: FrameKey) -> Self {
217 Self { key, timestamp: SystemTime::now(), images: Vec::new(), gps: Vec::new() }
218 }
219 pub fn image(mut self, camera: impl Into<String>, payload: FramePayload) -> Self {
221 self.images.push(LiveImage { camera: camera.into(), payload });
222 self
223 }
224 pub fn file(self, camera: impl Into<String>, relative_name: impl Into<String>) -> Self {
226 self.image(camera, FramePayload::File { relative_name: relative_name.into() })
227 }
228 pub fn rgb8(self, camera: impl Into<String>, width: u32, height: u32, pixels: impl Into<Arc<[u8]>>) -> Self {
230 self.image(camera, FramePayload::Rgb8 { width, height, pixels: pixels.into() })
231 }
232 pub fn encoded(self, camera: impl Into<String>, bytes: impl Into<Arc<[u8]>>, format: EncodedFormat) -> Self {
234 self.image(camera, FramePayload::Encoded { bytes: bytes.into(), format })
235 }
236 pub fn gps_fix(mut self, camera: impl Into<String>, lat: f64, lon: f64, alt: f64) -> Self {
238 self.gps.push(GpsRecord { name: camera.into(), lat, lon, alt });
239 self
240 }
241 pub fn at(mut self, t: SystemTime) -> Self {
243 self.timestamp = t;
244 self
245 }
246}
247
248#[derive(Clone, Debug)]
250pub enum FrameState {
251 Ready(LiveFrameSet),
253 Pending {
255 key: FrameKey,
257 timestamp: SystemTime,
259 missing_cameras: Vec<String>,
261 },
262 End,
264}
265
266impl FrameState {
267 pub fn ready(set: LiveFrameSet) -> Self {
269 FrameState::Ready(set)
270 }
271 pub fn pending<S: Into<String>>(key: FrameKey, missing_cameras: impl IntoIterator<Item = S>) -> Self {
273 FrameState::Pending { key, timestamp: SystemTime::now(), missing_cameras: missing_cameras.into_iter().map(Into::into).collect() }
274 }
275}
276
277#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
283pub enum ReconcilePolicy {
284 #[default]
286 AppendOnly,
287 ReplayWindow {
291 positions: usize,
293 },
294 InvalidateAffectedZones,
299 Strict,
301}
302
303#[derive(Clone, Copy, Debug, PartialEq, Eq)]
305pub enum Backpressure {
306 Unbounded,
308 Block {
310 max_pending: usize,
312 },
313 KeepLatest {
316 max_pending: usize,
318 },
319}
320
321impl Default for Backpressure {
322 fn default() -> Self {
323 Backpressure::Block { max_pending: 8 }
324 }
325}
326
327#[derive(Clone, Debug, PartialEq, Eq)]
329pub enum IgnoreReason {
330 StaleRevision {
332 accepted: u32,
334 },
335 CorrectionNotApplied,
337 Late,
339 OutsideReplayWindow {
341 position: usize,
343 },
344 ForeignSource {
346 expected: String,
348 },
349 Closed,
351}
352
353impl fmt::Display for IgnoreReason {
354 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
355 match self {
356 IgnoreReason::StaleRevision { accepted } => write!(f, "stale revision (accepted revision {accepted})"),
357 IgnoreReason::CorrectionNotApplied => write!(f, "correction not applied by this policy"),
358 IgnoreReason::Late => write!(f, "late arrival not inserted by this policy"),
359 IgnoreReason::OutsideReplayWindow { position } => write!(f, "outside replay window (position {position})"),
360 IgnoreReason::ForeignSource { expected } => write!(f, "foreign source (node source is {expected})"),
361 IgnoreReason::Closed => write!(f, "node closed"),
362 }
363 }
364}
365
366#[derive(Clone, Debug, PartialEq, Eq)]
368pub enum Outcome {
369 Appended {
371 position: usize,
373 skipped: u64,
375 },
376 Duplicate {
378 position: usize,
380 },
381 Waiting {
383 missing: Vec<String>,
385 },
386 Replayed {
388 from: usize,
390 positions: usize,
392 },
393 Invalidated {
395 position: usize,
397 zones: Vec<usize>,
399 },
400 Ignored(IgnoreReason),
402 Ended,
404}
405
406#[derive(Clone, Debug, PartialEq, Eq)]
408pub enum ComposeError {
409 Config(Vec<String>),
411 InvalidFrameSet(String),
413 Rejected(String),
415 Io(String),
417 Session(String),
419 UnknownNode(String),
421 DuplicateNode(String),
423 Closed,
425}
426
427impl fmt::Display for ComposeError {
428 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
429 match self {
430 ComposeError::Config(v) => write!(f, "invalid node configuration: {}", v.join("; ")),
431 ComposeError::InvalidFrameSet(m) => write!(f, "invalid frame set: {m}"),
432 ComposeError::Rejected(m) => write!(f, "rejected: {m}"),
433 ComposeError::Io(m) => write!(f, "i/o: {m}"),
434 ComposeError::Session(m) => write!(f, "session failed: {m}"),
435 ComposeError::UnknownNode(id) => write!(f, "no node {id:?} on this host"),
436 ComposeError::DuplicateNode(id) => write!(f, "node {id:?} already exists on this host"),
437 ComposeError::Closed => write!(f, "node already closed"),
438 }
439 }
440}
441
442impl std::error::Error for ComposeError {}
443
444#[derive(Clone, Debug, Default, PartialEq, Eq)]
446pub struct ComposeStats {
447 pub declared: u64,
449 pub appended: u64,
451 pub duplicates: u64,
453 pub waiting: u64,
455 pub skipped_sequences: u64,
457 pub replays: u64,
459 pub replayed_positions: u64,
461 pub invalidations: u64,
463 pub ignored: u64,
465}
466
467pub struct Closed {
469 pub session: Session,
471 pub summary: Summary,
473 pub stats: ComposeStats,
475}
476
477impl fmt::Debug for Closed {
478 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
479 f.debug_struct("Closed")
480 .field("positions", &self.session.positions())
481 .field("summary", &self.summary)
482 .field("stats", &self.stats)
483 .finish()
484 }
485}
486
487#[derive(Clone, Default)]
493pub struct CompositionHost {
494 nodes: Arc<Mutex<BTreeMap<String, ReconNode>>>,
495}
496
497impl fmt::Debug for CompositionHost {
498 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
499 f.debug_struct("CompositionHost").field("nodes", &self.ids()).finish()
500 }
501}
502
503impl CompositionHost {
504 pub fn new() -> Self {
506 Self::default()
507 }
508 pub fn node(&self, id: &str) -> Option<ReconNode> {
510 lock(&self.nodes).get(id).cloned()
511 }
512 pub fn ids(&self) -> Vec<String> {
514 lock(&self.nodes).keys().cloned().collect()
515 }
516 pub fn compose(&self, id: &str, state: FrameState) -> Result<Outcome, ComposeError> {
518 self.node(id).ok_or_else(|| ComposeError::UnknownNode(id.to_string()))?.compose(state)
519 }
520 pub fn close(&self, id: &str) -> Result<Closed, ComposeError> {
522 let node = lock(&self.nodes).remove(id).ok_or_else(|| ComposeError::UnknownNode(id.to_string()))?;
523 node.close_inner()
524 }
525 pub fn close_all(&self) -> Vec<(String, Result<Closed, ComposeError>)> {
527 let nodes = std::mem::take(&mut *lock(&self.nodes));
528 nodes.into_iter().map(|(id, n)| (id, n.close_inner())).collect()
529 }
530 fn register(&self, node: &ReconNode) -> Result<(), ComposeError> {
531 let mut m = lock(&self.nodes);
532 if m.contains_key(&node.id) {
533 return Err(ComposeError::DuplicateNode(node.id.clone()));
534 }
535 m.insert(node.id.clone(), node.clone());
536 Ok(())
537 }
538}
539
540pub fn reconstruct(host: &CompositionHost, id: &str, state: FrameState) -> Result<Outcome, ComposeError> {
543 host.compose(id, state)
544}
545
546type Attach = Box<dyn FnOnce(Pipeline<Session>) -> Pipeline<Session> + Send>;
551
552pub struct NodeBuilder {
557 id: String,
558 plan: Plan,
559 policy: ReconcilePolicy,
560 backpressure: Backpressure,
561 source: Option<String>,
562 spool: PathBuf,
563 output: Option<(PathBuf, SinkOptions)>,
564 attach: Vec<Attach>,
565 frames: bool,
566}
567
568impl fmt::Debug for NodeBuilder {
569 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
570 f.debug_struct("NodeBuilder").field("id", &self.id).field("policy", &self.policy).field("hooks", &self.attach.len()).finish()
571 }
572}
573
574impl ReconNode {
575 pub fn declare() -> NodeBuilder {
577 let mut plan = Plan::default();
578 plan.dense.enabled = false;
579 plan.gps = None;
580 NodeBuilder {
581 id: "default".to_string(),
582 plan,
583 policy: ReconcilePolicy::default(),
584 backpressure: Backpressure::default(),
585 source: None,
586 spool: PathBuf::from(".live"),
587 output: None,
588 attach: Vec::new(),
589 frames: false,
590 }
591 }
592}
593
594impl NodeBuilder {
595 pub fn id(mut self, id: impl Into<String>) -> Self {
597 self.id = id.into();
598 self
599 }
600 pub fn plan(mut self, plan: Plan) -> Self {
603 self.plan = plan;
604 self
605 }
606 pub fn images(mut self, dir: impl Into<PathBuf>) -> Self {
608 self.plan.input.images = dir.into();
609 self
610 }
611 pub fn cameras<S: Into<String>>(mut self, cams: impl IntoIterator<Item = S>) -> Self {
613 self.plan.input.cameras = cams.into_iter().map(Into::into).collect();
614 self
615 }
616 pub fn zones(mut self, span: usize, overlap: usize) -> Self {
618 self.plan.zones.span = span;
619 self.plan.zones.overlap = overlap;
620 self
621 }
622 pub fn align(mut self, frame: AlignFrame) -> Self {
624 self.plan.align.frame = frame;
625 self
626 }
627 pub fn fixed_enu_origin(mut self, on: bool) -> Self {
629 self.plan.align.fixed_origin = on;
630 self
631 }
632 pub fn features(mut self, f: Features) -> Self {
634 self.plan.features = f;
635 self
636 }
637 pub fn match_backend(mut self, name: &str) -> Self {
639 self.plan.matching.backend = name.to_string();
640 self
641 }
642 pub fn gpu(mut self) -> Self {
644 self.plan.features.backend = "cuda".into();
645 self.plan.matching.backend = "cuda".into();
646 self.plan.dense.backend = "cuda".into();
647 self.plan.dense.undistort_backend = "cuda".into();
648 self
649 }
650 pub fn incremental_triangulation(mut self, on: bool) -> Self {
652 self.plan.sparse.incremental_triangulation = on;
653 self
654 }
655 pub fn dense(mut self, d: Dense) -> Self {
657 self.plan.dense = Dense { enabled: true, ..d };
658 self
659 }
660 pub fn no_dense(mut self) -> Self {
662 self.plan.dense.enabled = false;
663 self
664 }
665 pub fn seed(mut self, seed: u64) -> Self {
667 self.plan.seed = Some(seed);
668 self
669 }
670 pub fn reconcile(mut self, p: ReconcilePolicy) -> Self {
672 self.policy = p;
673 self
674 }
675 pub fn backpressure(mut self, b: Backpressure) -> Self {
677 self.backpressure = b;
678 self
679 }
680 pub fn source(mut self, id: impl Into<String>) -> Self {
682 self.source = Some(id.into());
683 self
684 }
685 pub fn spool(mut self, rel: impl Into<PathBuf>) -> Self {
687 self.spool = rel.into();
688 self
689 }
690 pub fn output(mut self, dir: impl Into<PathBuf>, opts: SinkOptions) -> Self {
692 self.output = Some((dir.into(), opts));
693 self
694 }
695 fn hook(mut self, f: impl FnOnce(Pipeline<Session>) -> Pipeline<Session> + Send + 'static) -> Self {
696 self.attach.push(Box::new(f));
697 self
698 }
699 pub fn on<F>(mut self, kind: EventKind, f: F) -> Self
701 where
702 F: FnMut(&Event) + Send + 'static,
703 {
704 self.frames |= kind == EventKind::FrameDecoded;
705 self.hook(move |p| p.on(kind, f))
706 }
707 pub fn on_event<F>(self, f: F) -> Self
709 where
710 F: FnMut(&Event) + Send + 'static,
711 {
712 self.hook(move |p| p.on_any(f))
713 }
714 pub fn on_frame<F>(mut self, f: F) -> Self
716 where
717 F: FnMut(&Arc<Frame>) + Send + 'static,
718 {
719 self.frames = true;
720 self.hook(move |p| p.on_frame(f))
721 }
722 pub fn on_zone_preview<F>(self, f: F) -> Self
724 where
725 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
726 {
727 self.hook(move |p| p.on_zone_preview(f))
728 }
729 pub fn on_zone_refined<F>(self, f: F) -> Self
731 where
732 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
733 {
734 self.hook(move |p| p.on_zone_refined(f))
735 }
736 pub fn on_message<F>(self, f: F) -> Self
738 where
739 F: FnMut(EventKind, &str) + Send + 'static,
740 {
741 self.hook(move |p| p.on_message(f))
742 }
743 pub fn event_policy(self, kind: EventKind, p: QueuePolicy) -> Self {
745 self.hook(move |pl| pl.policy(kind, p))
746 }
747 pub fn sync_hooks(self, on: bool) -> Self {
749 self.hook(move |pl| pl.sync(on))
750 }
751
752 pub fn build(self, host: &CompositionHost) -> Result<ReconNode, ComposeError> {
755 let NodeBuilder { id, plan, policy, backpressure, source, spool, output, attach, frames } = self;
756 let mut pr = Vec::new();
757 let cams = &plan.input.cameras;
758 if cams.is_empty() {
759 pr.push("cameras: empty".to_string());
760 }
761 let mut seen = std::collections::BTreeSet::new();
762 for c in cams {
763 if c.is_empty() || c.contains(['/', '\\']) {
764 pr.push(format!("cameras: invalid name {c:?}"));
765 } else if !seen.insert(c.as_str()) {
766 pr.push(format!("cameras: duplicate {c}"));
767 }
768 }
769 let z = plan.zones;
770 if z.span == 0 {
771 pr.push("zones.span: must be at least 1".into());
772 } else if z.overlap >= z.span {
773 pr.push(format!("zones.overlap: must be smaller than the span ({}), got {}", z.span, z.overlap));
774 }
775 if !plan.input.images.is_dir() {
776 pr.push(format!("images: folder not found: {}", plan.input.images.display()));
777 }
778 if spool.is_absolute() || spool.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
779 pr.push(format!("spool: must be a relative path inside the image folder, got {}", spool.display()));
780 }
781 if let ReconcilePolicy::ReplayWindow { positions: 0 } = policy {
782 pr.push("reconcile: replay window must be at least 1 position".into());
783 }
784 match backpressure {
785 Backpressure::Block { max_pending: 0 } | Backpressure::KeepLatest { max_pending: 0 } => {
786 pr.push("backpressure: max_pending must be at least 1".into())
787 }
788 _ => {}
789 }
790 let sift = make_sift_backend(&plan.features.backend).map_err(|e| pr.push(format!("features.backend: {e}"))).ok();
791 let matcher = make_match_backend(&plan.matching.backend).map_err(|e| pr.push(format!("matching.backend: {e}"))).ok();
792 if plan.dense.enabled {
793 if let Err(e) = parse_profile(&plan.dense.profile) {
794 pr.push(format!("dense.profile: {e}"));
795 }
796 }
797 let dense = if plan.dense.enabled { plan.dense.config().map_err(|e| pr.push(format!("dense: {e}"))).ok() } else { None };
798 if !pr.is_empty() {
799 return Err(ComposeError::Config(pr));
800 }
801 if host.node(&id).is_some() {
802 return Err(ComposeError::DuplicateNode(id));
803 }
804
805 let mut sc = SessionConfig::new(plan.input.images.clone());
806 sc.span = z.span;
807 sc.overlap = z.overlap;
808 sc.total_positions = None;
809 sc.incremental_triangulation = plan.sparse.incremental_triangulation;
810 sc.fixed_enu_origin = plan.align.fixed_origin;
811 sc.seed = plan.seed;
812 sc.extraction.sift.max_num_features = plan.features.max_num_features;
813 sc.extraction.reader.max_image_size = plan.features.max_image_size;
814 sc.sift = sift.expect("checked");
815 sc.matcher = Arc::from(matcher.expect("checked"));
816 sc.dense = dense;
817 sc.history = match policy {
818 ReconcilePolicy::ReplayWindow { positions } => positions + 1,
819 _ => 0,
820 };
821 sc.decode_frames = frames;
822
823 let mut pl = Pipeline::new(Session::new(sc));
824 if let Some((dir, opts)) = &output {
825 pl = sinks::attach(pl, dir, opts).map_err(|e| ComposeError::Io(format!("{}: {e}", dir.display())))?;
826 }
827 for a in attach {
828 pl = a(pl);
829 }
830 let core = Core {
831 cameras: plan.input.cameras.clone(),
832 image_root: plan.input.images.clone(),
833 spool,
834 policy,
835 source,
836 accepted: BTreeMap::new(),
837 order: Vec::new(),
838 recent: VecDeque::new(),
839 waiting: BTreeMap::new(),
840 stats: ComposeStats::default(),
841 pipeline: Some(pl),
842 ended: None,
843 };
844 let node = ReconNode { id, backpressure, core: Arc::new(Mutex::new(core)) };
845 host.register(&node)?;
846 Ok(node)
847 }
848}
849
850#[derive(Clone)]
857pub struct ReconNode {
858 id: String,
859 backpressure: Backpressure,
860 core: Arc<Mutex<Core>>,
861}
862
863impl fmt::Debug for ReconNode {
864 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
865 f.debug_struct("ReconNode").field("id", &self.id).finish()
866 }
867}
868
869#[derive(Clone, Copy, Debug)]
870struct Accepted {
871 revision: u32,
872 position: usize,
873}
874
875struct Core {
876 cameras: Vec<String>,
877 image_root: PathBuf,
878 spool: PathBuf,
879 policy: ReconcilePolicy,
880 source: Option<String>,
881 accepted: BTreeMap<u64, Accepted>,
883 order: Vec<u64>,
885 recent: VecDeque<FrameSet>,
887 waiting: BTreeMap<u64, Vec<String>>,
889 stats: ComposeStats,
890 pipeline: Option<Pipeline<Session>>,
891 ended: Option<Closed>,
893}
894
895impl ReconNode {
896 pub fn id(&self) -> &str {
898 &self.id
899 }
900
901 pub fn compose(&self, state: FrameState) -> Result<Outcome, ComposeError> {
903 lock(&self.core).compose(state)
904 }
905
906 pub fn compose_all(&self, states: impl IntoIterator<Item = FrameState>) -> Result<Vec<Outcome>, ComposeError> {
908 states.into_iter().map(|s| self.compose(s)).collect()
909 }
910
911 pub fn poll(&self) -> usize {
913 lock(&self.core).pipeline.as_mut().map_or(0, |p| p.poll())
914 }
915
916 pub fn subscribe(&self) -> Option<Receiver<Event>> {
918 lock(&self.core).pipeline.as_ref().map(|p| p.subscribe())
919 }
920
921 pub fn stats(&self) -> ComposeStats {
923 lock(&self.core).stats.clone()
924 }
925
926 pub fn positions(&self) -> usize {
928 lock(&self.core).order.len()
929 }
930
931 pub fn journal(&self) -> Vec<(u64, u32)> {
933 let c = lock(&self.core);
934 c.order.iter().map(|s| (*s, c.accepted[s].revision)).collect()
935 }
936
937 pub fn waiting(&self) -> Vec<(u64, Vec<String>)> {
939 lock(&self.core).waiting.iter().map(|(k, v)| (*k, v.clone())).collect()
940 }
941
942 pub fn with_session<T>(&self, f: impl FnOnce(&Session) -> T) -> Option<T> {
944 lock(&self.core).pipeline.as_ref().map(|p| f(p.reducer()))
945 }
946
947 pub fn close(&self) -> Result<Closed, ComposeError> {
950 self.close_inner()
951 }
952
953 fn close_inner(&self) -> Result<Closed, ComposeError> {
954 let mut c = lock(&self.core);
955 if let Some(done) = c.ended.take() {
956 return Ok(done);
957 }
958 c.finish().ok_or(ComposeError::Closed)
959 }
960
961 pub fn spawn(&self) -> ComposeHandle {
964 let queue = Arc::new(Queue { st: Mutex::new(QueueState::default()), cv: Condvar::new() });
965 let (q, node) = (Arc::clone(&queue), self.clone());
966 let worker = std::thread::Builder::new()
967 .name(format!("compose-{}", self.id))
968 .spawn(move || {
969 let mut outcomes = Vec::new();
970 loop {
971 let next = {
972 let mut st = lock(&q.st);
973 loop {
974 if let Some(s) = st.items.pop_front() {
975 q.cv.notify_all();
976 break Some(s);
977 }
978 if st.closed {
979 break None;
980 }
981 st = q.cv.wait(st).unwrap_or_else(|p| p.into_inner());
982 }
983 };
984 let Some(state) = next else { break };
985 let end = matches!(state, FrameState::End);
986 let r = node.compose(state);
987 lock(&q.st).processed += 1;
988 let fatal = matches!(r, Err(ComposeError::Session(_)));
989 outcomes.push(r);
990 if end || fatal {
991 break;
992 }
993 }
994 let mut st = lock(&q.st);
995 st.closed = true;
996 st.items.clear();
997 q.cv.notify_all();
998 outcomes
999 })
1000 .expect("compose worker thread");
1001 ComposeHandle { node: self.clone(), queue, policy: self.backpressure, worker: Some(worker) }
1002 }
1003}
1004
1005impl Core {
1006 fn pl(&mut self) -> &mut Pipeline<Session> {
1007 self.pipeline.as_mut().expect("open pipeline")
1008 }
1009
1010 fn warn(&mut self, message: String) {
1011 if let Some(p) = self.pipeline.as_mut() {
1012 p.reducer().emit_external(|meta| Event::Warning { meta, message });
1013 p.poll();
1014 }
1015 }
1016
1017 fn ignore(&mut self, key: &FrameKey, reason: IgnoreReason) -> Result<Outcome, ComposeError> {
1018 if self.policy == ReconcilePolicy::Strict && reason != IgnoreReason::Closed {
1019 return Err(ComposeError::Rejected(format!("{key}: {reason}")));
1020 }
1021 self.stats.ignored += 1;
1022 self.warn(format!("compose: {key} ignored: {reason}"));
1023 Ok(Outcome::Ignored(reason))
1024 }
1025
1026 fn compose(&mut self, state: FrameState) -> Result<Outcome, ComposeError> {
1027 self.stats.declared += 1;
1028 if self.pipeline.is_none() {
1029 return match state {
1030 FrameState::End => Ok(Outcome::Ended),
1031 _ => Ok(Outcome::Ignored(IgnoreReason::Closed)),
1032 };
1033 }
1034 match state {
1035 FrameState::End => {
1036 let done = self.finish().ok_or(ComposeError::Closed)?;
1037 self.ended = Some(done);
1038 Ok(Outcome::Ended)
1039 }
1040 FrameState::Pending { key, missing_cameras, .. } => {
1041 if let Some(r) = self.check_source(&key) {
1042 return r;
1043 }
1044 self.stats.waiting += 1;
1045 if !self.accepted.contains_key(&key.sequence) {
1046 self.waiting.insert(key.sequence, missing_cameras.clone());
1047 }
1048 Ok(Outcome::Waiting { missing: missing_cameras })
1049 }
1050 FrameState::Ready(set) => self.ready(set),
1051 }
1052 }
1053
1054 fn check_source(&mut self, key: &FrameKey) -> Option<Result<Outcome, ComposeError>> {
1055 match &self.source {
1056 None => {
1057 self.source = Some(key.source_id.clone());
1058 None
1059 }
1060 Some(s) if *s == key.source_id => None,
1061 Some(s) => {
1062 let expected = s.clone();
1063 Some(self.ignore(key, IgnoreReason::ForeignSource { expected }))
1064 }
1065 }
1066 }
1067
1068 fn ready(&mut self, set: LiveFrameSet) -> Result<Outcome, ComposeError> {
1069 let key = set.key.clone();
1070 let mut by_cam: HashMap<&str, &LiveImage> = HashMap::new();
1072 for im in &set.images {
1073 if !self.cameras.contains(&im.camera) {
1074 return Err(ComposeError::InvalidFrameSet(format!("{key}: unknown camera {}", im.camera)));
1075 }
1076 if by_cam.insert(im.camera.as_str(), im).is_some() {
1077 return Err(ComposeError::InvalidFrameSet(format!("{key}: camera {} given twice", im.camera)));
1078 }
1079 }
1080 if let Some(r) = self.check_source(&key) {
1081 return r;
1082 }
1083 let missing: Vec<String> = self.cameras.iter().filter(|c| !by_cam.contains_key(c.as_str())).cloned().collect();
1084 if !missing.is_empty() && !self.accepted.contains_key(&key.sequence) {
1085 self.stats.waiting += 1;
1086 self.waiting.insert(key.sequence, missing.clone());
1087 return Ok(Outcome::Waiting { missing });
1088 }
1089
1090 if let Some(acc) = self.accepted.get(&key.sequence).copied() {
1091 if key.revision == acc.revision {
1092 self.stats.duplicates += 1;
1093 return Ok(Outcome::Duplicate { position: acc.position });
1094 }
1095 if key.revision < acc.revision {
1096 return self.ignore(&key, IgnoreReason::StaleRevision { accepted: acc.revision });
1097 }
1098 if !missing.is_empty() {
1099 return Err(ComposeError::InvalidFrameSet(format!("{key}: correction misses cameras {}", missing.join(", "))));
1100 }
1101 return self.correct(set, acc);
1102 }
1103
1104 let newest = self.order.last().copied();
1105 match newest {
1106 Some(n) if key.sequence < n => self.late(set),
1107 _ => {
1108 let skipped = newest.map_or(0, |n| key.sequence - n - 1);
1109 if skipped > 0 && self.policy == ReconcilePolicy::Strict {
1110 return Err(ComposeError::Rejected(format!("{key}: {skipped} sequence(s) missing before it")));
1111 }
1112 let fs = self.resolve(&set, None)?;
1113 self.waiting.remove(&key.sequence);
1114 let position = self.order.len();
1115 self.push(fs)?;
1116 self.order.push(key.sequence);
1117 self.accepted.insert(key.sequence, Accepted { revision: key.revision, position });
1118 self.stats.appended += 1;
1119 if skipped > 0 {
1120 self.stats.skipped_sequences += skipped;
1121 self.warn(format!("compose: {key} appended at position {position} after {skipped} missing sequence(s)"));
1122 }
1123 Ok(Outcome::Appended { position, skipped })
1124 }
1125 }
1126 }
1127
1128 fn late(&mut self, set: LiveFrameSet) -> Result<Outcome, ComposeError> {
1130 let key = set.key.clone();
1131 let ReconcilePolicy::ReplayWindow { positions: w } = self.policy else {
1132 return self.ignore(&key, IgnoreReason::Late);
1133 };
1134 let p = self.order.partition_point(|&s| s < key.sequence);
1135 if !self.in_window(p, w) {
1136 return self.ignore(&key, IgnoreReason::OutsideReplayWindow { position: p });
1137 }
1138 let fs = self.resolve(&set, None)?;
1139 let tail_sets: Vec<FrameSet> = self.recent.iter().skip(self.recent.len() - (self.order.len() - p)).cloned().collect();
1140 let mut tail = vec![fs];
1141 tail.extend(tail_sets);
1142 let mut seqs = vec![key.sequence];
1143 seqs.extend(self.order[p..].iter().copied());
1144 self.waiting.remove(&key.sequence);
1145 let n = self.replay(p, tail, &seqs, (key.sequence, key.revision))?;
1146 self.warn(format!("compose: late {key} inserted at position {p}, replayed {n} position(s)"));
1147 Ok(Outcome::Replayed { from: p, positions: n })
1148 }
1149
1150 fn correct(&mut self, set: LiveFrameSet, acc: Accepted) -> Result<Outcome, ComposeError> {
1152 let key = set.key.clone();
1153 let p = acc.position;
1154 match self.policy {
1155 ReconcilePolicy::AppendOnly | ReconcilePolicy::Strict => self.ignore(&key, IgnoreReason::CorrectionNotApplied),
1156 ReconcilePolicy::ReplayWindow { positions: w } => {
1157 if !self.in_window(p, w) {
1158 return self.ignore(&key, IgnoreReason::OutsideReplayWindow { position: p });
1159 }
1160 let fs = self.resolve(&set, None)?;
1161 let start = self.recent.len() - (self.order.len() - p);
1162 let mut tail: Vec<FrameSet> = self.recent.iter().skip(start).cloned().collect();
1163 tail[0] = fs;
1164 let seqs: Vec<u64> = self.order[p..].to_vec();
1165 let n = self.replay(p, tail, &seqs, (key.sequence, key.revision))?;
1166 self.warn(format!("compose: correction {key} applied from position {p}, replayed {n} position(s)"));
1167 Ok(Outcome::Replayed { from: p, positions: n })
1168 }
1169 ReconcilePolicy::InvalidateAffectedZones => {
1170 let names: Vec<String> = self.pl().reducer().frames()[p].iter().map(|f| f.name.clone()).collect();
1172 self.resolve(&set, Some(&names))?;
1173 let zones: Vec<usize> =
1174 self.pl().reducer().arrived_zones().into_iter().filter(|z| z.lo <= p && p < z.hi).map(|z| z.zone).collect();
1175 for &z in &zones {
1176 self.pl().command(Command::InvalidateZone(z));
1177 }
1178 self.fail_check()?;
1179 self.accepted.insert(key.sequence, Accepted { revision: key.revision, position: p });
1180 self.stats.invalidations += 1;
1181 Ok(Outcome::Invalidated { position: p, zones })
1182 }
1183 }
1184 }
1185
1186 fn in_window(&self, p: usize, w: usize) -> bool {
1188 let npos = self.order.len();
1189 npos - p <= w && npos - p <= self.recent.len()
1190 }
1191
1192 fn replay(&mut self, p: usize, tail: Vec<FrameSet>, seqs: &[u64], changed: (u64, u32)) -> Result<usize, ComposeError> {
1195 let npos = self.order.len();
1196 self.pl().command(Command::ResetFrom(p));
1197 if self.pl().reducer().positions() != p {
1198 return Err(ComposeError::Session(format!("rewind to position {p} was refused by the session")));
1199 }
1200 let revs: HashMap<u64, u32> = self.order[p..].iter().map(|s| (*s, self.accepted[s].revision)).collect();
1201 for s in &self.order[p..] {
1202 self.accepted.remove(s);
1203 }
1204 self.order.truncate(p);
1205 let keep = self.recent.len() - (npos - p);
1206 self.recent.truncate(keep);
1207 let n = tail.len();
1208 for (fs, &seq) in tail.into_iter().zip(seqs) {
1209 let revision = if seq == changed.0 { changed.1 } else { revs[&seq] };
1210 let position = self.order.len();
1211 self.push(fs)?;
1212 self.order.push(seq);
1213 self.accepted.insert(seq, Accepted { revision, position });
1214 }
1215 self.stats.replays += 1;
1216 self.stats.replayed_positions += n as u64;
1217 Ok(n)
1218 }
1219
1220 fn push(&mut self, fs: FrameSet) -> Result<(), ComposeError> {
1222 let cap = match self.policy {
1223 ReconcilePolicy::ReplayWindow { positions } => positions,
1224 _ => 0,
1225 };
1226 if cap > 0 {
1227 self.recent.push_back(fs.clone());
1228 while self.recent.len() > cap {
1229 self.recent.pop_front();
1230 }
1231 }
1232 self.pl().push(Input::Frames(fs));
1233 self.fail_check()
1234 }
1235
1236 fn fail_check(&mut self) -> Result<(), ComposeError> {
1237 match self.pl().reducer().failed() {
1238 Some(e) => Err(ComposeError::Session(e.to_string())),
1239 None => Ok(()),
1240 }
1241 }
1242
1243 fn resolve(&self, set: &LiveFrameSet, names: Option<&[String]>) -> Result<FrameSet, ComposeError> {
1246 let key = &set.key;
1247 let mut fs = FrameSet::default();
1248 for (i, cam) in self.cameras.iter().enumerate() {
1249 let im = set.images.iter().find(|x| &x.camera == cam).expect("complete set");
1250 let forced = names.map(|n| n[i].clone());
1251 let name = match &im.payload {
1252 FramePayload::File { relative_name } => {
1253 let src = self.image_root.join(relative_name);
1254 if !src.is_file() {
1255 return Err(ComposeError::InvalidFrameSet(format!("{key}: file not found: {}", src.display())));
1256 }
1257 match forced {
1258 Some(n) if n != *relative_name => {
1259 let bytes = std::fs::read(&src).map_err(|e| ComposeError::Io(format!("{}: {e}", src.display())))?;
1260 self.store_encoded(&n, &bytes, ext_of(relative_name))?;
1261 n
1262 }
1263 _ => relative_name.clone(),
1264 }
1265 }
1266 FramePayload::Rgb8 { width, height, pixels } => {
1267 if pixels.len() != *width as usize * *height as usize * 3 || *width == 0 || *height == 0 {
1268 return Err(ComposeError::InvalidFrameSet(format!(
1269 "{key}: camera {cam}: {} bytes for {width}x{height} RGB8",
1270 pixels.len()
1271 )));
1272 }
1273 let n = forced.unwrap_or_else(|| self.spool_name(key, cam, "png"));
1274 let img = image::RgbImage::from_raw(*width, *height, pixels.to_vec()).expect("size checked");
1275 self.store_image(&n, &image::DynamicImage::ImageRgb8(img))?;
1276 n
1277 }
1278 FramePayload::Encoded { bytes, format } => {
1279 let n = forced.unwrap_or_else(|| self.spool_name(key, cam, format.extension()));
1280 self.store_encoded(&n, bytes, Some(format.extension()))?;
1281 n
1282 }
1283 };
1284 fs.images.push(crate::session::FrameImage { camera: cam.clone(), name });
1285 }
1286 for g in &set.gps {
1287 let mut g = g.clone();
1288 if let Some(i) = self.cameras.iter().position(|c| *c == g.name) {
1289 g.name = fs.images[i].name.clone();
1290 }
1291 fs.gps.push(g);
1292 }
1293 Ok(fs)
1294 }
1295
1296 fn spool_name(&self, key: &FrameKey, cam: &str, ext: &str) -> String {
1297 let src: String = key.source_id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
1298 let p = self.spool.join(cam).join(format!("{src}_{:010}.{ext}", key.sequence));
1299 p.to_string_lossy().replace('\\', "/")
1300 }
1301
1302 fn store_encoded(&self, name: &str, bytes: &[u8], ext: Option<&str>) -> Result<(), ComposeError> {
1304 if ext.is_some() && ext == ext_of(name) {
1305 return self.atomic_write(name, |path| std::fs::write(path, bytes).map_err(|e| e.to_string()));
1306 }
1307 let img = image::load_from_memory(bytes).map_err(|e| ComposeError::InvalidFrameSet(format!("{name}: {e}")))?;
1308 self.store_image(name, &img)
1309 }
1310
1311 fn store_image(&self, name: &str, img: &image::DynamicImage) -> Result<(), ComposeError> {
1312 let fmt = image::ImageFormat::from_path(name).map_err(|e| ComposeError::Io(format!("{name}: {e}")))?;
1313 self.atomic_write(name, |path| img.save_with_format(path, fmt).map_err(|e| e.to_string()))
1314 }
1315
1316 fn atomic_write(&self, name: &str, write: impl FnOnce(&Path) -> Result<(), String>) -> Result<(), ComposeError> {
1318 let dst = self.image_root.join(name);
1319 let dir = dst.parent().unwrap_or(&self.image_root);
1320 std::fs::create_dir_all(dir).map_err(|e| ComposeError::Io(format!("{}: {e}", dir.display())))?;
1321 let file = dst.file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
1322 let ext = ext_of(name).unwrap_or("tmp");
1323 let tmp = dir.join(format!(".{file}.partial.{ext}"));
1324 write(&tmp).map_err(|e| ComposeError::Io(format!("{}: {e}", tmp.display())))?;
1325 std::fs::rename(&tmp, &dst).map_err(|e| ComposeError::Io(format!("{}: {e}", dst.display())))
1326 }
1327
1328 fn finish(&mut self) -> Option<Closed> {
1329 let pl = self.pipeline.take()?;
1330 let (session, summary) = pl.finish();
1331 Some(Closed { session, summary, stats: self.stats.clone() })
1332 }
1333}
1334
1335fn ext_of(name: &str) -> Option<&str> {
1336 Path::new(name).extension().and_then(|e| e.to_str())
1337}
1338
1339fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
1340 m.lock().unwrap_or_else(|p| p.into_inner())
1341}
1342
1343#[derive(Default)]
1348struct QueueState {
1349 items: VecDeque<FrameState>,
1350 closed: bool,
1351 submitted: u64,
1352 dropped: u64,
1353 processed: u64,
1354 max_depth: usize,
1355}
1356
1357struct Queue {
1358 st: Mutex<QueueState>,
1359 cv: Condvar,
1360}
1361
1362#[derive(Debug)]
1364pub enum SendError {
1365 Full(FrameState),
1367 Closed(FrameState),
1369}
1370
1371impl fmt::Display for SendError {
1372 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1373 match self {
1374 SendError::Full(_) => write!(f, "compose queue full"),
1375 SendError::Closed(_) => write!(f, "compose worker stopped"),
1376 }
1377 }
1378}
1379
1380impl std::error::Error for SendError {}
1381
1382#[derive(Clone, Debug, Default, PartialEq, Eq)]
1384pub struct QueueStats {
1385 pub submitted: u64,
1387 pub dropped: u64,
1389 pub processed: u64,
1391 pub depth: usize,
1393 pub max_depth: usize,
1395}
1396
1397pub struct ComposeHandle {
1399 node: ReconNode,
1400 queue: Arc<Queue>,
1401 policy: Backpressure,
1402 worker: Option<JoinHandle<Vec<Result<Outcome, ComposeError>>>>,
1403}
1404
1405impl fmt::Debug for ComposeHandle {
1406 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1407 f.debug_struct("ComposeHandle").field("node", &self.node.id).field("policy", &self.policy).field("stats", &self.stats()).finish()
1408 }
1409}
1410
1411impl ComposeHandle {
1412 pub fn node(&self) -> &ReconNode {
1414 &self.node
1415 }
1416
1417 pub fn send(&self, state: FrameState) -> Result<(), SendError> {
1419 self.enqueue(state, true)
1420 }
1421
1422 pub fn try_send(&self, state: FrameState) -> Result<(), SendError> {
1424 self.enqueue(state, false)
1425 }
1426
1427 pub fn send_all(&self, states: impl IntoIterator<Item = FrameState>) -> Result<(), SendError> {
1429 states.into_iter().try_for_each(|s| self.send(s))
1430 }
1431
1432 fn enqueue(&self, state: FrameState, wait: bool) -> Result<(), SendError> {
1433 let q = &self.queue;
1434 let mut st = lock(&q.st);
1435 if st.closed {
1436 return Err(SendError::Closed(state));
1437 }
1438 let end = matches!(state, FrameState::End);
1440 match self.policy {
1441 Backpressure::Unbounded => {}
1442 Backpressure::Block { max_pending } => {
1443 while !end && st.items.len() >= max_pending && !st.closed {
1444 if !wait {
1445 return Err(SendError::Full(state));
1446 }
1447 st = q.cv.wait(st).unwrap_or_else(|p| p.into_inner());
1448 }
1449 if st.closed {
1450 return Err(SendError::Closed(state));
1451 }
1452 }
1453 Backpressure::KeepLatest { max_pending } => {
1454 while !end && st.items.len() >= max_pending {
1455 match st.items.iter().position(|s| matches!(s, FrameState::Ready(_))) {
1456 Some(i) => {
1457 st.items.remove(i);
1458 st.dropped += 1;
1459 }
1460 None => {
1461 st.items.pop_front();
1462 }
1463 }
1464 }
1465 }
1466 }
1467 st.items.push_back(state);
1468 st.submitted += 1;
1469 st.max_depth = st.max_depth.max(st.items.len());
1470 q.cv.notify_all();
1471 Ok(())
1472 }
1473
1474 pub fn stats(&self) -> QueueStats {
1476 let st = lock(&self.queue.st);
1477 QueueStats { submitted: st.submitted, dropped: st.dropped, processed: st.processed, depth: st.items.len(), max_depth: st.max_depth }
1478 }
1479
1480 pub fn close(mut self) -> (Vec<Result<Outcome, ComposeError>>, Result<Closed, ComposeError>) {
1483 let _ = self.enqueue(FrameState::End, false);
1484 let outcomes = self.worker.take().map(|w| w.join().unwrap_or_default()).unwrap_or_default();
1485 (outcomes, self.node.close_inner())
1486 }
1487}
1488
1489impl Drop for ComposeHandle {
1490 fn drop(&mut self) {
1491 if let Some(w) = self.worker.take() {
1492 {
1493 let mut st = lock(&self.queue.st);
1494 st.closed = true;
1495 self.queue.cv.notify_all();
1496 }
1497 let _ = w.join();
1498 }
1499 }
1500}
1501
1502#[cfg(test)]
1503mod tests {
1504 use super::*;
1505
1506 #[test]
1507 fn spool_names_are_sanitized_and_stable() {
1508 let c = Core {
1509 cameras: vec!["camF".into()],
1510 image_root: PathBuf::from("/tmp/x"),
1511 spool: PathBuf::from(".live"),
1512 policy: ReconcilePolicy::AppendOnly,
1513 source: None,
1514 accepted: BTreeMap::new(),
1515 order: Vec::new(),
1516 recent: VecDeque::new(),
1517 waiting: BTreeMap::new(),
1518 stats: ComposeStats::default(),
1519 pipeline: None,
1520 ended: None,
1521 };
1522 let k = FrameKey::new("rtsp://drone 1", 42, 3);
1523 assert_eq!(c.spool_name(&k, "camF", "png"), ".live/camF/rtsp___drone_1_0000000042.png");
1524 }
1525
1526 #[test]
1527 fn key_display() {
1528 assert_eq!(FrameKey::new("d", 7, 1).to_string(), "d#7r1");
1529 }
1530}