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