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::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.dense.backend = "cuda".into();
651 self.plan.dense.undistort_backend = "cuda".into();
652 self
653 }
654 pub fn stable_display(mut self, on: bool) -> Self {
659 self.plan.sparse.stable_display = on;
660 self
661 }
662 pub fn display_deltas(mut self, opts: LiveOptions) -> Self {
669 self.deltas = Some(opts);
670 self
671 }
672 pub fn incremental_triangulation(mut self, on: bool) -> Self {
674 self.plan.sparse.incremental_triangulation = on;
675 self
676 }
677 pub fn dense(mut self, d: Dense) -> Self {
679 self.plan.dense = Dense { enabled: true, ..d };
680 self
681 }
682 pub fn no_dense(mut self) -> Self {
684 self.plan.dense.enabled = false;
685 self
686 }
687 pub fn seed(mut self, seed: u64) -> Self {
689 self.plan.seed = Some(seed);
690 self
691 }
692 pub fn reconcile(mut self, p: ReconcilePolicy) -> Self {
694 self.policy = p;
695 self
696 }
697 pub fn backpressure(mut self, b: Backpressure) -> Self {
699 self.backpressure = b;
700 self
701 }
702 pub fn source(mut self, id: impl Into<String>) -> Self {
704 self.source = Some(id.into());
705 self
706 }
707 pub fn spool(mut self, rel: impl Into<PathBuf>) -> Self {
709 self.spool = rel.into();
710 self
711 }
712 pub fn output(mut self, dir: impl Into<PathBuf>, opts: SinkOptions) -> Self {
714 self.output = Some((dir.into(), opts));
715 self
716 }
717 fn hook(mut self, f: impl FnOnce(Pipeline<Session>) -> Pipeline<Session> + Send + 'static) -> Self {
718 self.attach.push(Box::new(f));
719 self
720 }
721 pub fn on<F>(mut self, kind: EventKind, f: F) -> Self
723 where
724 F: FnMut(&Event) + Send + 'static,
725 {
726 self.frames |= kind == EventKind::FrameDecoded;
727 self.hook(move |p| p.on(kind, f))
728 }
729 pub fn on_event<F>(self, f: F) -> Self
731 where
732 F: FnMut(&Event) + Send + 'static,
733 {
734 self.hook(move |p| p.on_any(f))
735 }
736 pub fn on_frame<F>(mut self, f: F) -> Self
738 where
739 F: FnMut(&Arc<Frame>) + Send + 'static,
740 {
741 self.frames = true;
742 self.hook(move |p| p.on_frame(f))
743 }
744 pub fn on_zone_preview<F>(self, f: F) -> Self
746 where
747 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
748 {
749 self.hook(move |p| p.on_zone_preview(f))
750 }
751 pub fn on_zone_refined<F>(self, f: F) -> Self
753 where
754 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
755 {
756 self.hook(move |p| p.on_zone_refined(f))
757 }
758 pub fn on_message<F>(self, f: F) -> Self
760 where
761 F: FnMut(EventKind, &str) + Send + 'static,
762 {
763 self.hook(move |p| p.on_message(f))
764 }
765 pub fn event_policy(self, kind: EventKind, p: QueuePolicy) -> Self {
767 self.hook(move |pl| pl.policy(kind, p))
768 }
769 pub fn sync_hooks(self, on: bool) -> Self {
771 self.hook(move |pl| pl.sync(on))
772 }
773
774 pub fn build(self, host: &CompositionHost) -> Result<ReconNode, ComposeError> {
777 let NodeBuilder { id, plan, policy, backpressure, source, spool, output, attach, frames, deltas } = self;
778 let mut pr = Vec::new();
779 let cams = &plan.input.cameras;
780 if cams.is_empty() {
781 pr.push("cameras: empty".to_string());
782 }
783 let mut seen = std::collections::BTreeSet::new();
784 for c in cams {
785 if c.is_empty() || c.contains(['/', '\\']) {
786 pr.push(format!("cameras: invalid name {c:?}"));
787 } else if !seen.insert(c.as_str()) {
788 pr.push(format!("cameras: duplicate {c}"));
789 }
790 }
791 let z = plan.zones;
792 if z.span == 0 {
793 pr.push("zones.span: must be at least 1".into());
794 } else if z.overlap >= z.span {
795 pr.push(format!("zones.overlap: must be smaller than the span ({}), got {}", z.span, z.overlap));
796 }
797 if !plan.input.images.is_dir() {
798 pr.push(format!("images: folder not found: {}", plan.input.images.display()));
799 }
800 if spool.is_absolute() || spool.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
801 pr.push(format!("spool: must be a relative path inside the image folder, got {}", spool.display()));
802 }
803 if let ReconcilePolicy::ReplayWindow { positions: 0 } = policy {
804 pr.push("reconcile: replay window must be at least 1 position".into());
805 }
806 match backpressure {
807 Backpressure::Block { max_pending: 0 } | Backpressure::KeepLatest { max_pending: 0 } => {
808 pr.push("backpressure: max_pending must be at least 1".into())
809 }
810 _ => {}
811 }
812 let sift = make_sift_backend(&plan.features.backend).map_err(|e| pr.push(format!("features.backend: {e}"))).ok();
813 let matcher = make_match_backend(&plan.matching.backend).map_err(|e| pr.push(format!("matching.backend: {e}"))).ok();
814 if plan.dense.enabled {
815 if let Err(e) = parse_profile(&plan.dense.profile) {
816 pr.push(format!("dense.profile: {e}"));
817 }
818 }
819 let dense = if plan.dense.enabled { plan.dense.config().map_err(|e| pr.push(format!("dense: {e}"))).ok() } else { None };
820 if !pr.is_empty() {
821 return Err(ComposeError::Config(pr));
822 }
823 if host.node(&id).is_some() {
824 return Err(ComposeError::DuplicateNode(id));
825 }
826
827 let mut sc = SessionConfig::new(plan.input.images.clone());
828 sc.span = z.span;
829 sc.overlap = z.overlap;
830 sc.total_positions = None;
831 sc.incremental_triangulation = plan.sparse.incremental_triangulation;
832 sc.fixed_enu_origin = plan.align.fixed_origin;
833 sc.seed = plan.seed;
834 sc.extraction.sift.max_num_features = plan.features.max_num_features;
835 sc.extraction.reader.max_image_size = plan.features.max_image_size;
836 sc.sift = sift.expect("checked");
837 sc.matcher = Arc::from(matcher.expect("checked"));
838 sc.dense = dense;
839 sc.history = match policy {
840 ReconcilePolicy::ReplayWindow { positions } => positions + 1,
841 _ => 0,
842 };
843 sc.decode_frames = frames;
844 sc.stable_display = plan.sparse.stable_display;
845 sc.settle_tol = plan.sparse.settle_tol;
846 sc.settle_max_zone = plan.sparse.settle_max_zone;
847 if deltas.as_ref().is_some_and(|o| o.stride == 0) {
848 return Err(ComposeError::Config(vec!["display_deltas: stride must be at least 1".into()]));
849 }
850
851 let origin = Arc::new(Mutex::new(None));
852 let mut pl = Pipeline::new(Session::new(sc));
853 if let Some(opts) = deltas {
854 let mut feed = DisplayFeed::new(opts).with_origin(Arc::clone(&origin));
855 pl = pl.derive(move |e| feed.handle(e).into_iter().collect());
856 }
857 if let Some((dir, opts)) = &output {
858 pl = sinks::attach(pl, dir, opts).map_err(|e| ComposeError::Io(format!("{}: {e}", dir.display())))?;
859 }
860 for a in attach {
861 pl = a(pl);
862 }
863 let core = Core {
864 cameras: plan.input.cameras.clone(),
865 image_root: plan.input.images.clone(),
866 spool,
867 policy,
868 source,
869 accepted: BTreeMap::new(),
870 order: Vec::new(),
871 recent: VecDeque::new(),
872 waiting: BTreeMap::new(),
873 stats: ComposeStats::default(),
874 pipeline: Some(pl),
875 ended: None,
876 origin,
877 };
878 let node = ReconNode { id, backpressure, core: Arc::new(Mutex::new(core)) };
879 host.register(&node)?;
880 Ok(node)
881 }
882}
883
884#[derive(Clone)]
891pub struct ReconNode {
892 id: String,
893 backpressure: Backpressure,
894 core: Arc<Mutex<Core>>,
895}
896
897impl fmt::Debug for ReconNode {
898 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
899 f.debug_struct("ReconNode").field("id", &self.id).finish()
900 }
901}
902
903#[derive(Clone, Copy, Debug)]
904struct Accepted {
905 revision: u32,
906 position: usize,
907}
908
909struct Core {
910 cameras: Vec<String>,
911 image_root: PathBuf,
912 spool: PathBuf,
913 policy: ReconcilePolicy,
914 source: Option<String>,
915 accepted: BTreeMap<u64, Accepted>,
917 order: Vec<u64>,
919 recent: VecDeque<FrameSet>,
921 waiting: BTreeMap<u64, Vec<String>>,
923 stats: ComposeStats,
924 pipeline: Option<Pipeline<Session>>,
925 ended: Option<Closed>,
927 origin: Arc<Mutex<Option<[f64; 3]>>>,
929}
930
931impl ReconNode {
932 pub fn id(&self) -> &str {
934 &self.id
935 }
936
937 pub fn compose(&self, state: FrameState) -> Result<Outcome, ComposeError> {
939 lock(&self.core).compose(state)
940 }
941
942 pub fn compose_all(&self, states: impl IntoIterator<Item = FrameState>) -> Result<Vec<Outcome>, ComposeError> {
944 states.into_iter().map(|s| self.compose(s)).collect()
945 }
946
947 pub fn poll(&self) -> usize {
949 lock(&self.core).pipeline.as_mut().map_or(0, |p| p.poll())
950 }
951
952 pub fn subscribe(&self) -> Option<Receiver<Event>> {
954 lock(&self.core).pipeline.as_ref().map(|p| p.subscribe())
955 }
956
957 pub fn stats(&self) -> ComposeStats {
959 lock(&self.core).stats.clone()
960 }
961
962 pub fn positions(&self) -> usize {
964 lock(&self.core).order.len()
965 }
966
967 pub fn journal(&self) -> Vec<(u64, u32)> {
969 let c = lock(&self.core);
970 c.order.iter().map(|s| (*s, c.accepted[s].revision)).collect()
971 }
972
973 pub fn waiting(&self) -> Vec<(u64, Vec<String>)> {
975 lock(&self.core).waiting.iter().map(|(k, v)| (*k, v.clone())).collect()
976 }
977
978 pub fn with_session<T>(&self, f: impl FnOnce(&Session) -> T) -> Option<T> {
980 lock(&self.core).pipeline.as_ref().map(|p| f(p.reducer()))
981 }
982
983 pub fn close(&self) -> Result<Closed, ComposeError> {
986 self.close_inner()
987 }
988
989 fn close_inner(&self) -> Result<Closed, ComposeError> {
990 let mut c = lock(&self.core);
991 if let Some(done) = c.ended.take() {
992 return Ok(done);
993 }
994 c.finish().ok_or(ComposeError::Closed)
995 }
996
997 pub fn spawn(&self) -> ComposeHandle {
1000 let queue = Arc::new(Queue { st: Mutex::new(QueueState::default()), cv: Condvar::new() });
1001 let (q, node) = (Arc::clone(&queue), self.clone());
1002 let worker = std::thread::Builder::new()
1003 .name(format!("compose-{}", self.id))
1004 .spawn(move || {
1005 let mut outcomes = Vec::new();
1006 loop {
1007 let next = {
1008 let mut st = lock(&q.st);
1009 loop {
1010 if let Some(s) = st.items.pop_front() {
1011 q.cv.notify_all();
1012 break Some(s);
1013 }
1014 if st.closed {
1015 break None;
1016 }
1017 st = q.cv.wait(st).unwrap_or_else(|p| p.into_inner());
1018 }
1019 };
1020 let Some(state) = next else { break };
1021 let end = matches!(state, FrameState::End);
1022 let r = node.compose(state);
1023 lock(&q.st).processed += 1;
1024 let fatal = matches!(r, Err(ComposeError::Session(_)));
1025 outcomes.push(r);
1026 if end || fatal {
1027 break;
1028 }
1029 }
1030 let mut st = lock(&q.st);
1031 st.closed = true;
1032 st.items.clear();
1033 q.cv.notify_all();
1034 outcomes
1035 })
1036 .expect("compose worker thread");
1037 ComposeHandle { node: self.clone(), queue, policy: self.backpressure, worker: Some(worker) }
1038 }
1039}
1040
1041impl Core {
1042 fn pl(&mut self) -> &mut Pipeline<Session> {
1043 self.pipeline.as_mut().expect("open pipeline")
1044 }
1045
1046 fn warn(&mut self, message: String) {
1047 if let Some(p) = self.pipeline.as_mut() {
1048 p.reducer().emit_external(|meta| Event::Warning { meta, message });
1049 p.poll();
1050 }
1051 }
1052
1053 fn ignore(&mut self, key: &FrameKey, reason: IgnoreReason) -> Result<Outcome, ComposeError> {
1054 if self.policy == ReconcilePolicy::Strict && reason != IgnoreReason::Closed {
1055 return Err(ComposeError::Rejected(format!("{key}: {reason}")));
1056 }
1057 self.stats.ignored += 1;
1058 self.warn(format!("compose: {key} ignored: {reason}"));
1059 Ok(Outcome::Ignored(reason))
1060 }
1061
1062 fn compose(&mut self, state: FrameState) -> Result<Outcome, ComposeError> {
1063 self.stats.declared += 1;
1064 if self.pipeline.is_none() {
1065 return match state {
1066 FrameState::End => Ok(Outcome::Ended),
1067 _ => Ok(Outcome::Ignored(IgnoreReason::Closed)),
1068 };
1069 }
1070 match state {
1071 FrameState::End => {
1072 let done = self.finish().ok_or(ComposeError::Closed)?;
1073 self.ended = Some(done);
1074 Ok(Outcome::Ended)
1075 }
1076 FrameState::Pending { key, missing_cameras, .. } => {
1077 if let Some(r) = self.check_source(&key) {
1078 return r;
1079 }
1080 self.stats.waiting += 1;
1081 if !self.accepted.contains_key(&key.sequence) {
1082 self.waiting.insert(key.sequence, missing_cameras.clone());
1083 }
1084 Ok(Outcome::Waiting { missing: missing_cameras })
1085 }
1086 FrameState::Ready(set) => self.ready(set),
1087 }
1088 }
1089
1090 fn check_source(&mut self, key: &FrameKey) -> Option<Result<Outcome, ComposeError>> {
1091 match &self.source {
1092 None => {
1093 self.source = Some(key.source_id.clone());
1094 None
1095 }
1096 Some(s) if *s == key.source_id => None,
1097 Some(s) => {
1098 let expected = s.clone();
1099 Some(self.ignore(key, IgnoreReason::ForeignSource { expected }))
1100 }
1101 }
1102 }
1103
1104 fn ready(&mut self, set: LiveFrameSet) -> Result<Outcome, ComposeError> {
1105 let key = set.key.clone();
1106 let mut by_cam: HashMap<&str, &LiveImage> = HashMap::new();
1108 for im in &set.images {
1109 if !self.cameras.contains(&im.camera) {
1110 return Err(ComposeError::InvalidFrameSet(format!("{key}: unknown camera {}", im.camera)));
1111 }
1112 if by_cam.insert(im.camera.as_str(), im).is_some() {
1113 return Err(ComposeError::InvalidFrameSet(format!("{key}: camera {} given twice", im.camera)));
1114 }
1115 }
1116 if let Some(r) = self.check_source(&key) {
1117 return r;
1118 }
1119 let missing: Vec<String> = self.cameras.iter().filter(|c| !by_cam.contains_key(c.as_str())).cloned().collect();
1120 if !missing.is_empty() && !self.accepted.contains_key(&key.sequence) {
1121 self.stats.waiting += 1;
1122 self.waiting.insert(key.sequence, missing.clone());
1123 return Ok(Outcome::Waiting { missing });
1124 }
1125
1126 if let Some(acc) = self.accepted.get(&key.sequence).copied() {
1127 if key.revision == acc.revision {
1128 self.stats.duplicates += 1;
1129 return Ok(Outcome::Duplicate { position: acc.position });
1130 }
1131 if key.revision < acc.revision {
1132 return self.ignore(&key, IgnoreReason::StaleRevision { accepted: acc.revision });
1133 }
1134 if !missing.is_empty() {
1135 return Err(ComposeError::InvalidFrameSet(format!("{key}: correction misses cameras {}", missing.join(", "))));
1136 }
1137 return self.correct(set, acc);
1138 }
1139
1140 let newest = self.order.last().copied();
1141 match newest {
1142 Some(n) if key.sequence < n => self.late(set),
1143 _ => {
1144 let skipped = newest.map_or(0, |n| key.sequence - n - 1);
1145 if skipped > 0 && self.policy == ReconcilePolicy::Strict {
1146 return Err(ComposeError::Rejected(format!("{key}: {skipped} sequence(s) missing before it")));
1147 }
1148 let fs = self.resolve(&set, None)?;
1149 self.waiting.remove(&key.sequence);
1150 let position = self.order.len();
1151 self.push(fs)?;
1152 self.order.push(key.sequence);
1153 self.accepted.insert(key.sequence, Accepted { revision: key.revision, position });
1154 self.stats.appended += 1;
1155 if skipped > 0 {
1156 self.stats.skipped_sequences += skipped;
1157 self.warn(format!("compose: {key} appended at position {position} after {skipped} missing sequence(s)"));
1158 }
1159 Ok(Outcome::Appended { position, skipped })
1160 }
1161 }
1162 }
1163
1164 fn late(&mut self, set: LiveFrameSet) -> Result<Outcome, ComposeError> {
1166 let key = set.key.clone();
1167 let ReconcilePolicy::ReplayWindow { positions: w } = self.policy else {
1168 return self.ignore(&key, IgnoreReason::Late);
1169 };
1170 let p = self.order.partition_point(|&s| s < key.sequence);
1171 if !self.in_window(p, w) {
1172 return self.ignore(&key, IgnoreReason::OutsideReplayWindow { position: p });
1173 }
1174 let fs = self.resolve(&set, None)?;
1175 let tail_sets: Vec<FrameSet> = self.recent.iter().skip(self.recent.len() - (self.order.len() - p)).cloned().collect();
1176 let mut tail = vec![fs];
1177 tail.extend(tail_sets);
1178 let mut seqs = vec![key.sequence];
1179 seqs.extend(self.order[p..].iter().copied());
1180 self.waiting.remove(&key.sequence);
1181 let n = self.replay(p, tail, &seqs, (key.sequence, key.revision))?;
1182 self.warn(format!("compose: late {key} inserted at position {p}, replayed {n} position(s)"));
1183 Ok(Outcome::Replayed { from: p, positions: n })
1184 }
1185
1186 fn correct(&mut self, set: LiveFrameSet, acc: Accepted) -> Result<Outcome, ComposeError> {
1188 let key = set.key.clone();
1189 let p = acc.position;
1190 match self.policy {
1191 ReconcilePolicy::AppendOnly | ReconcilePolicy::Strict => self.ignore(&key, IgnoreReason::CorrectionNotApplied),
1192 ReconcilePolicy::ReplayWindow { positions: w } => {
1193 if !self.in_window(p, w) {
1194 return self.ignore(&key, IgnoreReason::OutsideReplayWindow { position: p });
1195 }
1196 let fs = self.resolve(&set, None)?;
1197 let start = self.recent.len() - (self.order.len() - p);
1198 let mut tail: Vec<FrameSet> = self.recent.iter().skip(start).cloned().collect();
1199 tail[0] = fs;
1200 let seqs: Vec<u64> = self.order[p..].to_vec();
1201 let n = self.replay(p, tail, &seqs, (key.sequence, key.revision))?;
1202 self.warn(format!("compose: correction {key} applied from position {p}, replayed {n} position(s)"));
1203 Ok(Outcome::Replayed { from: p, positions: n })
1204 }
1205 ReconcilePolicy::InvalidateAffectedZones => {
1206 let names: Vec<String> = self.pl().reducer().frames()[p].iter().map(|f| f.name.clone()).collect();
1208 self.resolve(&set, Some(&names))?;
1209 let zones: Vec<usize> =
1210 self.pl().reducer().arrived_zones().into_iter().filter(|z| z.lo <= p && p < z.hi).map(|z| z.zone).collect();
1211 for &z in &zones {
1212 self.pl().command(Command::InvalidateZone(z));
1213 }
1214 self.fail_check()?;
1215 self.accepted.insert(key.sequence, Accepted { revision: key.revision, position: p });
1216 self.stats.invalidations += 1;
1217 Ok(Outcome::Invalidated { position: p, zones })
1218 }
1219 }
1220 }
1221
1222 fn in_window(&self, p: usize, w: usize) -> bool {
1224 let npos = self.order.len();
1225 npos - p <= w && npos - p <= self.recent.len()
1226 }
1227
1228 fn replay(&mut self, p: usize, tail: Vec<FrameSet>, seqs: &[u64], changed: (u64, u32)) -> Result<usize, ComposeError> {
1231 let npos = self.order.len();
1232 self.pl().command(Command::ResetFrom(p));
1233 if self.pl().reducer().positions() != p {
1234 return Err(ComposeError::Session(format!("rewind to position {p} was refused by the session")));
1235 }
1236 let revs: HashMap<u64, u32> = self.order[p..].iter().map(|s| (*s, self.accepted[s].revision)).collect();
1237 for s in &self.order[p..] {
1238 self.accepted.remove(s);
1239 }
1240 self.order.truncate(p);
1241 let keep = self.recent.len() - (npos - p);
1242 self.recent.truncate(keep);
1243 let n = tail.len();
1244 for (fs, &seq) in tail.into_iter().zip(seqs) {
1245 let revision = if seq == changed.0 { changed.1 } else { revs[&seq] };
1246 let position = self.order.len();
1247 self.push(fs)?;
1248 self.order.push(seq);
1249 self.accepted.insert(seq, Accepted { revision, position });
1250 }
1251 self.stats.replays += 1;
1252 self.stats.replayed_positions += n as u64;
1253 Ok(n)
1254 }
1255
1256 fn push(&mut self, fs: FrameSet) -> Result<(), ComposeError> {
1258 let cap = match self.policy {
1259 ReconcilePolicy::ReplayWindow { positions } => positions,
1260 _ => 0,
1261 };
1262 if cap > 0 {
1263 self.recent.push_back(fs.clone());
1264 while self.recent.len() > cap {
1265 self.recent.pop_front();
1266 }
1267 }
1268 if let Some(g) = fs.gps.first() {
1269 lock(&self.origin).get_or_insert([g.lat, g.lon, g.alt]);
1270 }
1271 self.pl().push(Input::Frames(fs));
1272 self.fail_check()
1273 }
1274
1275 fn fail_check(&mut self) -> Result<(), ComposeError> {
1276 match self.pl().reducer().failed() {
1277 Some(e) => Err(ComposeError::Session(e.to_string())),
1278 None => Ok(()),
1279 }
1280 }
1281
1282 fn resolve(&self, set: &LiveFrameSet, names: Option<&[String]>) -> Result<FrameSet, ComposeError> {
1285 let key = &set.key;
1286 let mut fs = FrameSet::default();
1287 for (i, cam) in self.cameras.iter().enumerate() {
1288 let im = set.images.iter().find(|x| &x.camera == cam).expect("complete set");
1289 let forced = names.map(|n| n[i].clone());
1290 let name = match &im.payload {
1291 FramePayload::File { relative_name } => {
1292 let src = self.image_root.join(relative_name);
1293 if !src.is_file() {
1294 return Err(ComposeError::InvalidFrameSet(format!("{key}: file not found: {}", src.display())));
1295 }
1296 match forced {
1297 Some(n) if n != *relative_name => {
1298 let bytes = std::fs::read(&src).map_err(|e| ComposeError::Io(format!("{}: {e}", src.display())))?;
1299 self.store_encoded(&n, &bytes, ext_of(relative_name))?;
1300 n
1301 }
1302 _ => relative_name.clone(),
1303 }
1304 }
1305 FramePayload::Rgb8 { width, height, pixels } => {
1306 if pixels.len() != *width as usize * *height as usize * 3 || *width == 0 || *height == 0 {
1307 return Err(ComposeError::InvalidFrameSet(format!(
1308 "{key}: camera {cam}: {} bytes for {width}x{height} RGB8",
1309 pixels.len()
1310 )));
1311 }
1312 let n = forced.unwrap_or_else(|| self.spool_name(key, cam, "png"));
1313 let img = image::RgbImage::from_raw(*width, *height, pixels.to_vec()).expect("size checked");
1314 self.store_image(&n, &image::DynamicImage::ImageRgb8(img))?;
1315 n
1316 }
1317 FramePayload::Encoded { bytes, format } => {
1318 let n = forced.unwrap_or_else(|| self.spool_name(key, cam, format.extension()));
1319 self.store_encoded(&n, bytes, Some(format.extension()))?;
1320 n
1321 }
1322 };
1323 fs.images.push(crate::session::FrameImage { camera: cam.clone(), name });
1324 }
1325 for g in &set.gps {
1326 let mut g = g.clone();
1327 if let Some(i) = self.cameras.iter().position(|c| *c == g.name) {
1328 g.name = fs.images[i].name.clone();
1329 }
1330 fs.gps.push(g);
1331 }
1332 Ok(fs)
1333 }
1334
1335 fn spool_name(&self, key: &FrameKey, cam: &str, ext: &str) -> String {
1336 let src: String = key.source_id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
1337 let p = self.spool.join(cam).join(format!("{src}_{:010}.{ext}", key.sequence));
1338 p.to_string_lossy().replace('\\', "/")
1339 }
1340
1341 fn store_encoded(&self, name: &str, bytes: &[u8], ext: Option<&str>) -> Result<(), ComposeError> {
1343 if ext.is_some() && ext == ext_of(name) {
1344 return self.atomic_write(name, |path| std::fs::write(path, bytes).map_err(|e| e.to_string()));
1345 }
1346 let img = image::load_from_memory(bytes).map_err(|e| ComposeError::InvalidFrameSet(format!("{name}: {e}")))?;
1347 self.store_image(name, &img)
1348 }
1349
1350 fn store_image(&self, name: &str, img: &image::DynamicImage) -> Result<(), ComposeError> {
1351 let fmt = image::ImageFormat::from_path(name).map_err(|e| ComposeError::Io(format!("{name}: {e}")))?;
1352 self.atomic_write(name, |path| img.save_with_format(path, fmt).map_err(|e| e.to_string()))
1353 }
1354
1355 fn atomic_write(&self, name: &str, write: impl FnOnce(&Path) -> Result<(), String>) -> Result<(), ComposeError> {
1357 let dst = self.image_root.join(name);
1358 let dir = dst.parent().unwrap_or(&self.image_root);
1359 std::fs::create_dir_all(dir).map_err(|e| ComposeError::Io(format!("{}: {e}", dir.display())))?;
1360 let file = dst.file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
1361 let ext = ext_of(name).unwrap_or("tmp");
1362 let tmp = dir.join(format!(".{file}.partial.{ext}"));
1363 write(&tmp).map_err(|e| ComposeError::Io(format!("{}: {e}", tmp.display())))?;
1364 std::fs::rename(&tmp, &dst).map_err(|e| ComposeError::Io(format!("{}: {e}", dst.display())))
1365 }
1366
1367 fn finish(&mut self) -> Option<Closed> {
1368 let pl = self.pipeline.take()?;
1369 let (session, summary) = pl.finish();
1370 Some(Closed { session, summary, stats: self.stats.clone() })
1371 }
1372}
1373
1374fn ext_of(name: &str) -> Option<&str> {
1375 Path::new(name).extension().and_then(|e| e.to_str())
1376}
1377
1378fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
1379 m.lock().unwrap_or_else(|p| p.into_inner())
1380}
1381
1382#[derive(Default)]
1387struct QueueState {
1388 items: VecDeque<FrameState>,
1389 closed: bool,
1390 submitted: u64,
1391 dropped: u64,
1392 processed: u64,
1393 max_depth: usize,
1394}
1395
1396struct Queue {
1397 st: Mutex<QueueState>,
1398 cv: Condvar,
1399}
1400
1401#[derive(Debug)]
1403pub enum SendError {
1404 Full(FrameState),
1406 Closed(FrameState),
1408}
1409
1410impl fmt::Display for SendError {
1411 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1412 match self {
1413 SendError::Full(_) => write!(f, "compose queue full"),
1414 SendError::Closed(_) => write!(f, "compose worker stopped"),
1415 }
1416 }
1417}
1418
1419impl std::error::Error for SendError {}
1420
1421#[derive(Clone, Debug, Default, PartialEq, Eq)]
1423pub struct QueueStats {
1424 pub submitted: u64,
1426 pub dropped: u64,
1428 pub processed: u64,
1430 pub depth: usize,
1432 pub max_depth: usize,
1434}
1435
1436pub struct ComposeHandle {
1438 node: ReconNode,
1439 queue: Arc<Queue>,
1440 policy: Backpressure,
1441 worker: Option<JoinHandle<Vec<Result<Outcome, ComposeError>>>>,
1442}
1443
1444impl fmt::Debug for ComposeHandle {
1445 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1446 f.debug_struct("ComposeHandle").field("node", &self.node.id).field("policy", &self.policy).field("stats", &self.stats()).finish()
1447 }
1448}
1449
1450impl ComposeHandle {
1451 pub fn node(&self) -> &ReconNode {
1453 &self.node
1454 }
1455
1456 pub fn send(&self, state: FrameState) -> Result<(), SendError> {
1458 self.enqueue(state, true)
1459 }
1460
1461 pub fn try_send(&self, state: FrameState) -> Result<(), SendError> {
1463 self.enqueue(state, false)
1464 }
1465
1466 pub fn send_all(&self, states: impl IntoIterator<Item = FrameState>) -> Result<(), SendError> {
1468 states.into_iter().try_for_each(|s| self.send(s))
1469 }
1470
1471 fn enqueue(&self, state: FrameState, wait: bool) -> Result<(), SendError> {
1472 let q = &self.queue;
1473 let mut st = lock(&q.st);
1474 if st.closed {
1475 return Err(SendError::Closed(state));
1476 }
1477 let end = matches!(state, FrameState::End);
1479 match self.policy {
1480 Backpressure::Unbounded => {}
1481 Backpressure::Block { max_pending } => {
1482 while !end && st.items.len() >= max_pending && !st.closed {
1483 if !wait {
1484 return Err(SendError::Full(state));
1485 }
1486 st = q.cv.wait(st).unwrap_or_else(|p| p.into_inner());
1487 }
1488 if st.closed {
1489 return Err(SendError::Closed(state));
1490 }
1491 }
1492 Backpressure::KeepLatest { max_pending } => {
1493 while !end && st.items.len() >= max_pending {
1494 match st.items.iter().position(|s| matches!(s, FrameState::Ready(_))) {
1495 Some(i) => {
1496 st.items.remove(i);
1497 st.dropped += 1;
1498 }
1499 None => {
1500 st.items.pop_front();
1501 }
1502 }
1503 }
1504 }
1505 }
1506 st.items.push_back(state);
1507 st.submitted += 1;
1508 st.max_depth = st.max_depth.max(st.items.len());
1509 q.cv.notify_all();
1510 Ok(())
1511 }
1512
1513 pub fn stats(&self) -> QueueStats {
1515 let st = lock(&self.queue.st);
1516 QueueStats { submitted: st.submitted, dropped: st.dropped, processed: st.processed, depth: st.items.len(), max_depth: st.max_depth }
1517 }
1518
1519 pub fn close(mut self) -> (Vec<Result<Outcome, ComposeError>>, Result<Closed, ComposeError>) {
1522 let _ = self.enqueue(FrameState::End, false);
1523 let outcomes = self.worker.take().map(|w| w.join().unwrap_or_default()).unwrap_or_default();
1524 (outcomes, self.node.close_inner())
1525 }
1526}
1527
1528impl Drop for ComposeHandle {
1529 fn drop(&mut self) {
1530 if let Some(w) = self.worker.take() {
1531 {
1532 let mut st = lock(&self.queue.st);
1533 st.closed = true;
1534 self.queue.cv.notify_all();
1535 }
1536 let _ = w.join();
1537 }
1538 }
1539}
1540
1541#[cfg(test)]
1542mod tests {
1543 use super::*;
1544
1545 #[test]
1546 fn spool_names_are_sanitized_and_stable() {
1547 let c = Core {
1548 cameras: vec!["camF".into()],
1549 image_root: PathBuf::from("/tmp/x"),
1550 spool: PathBuf::from(".live"),
1551 policy: ReconcilePolicy::AppendOnly,
1552 source: None,
1553 accepted: BTreeMap::new(),
1554 order: Vec::new(),
1555 recent: VecDeque::new(),
1556 waiting: BTreeMap::new(),
1557 stats: ComposeStats::default(),
1558 pipeline: None,
1559 ended: None,
1560 origin: Arc::new(Mutex::new(None)),
1561 };
1562 let k = FrameKey::new("rtsp://drone 1", 42, 3);
1563 assert_eq!(c.spool_name(&k, "camF", "png"), ".live/camF/rtsp___drone_1_0000000042.png");
1564 }
1565
1566 #[test]
1567 fn key_display() {
1568 assert_eq!(FrameKey::new("d", 7, 1).to_string(), "d#7r1");
1569 }
1570}