1use crate::ahead::Lookahead;
92use crate::blocks::LiveStream;
93use crate::declare::{AlignFrame, Dense, Features, Plan};
94use crate::densewrap::{make_ba_backend, make_match_backend, make_sift_backend, make_sparse_backend, parse_profile};
95use crate::events::{Command, Event, EventKind, Frame, ZoneRange};
96use crate::intrinsics::{IntrinsicsPrior, DEFAULT_DIST_SIGMA, DEFAULT_FOCAL_SIGMA};
97use crate::live::DisplayFeed;
98use crate::pipeline::{Pipeline, QueuePolicy, Summary};
99use crate::post::LiveOptions;
100use crate::session::{FrameSet, Input, Session, SessionConfig};
101use crate::sinks::{self, DefaultSinks, SinkOptions, SinkSet};
102use cumulus3d_core::io::ply::PointCloud;
103use cumulus3d_core::io::GpsRecord;
104use std::collections::{BTreeMap, HashMap, VecDeque};
105use std::fmt;
106use std::path::{Path, PathBuf};
107use std::sync::mpsc::Receiver;
108use std::sync::{Arc, Condvar, Mutex, MutexGuard};
109use std::thread::JoinHandle;
110use std::time::SystemTime;
111
112#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
118pub struct FrameKey {
119 pub source_id: String,
121 pub sequence: u64,
123 pub revision: u32,
125}
126
127impl FrameKey {
128 pub fn new(source_id: impl Into<String>, sequence: u64, revision: u32) -> Self {
130 Self { source_id: source_id.into(), sequence, revision }
131 }
132}
133
134impl fmt::Display for FrameKey {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 write!(f, "{}#{}r{}", self.source_id, self.sequence, self.revision)
137 }
138}
139
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142pub enum EncodedFormat {
143 Jpeg,
145 Png,
147}
148
149impl EncodedFormat {
150 fn extension(self) -> &'static str {
151 match self {
152 EncodedFormat::Jpeg => "jpg",
153 EncodedFormat::Png => "png",
154 }
155 }
156}
157
158#[derive(Clone)]
160pub enum FramePayload {
161 File {
163 relative_name: String,
165 },
166 Rgb8 {
168 width: u32,
170 height: u32,
172 pixels: Arc<[u8]>,
174 },
175 Encoded {
177 bytes: Arc<[u8]>,
179 format: EncodedFormat,
181 },
182}
183
184impl fmt::Debug for FramePayload {
185 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186 match self {
187 FramePayload::File { relative_name } => f.debug_struct("File").field("relative_name", relative_name).finish(),
188 FramePayload::Rgb8 { width, height, .. } => f.debug_struct("Rgb8").field("width", width).field("height", height).finish(),
189 FramePayload::Encoded { bytes, format } => {
190 f.debug_struct("Encoded").field("bytes", &bytes.len()).field("format", format).finish()
191 }
192 }
193 }
194}
195
196#[derive(Clone, Debug)]
198pub struct LiveImage {
199 pub camera: String,
201 pub payload: FramePayload,
203}
204
205#[derive(Clone, Debug)]
207pub struct LiveFrameSet {
208 pub key: FrameKey,
210 pub timestamp: SystemTime,
212 pub images: Vec<LiveImage>,
214 pub gps: Vec<GpsRecord>,
217}
218
219impl LiveFrameSet {
220 pub fn new(key: FrameKey) -> Self {
222 Self { key, timestamp: SystemTime::now(), images: Vec::new(), gps: Vec::new() }
223 }
224 pub fn image(mut self, camera: impl Into<String>, payload: FramePayload) -> Self {
226 self.images.push(LiveImage { camera: camera.into(), payload });
227 self
228 }
229 pub fn file(self, camera: impl Into<String>, relative_name: impl Into<String>) -> Self {
231 self.image(camera, FramePayload::File { relative_name: relative_name.into() })
232 }
233 pub fn rgb8(self, camera: impl Into<String>, width: u32, height: u32, pixels: impl Into<Arc<[u8]>>) -> Self {
235 self.image(camera, FramePayload::Rgb8 { width, height, pixels: pixels.into() })
236 }
237 pub fn encoded(self, camera: impl Into<String>, bytes: impl Into<Arc<[u8]>>, format: EncodedFormat) -> Self {
239 self.image(camera, FramePayload::Encoded { bytes: bytes.into(), format })
240 }
241 pub fn gps_fix(mut self, camera: impl Into<String>, lat: f64, lon: f64, alt: f64) -> Self {
243 self.gps.push(GpsRecord { name: camera.into(), lat, lon, alt });
244 self
245 }
246 pub fn at(mut self, t: SystemTime) -> Self {
248 self.timestamp = t;
249 self
250 }
251}
252
253#[derive(Clone, Debug)]
255pub enum FrameState {
256 Ready(LiveFrameSet),
258 Pending {
260 key: FrameKey,
262 timestamp: SystemTime,
264 missing_cameras: Vec<String>,
266 },
267 End,
269}
270
271impl FrameState {
272 pub fn ready(set: LiveFrameSet) -> Self {
274 FrameState::Ready(set)
275 }
276 pub fn pending<S: Into<String>>(key: FrameKey, missing_cameras: impl IntoIterator<Item = S>) -> Self {
278 FrameState::Pending { key, timestamp: SystemTime::now(), missing_cameras: missing_cameras.into_iter().map(Into::into).collect() }
279 }
280}
281
282#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
288pub enum ReconcilePolicy {
289 #[default]
291 AppendOnly,
292 ReplayWindow {
296 positions: usize,
298 },
299 InvalidateAffectedZones,
304 Strict,
306}
307
308const BATCH_MAX: usize = 32;
310
311#[derive(Clone, Copy, Debug, PartialEq, Eq)]
313pub enum Backpressure {
314 Unbounded,
316 Block {
318 max_pending: usize,
320 },
321 KeepLatest {
324 max_pending: usize,
326 },
327}
328
329impl Default for Backpressure {
330 fn default() -> Self {
331 Backpressure::Block { max_pending: 8 }
332 }
333}
334
335#[derive(Clone, Debug, PartialEq, Eq)]
337pub enum IgnoreReason {
338 StaleRevision {
340 accepted: u32,
342 },
343 CorrectionNotApplied,
345 Late,
347 OutsideReplayWindow {
349 position: usize,
351 },
352 ForeignSource {
354 expected: String,
356 },
357 Closed,
359}
360
361impl fmt::Display for IgnoreReason {
362 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
363 match self {
364 IgnoreReason::StaleRevision { accepted } => write!(f, "stale revision (accepted revision {accepted})"),
365 IgnoreReason::CorrectionNotApplied => write!(f, "correction not applied by this policy"),
366 IgnoreReason::Late => write!(f, "late arrival not inserted by this policy"),
367 IgnoreReason::OutsideReplayWindow { position } => write!(f, "outside replay window (position {position})"),
368 IgnoreReason::ForeignSource { expected } => write!(f, "foreign source (node source is {expected})"),
369 IgnoreReason::Closed => write!(f, "node closed"),
370 }
371 }
372}
373
374#[derive(Clone, Debug, PartialEq, Eq)]
376pub enum Outcome {
377 Appended {
379 position: usize,
381 skipped: u64,
383 },
384 Duplicate {
386 position: usize,
388 },
389 Waiting {
391 missing: Vec<String>,
393 },
394 Replayed {
396 from: usize,
398 positions: usize,
400 },
401 Invalidated {
403 position: usize,
405 zones: Vec<usize>,
407 },
408 Ignored(IgnoreReason),
410 Ended,
412}
413
414#[derive(Clone, Debug, PartialEq, Eq)]
416pub enum ComposeError {
417 Config(Vec<String>),
419 InvalidFrameSet(String),
421 Rejected(String),
423 Io(String),
425 Session(String),
427 UnknownNode(String),
429 DuplicateNode(String),
431 Closed,
433}
434
435impl fmt::Display for ComposeError {
436 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
437 match self {
438 ComposeError::Config(v) => write!(f, "invalid node configuration: {}", v.join("; ")),
439 ComposeError::InvalidFrameSet(m) => write!(f, "invalid frame set: {m}"),
440 ComposeError::Rejected(m) => write!(f, "rejected: {m}"),
441 ComposeError::Io(m) => write!(f, "i/o: {m}"),
442 ComposeError::Session(m) => write!(f, "session failed: {m}"),
443 ComposeError::UnknownNode(id) => write!(f, "no node {id:?} on this host"),
444 ComposeError::DuplicateNode(id) => write!(f, "node {id:?} already exists on this host"),
445 ComposeError::Closed => write!(f, "node already closed"),
446 }
447 }
448}
449
450impl std::error::Error for ComposeError {}
451
452#[derive(Clone, Debug, Default, PartialEq, Eq)]
454pub struct ComposeStats {
455 pub declared: u64,
457 pub appended: u64,
459 pub duplicates: u64,
461 pub waiting: u64,
463 pub skipped_sequences: u64,
465 pub replays: u64,
467 pub replayed_positions: u64,
469 pub invalidations: u64,
471 pub ignored: u64,
473}
474
475pub struct Closed {
477 pub session: Session,
479 pub summary: Summary,
481 pub stats: ComposeStats,
483}
484
485impl fmt::Debug for Closed {
486 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
487 f.debug_struct("Closed")
488 .field("positions", &self.session.positions())
489 .field("summary", &self.summary)
490 .field("stats", &self.stats)
491 .finish()
492 }
493}
494
495#[derive(Clone, Default)]
501pub struct CompositionHost {
502 nodes: Arc<Mutex<BTreeMap<String, ReconNode>>>,
503}
504
505impl fmt::Debug for CompositionHost {
506 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
507 f.debug_struct("CompositionHost").field("nodes", &self.ids()).finish()
508 }
509}
510
511impl CompositionHost {
512 pub fn new() -> Self {
514 Self::default()
515 }
516 pub fn node(&self, id: &str) -> Option<ReconNode> {
518 lock(&self.nodes).get(id).cloned()
519 }
520 pub fn ids(&self) -> Vec<String> {
522 lock(&self.nodes).keys().cloned().collect()
523 }
524 pub fn compose(&self, id: &str, state: FrameState) -> Result<Outcome, ComposeError> {
526 self.node(id).ok_or_else(|| ComposeError::UnknownNode(id.to_string()))?.compose(state)
527 }
528 pub fn close(&self, id: &str) -> Result<Closed, ComposeError> {
530 let node = lock(&self.nodes).remove(id).ok_or_else(|| ComposeError::UnknownNode(id.to_string()))?;
531 node.close_inner()
532 }
533 pub fn close_all(&self) -> Vec<(String, Result<Closed, ComposeError>)> {
535 let nodes = std::mem::take(&mut *lock(&self.nodes));
536 nodes.into_iter().map(|(id, n)| (id, n.close_inner())).collect()
537 }
538 fn register(&self, node: &ReconNode) -> Result<(), ComposeError> {
539 let mut m = lock(&self.nodes);
540 if m.contains_key(&node.id) {
541 return Err(ComposeError::DuplicateNode(node.id.clone()));
542 }
543 m.insert(node.id.clone(), node.clone());
544 Ok(())
545 }
546}
547
548pub fn reconstruct(host: &CompositionHost, id: &str, state: FrameState) -> Result<Outcome, ComposeError> {
551 host.compose(id, state)
552}
553
554type Attach = Box<dyn FnOnce(Pipeline<Session>) -> Pipeline<Session> + Send>;
559
560pub struct NodeBuilder {
565 id: String,
566 plan: Plan,
567 policy: ReconcilePolicy,
568 backpressure: Backpressure,
569 source: Option<String>,
570 spool: PathBuf,
571 output: Option<(PathBuf, SinkOptions)>,
572 attach: Vec<Attach>,
573 frames: bool,
574 deltas: Option<LiveOptions>,
575 stream_origin: Option<[f64; 3]>,
577 stream_only: bool,
579 prior: Option<IntrinsicsPrior>,
581 prior_error: Option<String>,
583}
584
585impl fmt::Debug for NodeBuilder {
586 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
587 f.debug_struct("NodeBuilder").field("id", &self.id).field("policy", &self.policy).field("hooks", &self.attach.len()).finish()
588 }
589}
590
591impl ReconNode {
592 pub fn declare() -> NodeBuilder {
594 let mut plan = Plan::default();
595 plan.dense.enabled = false;
596 plan.gps = None;
597 plan.live = crate::declare::Live::live_node();
598 NodeBuilder {
599 id: "default".to_string(),
600 plan,
601 policy: ReconcilePolicy::default(),
602 backpressure: Backpressure::default(),
603 source: None,
604 spool: PathBuf::from(".live"),
605 output: None,
606 attach: Vec::new(),
607 frames: false,
608 deltas: None,
609 stream_origin: None,
610 stream_only: false,
611 prior: None,
612 prior_error: None,
613 }
614 }
615}
616
617impl NodeBuilder {
618 pub fn id(mut self, id: impl Into<String>) -> Self {
620 self.id = id.into();
621 self
622 }
623 pub fn plan(mut self, plan: Plan) -> Self {
626 self.plan = plan;
627 self
628 }
629 pub fn images(mut self, dir: impl Into<PathBuf>) -> Self {
631 self.plan.input.images = dir.into();
632 self
633 }
634 pub fn cameras<S: Into<String>>(mut self, cams: impl IntoIterator<Item = S>) -> Self {
636 self.plan.input.cameras = cams.into_iter().map(Into::into).collect();
637 self
638 }
639 pub fn zones(mut self, span: usize, overlap: usize) -> Self {
641 self.plan.zones.span = span;
642 self.plan.zones.overlap = overlap;
643 self
644 }
645 pub fn align(mut self, frame: AlignFrame) -> Self {
647 self.plan.align.frame = frame;
648 self
649 }
650 pub fn fixed_enu_origin(mut self, on: bool) -> Self {
652 self.plan.align.fixed_origin = on;
653 self
654 }
655 pub fn features(mut self, f: Features) -> Self {
657 self.plan.features = f;
658 self
659 }
660 pub fn match_backend(mut self, name: &str) -> Self {
662 self.plan.matching.backend = name.to_string();
663 self
664 }
665 pub fn gpu(mut self) -> Self {
667 self.plan.features.backend = "cuda".into();
668 self.plan.matching.backend = "cuda".into();
669 self.plan.sparse.backend = "cuda".into();
670 self.plan.dense.backend = "cuda".into();
671 self.plan.sparse.ba_backend = "cuda".into();
672 self.plan.dense.undistort_backend = "cuda".into();
673 self
674 }
675 pub fn stable_display(mut self, on: bool) -> Self {
680 self.plan.sparse.stable_display = on;
681 self
682 }
683 pub fn display_deltas(mut self, opts: LiveOptions) -> Self {
690 self.deltas = Some(opts);
691 self
692 }
693 pub fn incremental_triangulation(mut self, on: bool) -> Self {
695 self.plan.sparse.incremental_triangulation = on;
696 self
697 }
698 pub fn lookahead(mut self, positions: usize) -> Self {
703 self.plan.live.lookahead = positions;
704 self
705 }
706 pub fn batch(mut self, threshold: usize) -> Self {
713 self.plan.live.batch = threshold;
714 self
715 }
716 pub fn preview(mut self, on: bool) -> Self {
719 self.plan.live.preview = on;
720 self
721 }
722 pub fn settle(mut self, tol: f64, max_zone: usize) -> Self {
725 self.plan.sparse.settle_tol = tol;
726 self.plan.sparse.settle_max_zone = max_zone;
727 self
728 }
729 pub fn live_stride(mut self, stride: usize) -> Self {
731 self.plan.sinks.live_stride = stride;
732 self
733 }
734 pub fn stream_origin(mut self, lat: f64, lon: f64, alt: f64) -> Self {
737 self.stream_origin = Some([lat, lon, alt]);
738 self
739 }
740 pub fn stream_only(mut self, on: bool) -> Self {
743 self.stream_only = on;
744 self
745 }
746 pub fn dense(mut self, d: Dense) -> Self {
748 self.plan.dense = Dense { enabled: true, ..d };
749 self
750 }
751 pub fn no_dense(mut self) -> Self {
753 self.plan.dense.enabled = false;
754 self
755 }
756 pub fn seed(mut self, seed: u64) -> Self {
758 self.plan.seed = Some(seed);
759 self
760 }
761 pub fn reconcile(mut self, p: ReconcilePolicy) -> Self {
763 self.policy = p;
764 self
765 }
766 pub fn backpressure(mut self, b: Backpressure) -> Self {
768 self.backpressure = b;
769 self
770 }
771 pub fn source(mut self, id: impl Into<String>) -> Self {
773 self.source = Some(id.into());
774 self
775 }
776 pub fn spool(mut self, rel: impl Into<PathBuf>) -> Self {
778 self.spool = rel.into();
779 self
780 }
781 pub fn output(mut self, dir: impl Into<PathBuf>, opts: SinkOptions) -> Self {
784 self.output = Some((dir.into(), opts));
785 self
786 }
787 fn hook(mut self, f: impl FnOnce(Pipeline<Session>) -> Pipeline<Session> + Send + 'static) -> Self {
788 self.attach.push(Box::new(f));
789 self
790 }
791 pub fn on<F>(mut self, kind: EventKind, f: F) -> Self
793 where
794 F: FnMut(&Event) + Send + 'static,
795 {
796 self.frames |= kind == EventKind::FrameDecoded;
797 self.hook(move |p| p.on(kind, f))
798 }
799 pub fn on_event<F>(self, f: F) -> Self
801 where
802 F: FnMut(&Event) + Send + 'static,
803 {
804 self.hook(move |p| p.on_any(f))
805 }
806 pub fn on_frame<F>(mut self, f: F) -> Self
808 where
809 F: FnMut(&Arc<Frame>) + Send + 'static,
810 {
811 self.frames = true;
812 self.hook(move |p| p.on_frame(f))
813 }
814 pub fn on_zone_preview<F>(self, f: F) -> Self
816 where
817 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
818 {
819 self.hook(move |p| p.on_zone_preview(f))
820 }
821 pub fn on_zone_refined<F>(self, f: F) -> Self
823 where
824 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
825 {
826 self.hook(move |p| p.on_zone_refined(f))
827 }
828 pub fn on_message<F>(self, f: F) -> Self
830 where
831 F: FnMut(EventKind, &str) + Send + 'static,
832 {
833 self.hook(move |p| p.on_message(f))
834 }
835 pub fn event_policy(self, kind: EventKind, p: QueuePolicy) -> Self {
837 self.hook(move |pl| pl.policy(kind, p))
838 }
839 pub fn sync_hooks(self, on: bool) -> Self {
841 self.hook(move |pl| pl.sync(on))
842 }
843
844 pub fn intrinsics_prior(mut self, prior: IntrinsicsPrior) -> Self {
857 self.prior = Some(prior);
858 self.prior_error = None;
859 self
860 }
861
862 pub fn intrinsics_prior_json(mut self, path: impl AsRef<Path>) -> Self {
866 match IntrinsicsPrior::load(path, DEFAULT_FOCAL_SIGMA, DEFAULT_DIST_SIGMA) {
867 Ok(p) => {
868 self.prior = Some(p);
869 self.prior_error = None;
870 }
871 Err(e) => self.prior_error = Some(e),
872 }
873 self
874 }
875
876 pub fn build(self, host: &CompositionHost) -> Result<ReconNode, ComposeError> {
879 let NodeBuilder {
880 id,
881 plan,
882 policy,
883 backpressure,
884 source,
885 spool,
886 output,
887 attach,
888 frames,
889 deltas,
890 stream_origin,
891 stream_only,
892 prior,
893 prior_error,
894 } = self;
895 let mut pr = Vec::new();
896 let cams = &plan.input.cameras;
897 if cams.is_empty() {
898 pr.push("cameras: empty".to_string());
899 }
900 let mut seen = std::collections::BTreeSet::new();
901 for c in cams {
902 if c.is_empty() || c.contains(['/', '\\']) {
903 pr.push(format!("cameras: invalid name {c:?}"));
904 } else if !seen.insert(c.as_str()) {
905 pr.push(format!("cameras: duplicate {c}"));
906 }
907 }
908 let z = plan.zones;
909 if z.span == 0 {
910 pr.push("zones.span: must be at least 1".into());
911 } else if z.overlap >= z.span {
912 pr.push(format!("zones.overlap: must be smaller than the span ({}), got {}", z.span, z.overlap));
913 }
914 if !plan.input.images.is_dir() {
915 pr.push(format!("images: folder not found: {}", plan.input.images.display()));
916 }
917 if spool.is_absolute() || spool.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
918 pr.push(format!("spool: must be a relative path inside the image folder, got {}", spool.display()));
919 }
920 if plan.sinks.live_stride == 0 {
921 pr.push("live_stride: must be at least 1 (1 = keep all)".into());
922 }
923 if plan.sparse.settle_tol.is_nan() || plan.sparse.settle_tol < 0.0 {
924 pr.push(format!("settle tolerance: must be >= 0, got {}", plan.sparse.settle_tol));
925 }
926 if let ReconcilePolicy::ReplayWindow { positions: 0 } = policy {
927 pr.push("reconcile: replay window must be at least 1 position".into());
928 }
929 match backpressure {
930 Backpressure::Block { max_pending: 0 } | Backpressure::KeepLatest { max_pending: 0 } => {
931 pr.push("backpressure: max_pending must be at least 1".into())
932 }
933 _ => {}
934 }
935 if let Some(e) = prior_error {
937 pr.push(format!("intrinsics_prior: {e}"));
938 }
939 let prior = match prior {
940 Some(p) => Some(p),
941 None => plan.sparse.intrinsics_prior.as_ref().and_then(|f| {
942 IntrinsicsPrior::load(f, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma)
943 .map_err(|e| pr.push(format!("sparse.intrinsics_prior: {e}")))
944 .ok()
945 }),
946 };
947 if let Some(p) = &prior {
948 match p.validate() {
949 Err(e) => pr.push(format!("intrinsics_prior: {e}")),
950 Ok(()) => {
951 for n in p.named.keys().filter(|n| !cams.contains(n)) {
952 pr.push(format!("intrinsics_prior: camera {n:?} is not one of the node's cameras"));
953 }
954 }
955 }
956 }
957 let sift = make_sift_backend(&plan.features.backend).map_err(|e| pr.push(format!("features.backend: {e}"))).ok();
958 let matcher = make_match_backend(&plan.matching.backend).map_err(|e| pr.push(format!("matching.backend: {e}"))).ok();
959 let ba_backend = make_ba_backend(&plan.sparse.ba_backend).map_err(|e| pr.push(format!("sparse.ba_backend: {e}"))).ok();
960 let sparse = make_sparse_backend(&plan.sparse.backend).map_err(|e| pr.push(format!("sparse.backend: {e}"))).ok();
961 if plan.dense.enabled {
962 if let Err(e) = parse_profile(&plan.dense.profile) {
963 pr.push(format!("dense.profile: {e}"));
964 }
965 }
966 let dense = if plan.dense.enabled { plan.dense.config().map_err(|e| pr.push(format!("dense: {e}"))).ok() } else { None };
967 if !pr.is_empty() {
968 return Err(ComposeError::Config(pr));
969 }
970 if host.node(&id).is_some() {
971 return Err(ComposeError::DuplicateNode(id));
972 }
973
974 let mut sc = SessionConfig::new(plan.input.images.clone());
975 sc.span = z.span;
976 sc.overlap = z.overlap;
977 sc.total_positions = None;
978 sc.incremental_triangulation = plan.sparse.incremental_triangulation;
979 sc.fixed_enu_origin = plan.align.fixed_origin;
980 sc.seed = plan.seed;
981 sc.extraction.sift.max_num_features = plan.features.max_num_features;
982 sc.extraction.reader.max_image_size = plan.features.max_image_size;
983 sc.sift = sift.expect("checked");
984 sc.matcher = Arc::from(matcher.expect("checked"));
985 sc.ba_backend = ba_backend.expect("checked");
986 sc.sparse = sparse.expect("checked");
987 sc.dense = dense;
988 sc.history = match policy {
989 ReconcilePolicy::ReplayWindow { positions } => positions + 1,
990 _ => 0,
991 };
992 sc.decode_frames = frames;
993 sc.stable_display = plan.sparse.stable_display;
994 sc.settle_tol = plan.sparse.settle_tol;
995 sc.settle_max_zone = plan.sparse.settle_max_zone;
996 sc.intrinsics_prior = prior.map(Arc::new);
997 sc.lookahead = plan.live.lookahead;
998 sc.preview = plan.live.preview;
999 if deltas.as_ref().is_some_and(|o| o.stride == 0) {
1000 return Err(ComposeError::Config(vec!["display_deltas: stride must be at least 1".into()]));
1001 }
1002
1003 let origin = Arc::new(Mutex::new(None));
1004 let mut pl = Pipeline::new(Session::new(sc));
1005 if let Some(opts) = deltas {
1006 let mut feed = DisplayFeed::new(opts).with_origin(Arc::clone(&origin));
1007 pl = pl.derive(move |e| feed.handle(e).into_iter().collect());
1008 }
1009 if let Some((dir, opts)) = &output {
1010 let io = |e: std::io::Error| ComposeError::Io(format!("{}: {e}", dir.display()));
1011 pl = if plan.sparse.stable_display {
1012 let set = SinkSet {
1014 models: opts.save_models && !stream_only,
1015 zone_ply: !stream_only,
1016 live_files: !stream_only,
1017 live_stride: plan.sinks.live_stride,
1018 live_stream: Some(LiveStream { enu: stream_origin.is_some(), origin_gps: stream_origin }),
1019 ..SinkSet::all()
1020 };
1021 pl.on_any(DefaultSinks::with_set(dir, opts.echo, set).map_err(io)?.into_hook())
1022 } else {
1023 sinks::attach(pl, dir, opts).map_err(io)?
1024 };
1025 }
1026 for a in attach {
1027 pl = a(pl);
1028 }
1029 let resolver = Arc::new(Resolver::new(plan.input.cameras.clone(), plan.input.images.clone(), spool));
1030 let core = Core {
1031 cameras: plan.input.cameras.clone(),
1032 resolver: Arc::clone(&resolver),
1033 policy,
1034 source,
1035 accepted: BTreeMap::new(),
1036 order: Vec::new(),
1037 recent: VecDeque::new(),
1038 waiting: BTreeMap::new(),
1039 stats: ComposeStats::default(),
1040 pipeline: Some(pl),
1041 ended: None,
1042 origin,
1043 batching: false,
1044 deferred: Vec::new(),
1045 };
1046 let ahead = core.pipeline.as_ref().expect("open pipeline").reducer().lookahead();
1047 let batch = if matches!(policy, ReconcilePolicy::ReplayWindow { .. }) { 0 } else { plan.live.batch };
1048 let node = ReconNode { id, backpressure, core: Arc::new(Mutex::new(core)), batch, ahead, resolver };
1049 host.register(&node)?;
1050 Ok(node)
1051 }
1052}
1053
1054#[derive(Clone)]
1061pub struct ReconNode {
1062 id: String,
1063 backpressure: Backpressure,
1064 core: Arc<Mutex<Core>>,
1065 batch: usize,
1067 ahead: Lookahead,
1069 resolver: Arc<Resolver>,
1070}
1071
1072impl fmt::Debug for ReconNode {
1073 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1074 f.debug_struct("ReconNode").field("id", &self.id).finish()
1075 }
1076}
1077
1078#[derive(Clone, Copy, Debug)]
1079struct Accepted {
1080 revision: u32,
1081 position: usize,
1082}
1083
1084struct Core {
1085 cameras: Vec<String>,
1086 resolver: Arc<Resolver>,
1087 policy: ReconcilePolicy,
1088 source: Option<String>,
1089 accepted: BTreeMap<u64, Accepted>,
1091 order: Vec<u64>,
1093 recent: VecDeque<FrameSet>,
1095 waiting: BTreeMap<u64, Vec<String>>,
1097 stats: ComposeStats,
1098 pipeline: Option<Pipeline<Session>>,
1099 ended: Option<Closed>,
1101 origin: Arc<Mutex<Option<[f64; 3]>>>,
1103 batching: bool,
1105 deferred: Vec<FrameSet>,
1106}
1107
1108impl ReconNode {
1109 pub fn id(&self) -> &str {
1111 &self.id
1112 }
1113
1114 pub fn compose(&self, state: FrameState) -> Result<Outcome, ComposeError> {
1116 lock(&self.core).compose(state)
1117 }
1118
1119 pub fn compose_all(&self, states: impl IntoIterator<Item = FrameState>) -> Result<Vec<Outcome>, ComposeError> {
1121 states.into_iter().map(|s| self.compose(s)).collect()
1122 }
1123
1124 pub fn compose_batch(&self, states: impl IntoIterator<Item = FrameState>) -> Vec<Result<Outcome, ComposeError>> {
1130 lock(&self.core).compose_batch(states.into_iter().collect())
1131 }
1132
1133 pub fn poll(&self) -> usize {
1135 lock(&self.core).pipeline.as_mut().map_or(0, |p| p.poll())
1136 }
1137
1138 pub fn subscribe(&self) -> Option<Receiver<Event>> {
1140 lock(&self.core).pipeline.as_ref().map(|p| p.subscribe())
1141 }
1142
1143 pub fn stats(&self) -> ComposeStats {
1145 lock(&self.core).stats.clone()
1146 }
1147
1148 pub fn positions(&self) -> usize {
1150 lock(&self.core).order.len()
1151 }
1152
1153 pub fn journal(&self) -> Vec<(u64, u32)> {
1155 let c = lock(&self.core);
1156 c.order.iter().map(|s| (*s, c.accepted[s].revision)).collect()
1157 }
1158
1159 pub fn waiting(&self) -> Vec<(u64, Vec<String>)> {
1161 lock(&self.core).waiting.iter().map(|(k, v)| (*k, v.clone())).collect()
1162 }
1163
1164 pub fn with_session<T>(&self, f: impl FnOnce(&Session) -> T) -> Option<T> {
1166 lock(&self.core).pipeline.as_ref().map(|p| f(p.reducer()))
1167 }
1168
1169 pub fn close(&self) -> Result<Closed, ComposeError> {
1172 self.close_inner()
1173 }
1174
1175 fn close_inner(&self) -> Result<Closed, ComposeError> {
1176 let mut c = lock(&self.core);
1177 if let Some(done) = c.ended.take() {
1178 return Ok(done);
1179 }
1180 c.finish().ok_or(ComposeError::Closed)
1181 }
1182
1183 pub fn spawn(&self) -> ComposeHandle {
1186 let queue = Arc::new(Queue { st: Mutex::new(QueueState::default()), cv: Condvar::new() });
1187 let (q, node) = (Arc::clone(&queue), self.clone());
1188 let worker = std::thread::Builder::new()
1189 .name(format!("compose-{}", self.id))
1190 .spawn(move || {
1191 let mut outcomes = Vec::new();
1192 loop {
1193 let next = {
1195 let mut st = lock(&q.st);
1196 loop {
1197 if let Some(s) = st.items.pop_front() {
1198 let mut taken = vec![s];
1199 if node.batch >= 2 && matches!(taken[0], FrameState::Ready(_)) && st.items.len() + 1 >= node.batch {
1200 while taken.len() < BATCH_MAX && !matches!(taken.last(), Some(FrameState::End)) {
1201 match st.items.pop_front() {
1202 Some(more) => taken.push(more),
1203 None => break,
1204 }
1205 }
1206 }
1207 q.cv.notify_all();
1208 break Some(taken);
1209 }
1210 if st.closed {
1211 break None;
1212 }
1213 st = q.cv.wait(st).unwrap_or_else(|p| p.into_inner());
1214 }
1215 };
1216 let Some(taken) = next else { break };
1217 let end = taken.iter().any(|s| matches!(s, FrameState::End));
1218 let n = taken.len() as u64;
1219 let rs: Vec<_> = if n == 1 { taken.into_iter().map(|s| node.compose(s)).collect() } else { node.compose_batch(taken) };
1220 lock(&q.st).processed += n;
1221 let fatal = rs.iter().any(|r| matches!(r, Err(ComposeError::Session(_))));
1222 outcomes.extend(rs);
1223 if end || fatal {
1224 break;
1225 }
1226 }
1227 let mut st = lock(&q.st);
1228 st.closed = true;
1229 st.items.clear();
1230 q.cv.notify_all();
1231 outcomes
1232 })
1233 .expect("compose worker thread");
1234 ComposeHandle { node: self.clone(), queue, policy: self.backpressure, worker: Some(worker) }
1235 }
1236}
1237
1238impl Core {
1239 fn pl(&mut self) -> &mut Pipeline<Session> {
1240 self.pipeline.as_mut().expect("open pipeline")
1241 }
1242
1243 fn warn(&mut self, message: String) {
1244 if let Some(p) = self.pipeline.as_mut() {
1245 p.reducer().emit_external(|meta| Event::Warning { meta, message });
1246 p.poll();
1247 }
1248 }
1249
1250 fn ignore(&mut self, key: &FrameKey, reason: IgnoreReason) -> Result<Outcome, ComposeError> {
1251 if self.policy == ReconcilePolicy::Strict && reason != IgnoreReason::Closed {
1252 return Err(ComposeError::Rejected(format!("{key}: {reason}")));
1253 }
1254 self.stats.ignored += 1;
1255 self.warn(format!("compose: {key} ignored: {reason}"));
1256 Ok(Outcome::Ignored(reason))
1257 }
1258
1259 fn compose(&mut self, state: FrameState) -> Result<Outcome, ComposeError> {
1260 self.stats.declared += 1;
1261 if self.pipeline.is_none() {
1262 return match state {
1263 FrameState::End => Ok(Outcome::Ended),
1264 _ => Ok(Outcome::Ignored(IgnoreReason::Closed)),
1265 };
1266 }
1267 match state {
1268 FrameState::End => {
1269 let done = self.finish().ok_or(ComposeError::Closed)?;
1270 self.ended = Some(done);
1271 Ok(Outcome::Ended)
1272 }
1273 FrameState::Pending { key, missing_cameras, .. } => {
1274 if let Some(r) = self.check_source(&key) {
1275 return r;
1276 }
1277 self.stats.waiting += 1;
1278 if !self.accepted.contains_key(&key.sequence) {
1279 self.waiting.insert(key.sequence, missing_cameras.clone());
1280 }
1281 Ok(Outcome::Waiting { missing: missing_cameras })
1282 }
1283 FrameState::Ready(set) => self.ready(set),
1284 }
1285 }
1286
1287 fn check_source(&mut self, key: &FrameKey) -> Option<Result<Outcome, ComposeError>> {
1288 match &self.source {
1289 None => {
1290 self.source = Some(key.source_id.clone());
1291 None
1292 }
1293 Some(s) if *s == key.source_id => None,
1294 Some(s) => {
1295 let expected = s.clone();
1296 Some(self.ignore(key, IgnoreReason::ForeignSource { expected }))
1297 }
1298 }
1299 }
1300
1301 fn ready(&mut self, set: LiveFrameSet) -> Result<Outcome, ComposeError> {
1302 let key = set.key.clone();
1303 let mut by_cam: HashMap<&str, &LiveImage> = HashMap::new();
1305 for im in &set.images {
1306 if !self.cameras.contains(&im.camera) {
1307 return Err(ComposeError::InvalidFrameSet(format!("{key}: unknown camera {}", im.camera)));
1308 }
1309 if by_cam.insert(im.camera.as_str(), im).is_some() {
1310 return Err(ComposeError::InvalidFrameSet(format!("{key}: camera {} given twice", im.camera)));
1311 }
1312 }
1313 if let Some(r) = self.check_source(&key) {
1314 return r;
1315 }
1316 let missing: Vec<String> = self.cameras.iter().filter(|c| !by_cam.contains_key(c.as_str())).cloned().collect();
1317 if !missing.is_empty() && !self.accepted.contains_key(&key.sequence) {
1318 self.stats.waiting += 1;
1319 self.waiting.insert(key.sequence, missing.clone());
1320 return Ok(Outcome::Waiting { missing });
1321 }
1322
1323 if let Some(acc) = self.accepted.get(&key.sequence).copied() {
1324 if key.revision == acc.revision {
1325 self.stats.duplicates += 1;
1326 return Ok(Outcome::Duplicate { position: acc.position });
1327 }
1328 if key.revision < acc.revision {
1329 return self.ignore(&key, IgnoreReason::StaleRevision { accepted: acc.revision });
1330 }
1331 if !missing.is_empty() {
1332 return Err(ComposeError::InvalidFrameSet(format!("{key}: correction misses cameras {}", missing.join(", "))));
1333 }
1334 return self.correct(set, acc);
1335 }
1336
1337 let newest = self.order.last().copied();
1338 match newest {
1339 Some(n) if key.sequence < n => self.late(set),
1340 _ => {
1341 let skipped = newest.map_or(0, |n| key.sequence - n - 1);
1342 if skipped > 0 && self.policy == ReconcilePolicy::Strict {
1343 return Err(ComposeError::Rejected(format!("{key}: {skipped} sequence(s) missing before it")));
1344 }
1345 let fs = self.resolver.resolve_cached(&set)?;
1346 self.waiting.remove(&key.sequence);
1347 let position = self.order.len();
1348 self.push(fs)?;
1349 self.order.push(key.sequence);
1350 self.accepted.insert(key.sequence, Accepted { revision: key.revision, position });
1351 self.stats.appended += 1;
1352 if skipped > 0 {
1353 self.stats.skipped_sequences += skipped;
1354 self.warn(format!("compose: {key} appended at position {position} after {skipped} missing sequence(s)"));
1355 }
1356 Ok(Outcome::Appended { position, skipped })
1357 }
1358 }
1359 }
1360
1361 fn late(&mut self, set: LiveFrameSet) -> Result<Outcome, ComposeError> {
1363 self.flush_deferred()?;
1364 let key = set.key.clone();
1365 let ReconcilePolicy::ReplayWindow { positions: w } = self.policy else {
1366 return self.ignore(&key, IgnoreReason::Late);
1367 };
1368 let p = self.order.partition_point(|&s| s < key.sequence);
1369 if !self.in_window(p, w) {
1370 return self.ignore(&key, IgnoreReason::OutsideReplayWindow { position: p });
1371 }
1372 let fs = self.resolve(&set, None)?;
1373 let tail_sets: Vec<FrameSet> = self.recent.iter().skip(self.recent.len() - (self.order.len() - p)).cloned().collect();
1374 let mut tail = vec![fs];
1375 tail.extend(tail_sets);
1376 let mut seqs = vec![key.sequence];
1377 seqs.extend(self.order[p..].iter().copied());
1378 self.waiting.remove(&key.sequence);
1379 let n = self.replay(p, tail, &seqs, (key.sequence, key.revision))?;
1380 self.warn(format!("compose: late {key} inserted at position {p}, replayed {n} position(s)"));
1381 Ok(Outcome::Replayed { from: p, positions: n })
1382 }
1383
1384 fn correct(&mut self, set: LiveFrameSet, acc: Accepted) -> Result<Outcome, ComposeError> {
1386 self.flush_deferred()?;
1387 let key = set.key.clone();
1388 let p = acc.position;
1389 match self.policy {
1390 ReconcilePolicy::AppendOnly | ReconcilePolicy::Strict => self.ignore(&key, IgnoreReason::CorrectionNotApplied),
1391 ReconcilePolicy::ReplayWindow { positions: w } => {
1392 if !self.in_window(p, w) {
1393 return self.ignore(&key, IgnoreReason::OutsideReplayWindow { position: p });
1394 }
1395 let fs = self.resolve(&set, None)?;
1396 let start = self.recent.len() - (self.order.len() - p);
1397 let mut tail: Vec<FrameSet> = self.recent.iter().skip(start).cloned().collect();
1398 tail[0] = fs;
1399 let seqs: Vec<u64> = self.order[p..].to_vec();
1400 let n = self.replay(p, tail, &seqs, (key.sequence, key.revision))?;
1401 self.warn(format!("compose: correction {key} applied from position {p}, replayed {n} position(s)"));
1402 Ok(Outcome::Replayed { from: p, positions: n })
1403 }
1404 ReconcilePolicy::InvalidateAffectedZones => {
1405 let names: Vec<String> = self.pl().reducer().frames()[p].iter().map(|f| f.name.clone()).collect();
1407 self.resolve(&set, Some(&names))?;
1408 let zones: Vec<usize> =
1409 self.pl().reducer().arrived_zones().into_iter().filter(|z| z.lo <= p && p < z.hi).map(|z| z.zone).collect();
1410 for &z in &zones {
1411 self.pl().command(Command::InvalidateZone(z));
1412 }
1413 self.fail_check()?;
1414 self.accepted.insert(key.sequence, Accepted { revision: key.revision, position: p });
1415 self.stats.invalidations += 1;
1416 Ok(Outcome::Invalidated { position: p, zones })
1417 }
1418 }
1419 }
1420
1421 fn in_window(&self, p: usize, w: usize) -> bool {
1423 let npos = self.order.len();
1424 npos - p <= w && npos - p <= self.recent.len()
1425 }
1426
1427 fn replay(&mut self, p: usize, tail: Vec<FrameSet>, seqs: &[u64], changed: (u64, u32)) -> Result<usize, ComposeError> {
1430 let npos = self.order.len();
1431 self.pl().command(Command::ResetFrom(p));
1432 if self.pl().reducer().positions() != p {
1433 return Err(ComposeError::Session(format!("rewind to position {p} was refused by the session")));
1434 }
1435 let revs: HashMap<u64, u32> = self.order[p..].iter().map(|s| (*s, self.accepted[s].revision)).collect();
1436 for s in &self.order[p..] {
1437 self.accepted.remove(s);
1438 }
1439 self.order.truncate(p);
1440 let keep = self.recent.len() - (npos - p);
1441 self.recent.truncate(keep);
1442 let n = tail.len();
1443 for (fs, &seq) in tail.into_iter().zip(seqs) {
1444 let revision = if seq == changed.0 { changed.1 } else { revs[&seq] };
1445 let position = self.order.len();
1446 self.push(fs)?;
1447 self.order.push(seq);
1448 self.accepted.insert(seq, Accepted { revision, position });
1449 }
1450 self.stats.replays += 1;
1451 self.stats.replayed_positions += n as u64;
1452 Ok(n)
1453 }
1454
1455 fn push(&mut self, fs: FrameSet) -> Result<(), ComposeError> {
1457 let cap = match self.policy {
1458 ReconcilePolicy::ReplayWindow { positions } => positions,
1459 _ => 0,
1460 };
1461 if cap > 0 {
1462 self.recent.push_back(fs.clone());
1463 while self.recent.len() > cap {
1464 self.recent.pop_front();
1465 }
1466 }
1467 if let Some(g) = fs.gps.first() {
1468 lock(&self.origin).get_or_insert([g.lat, g.lon, g.alt]);
1469 }
1470 if self.batching {
1471 self.deferred.push(fs);
1472 return Ok(());
1473 }
1474 self.pl().push(Input::Frames(fs));
1475 self.fail_check()
1476 }
1477
1478 fn flush_deferred(&mut self) -> Result<(), ComposeError> {
1480 if self.deferred.is_empty() {
1481 return Ok(());
1482 }
1483 let mut sets = std::mem::take(&mut self.deferred);
1484 let input = if sets.len() == 1 { Input::Frames(sets.remove(0)) } else { Input::Batch(sets) };
1485 self.pl().push(input);
1486 self.fail_check()
1487 }
1488
1489 fn compose_batch(&mut self, states: Vec<FrameState>) -> Vec<Result<Outcome, ComposeError>> {
1490 let mut outs: Vec<Result<Outcome, ComposeError>> = Vec::with_capacity(states.len());
1491 self.batching = true;
1492 let mut last_deferred = None;
1493 for st in states {
1494 let before = self.deferred.len();
1495 let r = self.compose(st);
1496 if self.deferred.len() > before {
1497 last_deferred = Some(outs.len());
1498 } else if self.deferred.is_empty() {
1499 last_deferred = None;
1500 }
1501 outs.push(r);
1502 }
1503 self.batching = false;
1504 if let Err(e) = self.flush_deferred() {
1505 match last_deferred {
1506 Some(i) => outs[i] = Err(e),
1507 None => outs.push(Err(e)),
1508 }
1509 }
1510 outs
1511 }
1512
1513 fn fail_check(&mut self) -> Result<(), ComposeError> {
1514 match self.pl().reducer().failed() {
1515 Some(e) => Err(ComposeError::Session(e.to_string())),
1516 None => Ok(()),
1517 }
1518 }
1519
1520 fn resolve(&self, set: &LiveFrameSet, names: Option<&[String]>) -> Result<FrameSet, ComposeError> {
1521 self.resolver.resolve(set, names)
1522 }
1523
1524 fn finish(&mut self) -> Option<Closed> {
1525 let _ = self.flush_deferred();
1526 let pl = self.pipeline.take()?;
1527 let (session, summary) = pl.finish();
1528 Some(Closed { session, summary, stats: self.stats.clone() })
1529 }
1530}
1531
1532struct Resolver {
1535 cameras: Vec<String>,
1536 image_root: PathBuf,
1537 spool: PathBuf,
1538 ahead: Mutex<HashMap<(u64, u32), FrameSet>>,
1540}
1541
1542impl Resolver {
1543 fn new(cameras: Vec<String>, image_root: PathBuf, spool: PathBuf) -> Self {
1544 Self { cameras, image_root, spool, ahead: Mutex::new(HashMap::new()) }
1545 }
1546
1547 fn resolve_cached(&self, set: &LiveFrameSet) -> Result<FrameSet, ComposeError> {
1549 let hit = lock(&self.ahead).remove(&(set.key.sequence, set.key.revision));
1550 match hit {
1551 Some(fs) => Ok(fs),
1552 None => self.resolve(set, None),
1553 }
1554 }
1555
1556 fn resolve_ahead(&self, set: &LiveFrameSet) -> Option<FrameSet> {
1560 if set.images.len() != self.cameras.len()
1561 || !self.cameras.iter().all(|c| set.images.iter().filter(|i| &i.camera == c).count() == 1)
1562 || set.images.iter().any(|i| matches!(i.payload, FramePayload::Rgb8 { .. }))
1563 {
1564 return None;
1565 }
1566 let fs = self.resolve(set, None).ok()?;
1567 let mut m = lock(&self.ahead);
1568 if m.len() >= 256 {
1569 m.clear();
1570 }
1571 m.insert((set.key.sequence, set.key.revision), fs.clone());
1572 Some(fs)
1573 }
1574
1575 fn resolve(&self, set: &LiveFrameSet, names: Option<&[String]>) -> Result<FrameSet, ComposeError> {
1578 let key = &set.key;
1579 let mut fs = FrameSet::default();
1580 for (i, cam) in self.cameras.iter().enumerate() {
1581 let im = set.images.iter().find(|x| &x.camera == cam).expect("complete set");
1582 let forced = names.map(|n| n[i].clone());
1583 let name = match &im.payload {
1584 FramePayload::File { relative_name } => {
1585 let src = self.image_root.join(relative_name);
1586 if !src.is_file() {
1587 return Err(ComposeError::InvalidFrameSet(format!("{key}: file not found: {}", src.display())));
1588 }
1589 match forced {
1590 Some(n) if n != *relative_name => {
1591 let bytes = std::fs::read(&src).map_err(|e| ComposeError::Io(format!("{}: {e}", src.display())))?;
1592 self.store_encoded(&n, &bytes, ext_of(relative_name))?;
1593 n
1594 }
1595 _ => relative_name.clone(),
1596 }
1597 }
1598 FramePayload::Rgb8 { width, height, pixels } => {
1599 if pixels.len() != *width as usize * *height as usize * 3 || *width == 0 || *height == 0 {
1600 return Err(ComposeError::InvalidFrameSet(format!(
1601 "{key}: camera {cam}: {} bytes for {width}x{height} RGB8",
1602 pixels.len()
1603 )));
1604 }
1605 let n = forced.unwrap_or_else(|| self.spool_name(key, cam, "png"));
1606 let img = image::RgbImage::from_raw(*width, *height, pixels.to_vec()).expect("size checked");
1607 self.store_image(&n, &image::DynamicImage::ImageRgb8(img))?;
1608 n
1609 }
1610 FramePayload::Encoded { bytes, format } => {
1611 let n = forced.unwrap_or_else(|| self.spool_name(key, cam, format.extension()));
1612 self.store_encoded(&n, bytes, Some(format.extension()))?;
1613 n
1614 }
1615 };
1616 fs.images.push(crate::session::FrameImage { camera: cam.clone(), name });
1617 }
1618 for g in &set.gps {
1619 let mut g = g.clone();
1620 if let Some(i) = self.cameras.iter().position(|c| *c == g.name) {
1621 g.name = fs.images[i].name.clone();
1622 }
1623 fs.gps.push(g);
1624 }
1625 Ok(fs)
1626 }
1627
1628 fn spool_name(&self, key: &FrameKey, cam: &str, ext: &str) -> String {
1629 let src: String = key.source_id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
1630 let p = self.spool.join(cam).join(format!("{src}_{:010}.{ext}", key.sequence));
1631 p.to_string_lossy().replace('\\', "/")
1632 }
1633
1634 fn store_encoded(&self, name: &str, bytes: &[u8], ext: Option<&str>) -> Result<(), ComposeError> {
1636 if ext.is_some() && ext == ext_of(name) {
1637 return self.atomic_write(name, |path| std::fs::write(path, bytes).map_err(|e| e.to_string()));
1638 }
1639 let img = image::load_from_memory(bytes).map_err(|e| ComposeError::InvalidFrameSet(format!("{name}: {e}")))?;
1640 self.store_image(name, &img)
1641 }
1642
1643 fn store_image(&self, name: &str, img: &image::DynamicImage) -> Result<(), ComposeError> {
1644 let fmt = image::ImageFormat::from_path(name).map_err(|e| ComposeError::Io(format!("{name}: {e}")))?;
1645 self.atomic_write(name, |path| img.save_with_format(path, fmt).map_err(|e| e.to_string()))
1646 }
1647
1648 fn atomic_write(&self, name: &str, write: impl FnOnce(&Path) -> Result<(), String>) -> Result<(), ComposeError> {
1650 let dst = self.image_root.join(name);
1651 let dir = dst.parent().unwrap_or(&self.image_root);
1652 std::fs::create_dir_all(dir).map_err(|e| ComposeError::Io(format!("{}: {e}", dir.display())))?;
1653 let file = dst.file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
1654 let ext = ext_of(name).unwrap_or("tmp");
1655 let tmp = dir.join(format!(".{file}.partial.{ext}"));
1656 write(&tmp).map_err(|e| ComposeError::Io(format!("{}: {e}", tmp.display())))?;
1657 std::fs::rename(&tmp, &dst).map_err(|e| ComposeError::Io(format!("{}: {e}", dst.display())))
1658 }
1659}
1660
1661fn ext_of(name: &str) -> Option<&str> {
1662 Path::new(name).extension().and_then(|e| e.to_str())
1663}
1664
1665fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
1666 m.lock().unwrap_or_else(|p| p.into_inner())
1667}
1668
1669#[derive(Default)]
1674struct QueueState {
1675 items: VecDeque<FrameState>,
1676 closed: bool,
1677 submitted: u64,
1678 dropped: u64,
1679 processed: u64,
1680 max_depth: usize,
1681}
1682
1683struct Queue {
1684 st: Mutex<QueueState>,
1685 cv: Condvar,
1686}
1687
1688#[derive(Debug)]
1690pub enum SendError {
1691 Full(FrameState),
1693 Closed(FrameState),
1695}
1696
1697impl fmt::Display for SendError {
1698 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1699 match self {
1700 SendError::Full(_) => write!(f, "compose queue full"),
1701 SendError::Closed(_) => write!(f, "compose worker stopped"),
1702 }
1703 }
1704}
1705
1706impl std::error::Error for SendError {}
1707
1708#[derive(Clone, Debug, Default, PartialEq, Eq)]
1710pub struct QueueStats {
1711 pub submitted: u64,
1713 pub dropped: u64,
1715 pub processed: u64,
1717 pub depth: usize,
1719 pub max_depth: usize,
1721}
1722
1723pub struct ComposeHandle {
1725 node: ReconNode,
1726 queue: Arc<Queue>,
1727 policy: Backpressure,
1728 worker: Option<JoinHandle<Vec<Result<Outcome, ComposeError>>>>,
1729}
1730
1731impl fmt::Debug for ComposeHandle {
1732 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1733 f.debug_struct("ComposeHandle").field("node", &self.node.id).field("policy", &self.policy).field("stats", &self.stats()).finish()
1734 }
1735}
1736
1737impl ComposeHandle {
1738 pub fn node(&self) -> &ReconNode {
1740 &self.node
1741 }
1742
1743 pub fn send(&self, state: FrameState) -> Result<(), SendError> {
1745 self.enqueue(state, true)
1746 }
1747
1748 pub fn try_send(&self, state: FrameState) -> Result<(), SendError> {
1750 self.enqueue(state, false)
1751 }
1752
1753 pub fn send_all(&self, states: impl IntoIterator<Item = FrameState>) -> Result<(), SendError> {
1755 states.into_iter().try_for_each(|s| self.send(s))
1756 }
1757
1758 fn enqueue(&self, state: FrameState, wait: bool) -> Result<(), SendError> {
1759 if let (FrameState::Ready(set), true) = (&state, self.node.ahead.limit() > 0) {
1761 if let Some(fs) = self.node.resolver.resolve_ahead(set) {
1762 self.node.ahead.hint(&fs);
1763 }
1764 }
1765 let q = &self.queue;
1766 let mut st = lock(&q.st);
1767 if st.closed {
1768 return Err(SendError::Closed(state));
1769 }
1770 let end = matches!(state, FrameState::End);
1772 match self.policy {
1773 Backpressure::Unbounded => {}
1774 Backpressure::Block { max_pending } => {
1775 while !end && st.items.len() >= max_pending && !st.closed {
1776 if !wait {
1777 return Err(SendError::Full(state));
1778 }
1779 st = q.cv.wait(st).unwrap_or_else(|p| p.into_inner());
1780 }
1781 if st.closed {
1782 return Err(SendError::Closed(state));
1783 }
1784 }
1785 Backpressure::KeepLatest { max_pending } => {
1786 while !end && st.items.len() >= max_pending {
1787 match st.items.iter().position(|s| matches!(s, FrameState::Ready(_))) {
1788 Some(i) => {
1789 st.items.remove(i);
1790 st.dropped += 1;
1791 }
1792 None => {
1793 st.items.pop_front();
1794 }
1795 }
1796 }
1797 }
1798 }
1799 st.items.push_back(state);
1800 st.submitted += 1;
1801 st.max_depth = st.max_depth.max(st.items.len());
1802 q.cv.notify_all();
1803 Ok(())
1804 }
1805
1806 pub fn stats(&self) -> QueueStats {
1808 let st = lock(&self.queue.st);
1809 QueueStats { submitted: st.submitted, dropped: st.dropped, processed: st.processed, depth: st.items.len(), max_depth: st.max_depth }
1810 }
1811
1812 pub fn close(mut self) -> (Vec<Result<Outcome, ComposeError>>, Result<Closed, ComposeError>) {
1815 let _ = self.enqueue(FrameState::End, false);
1816 let outcomes = self.worker.take().map(|w| w.join().unwrap_or_default()).unwrap_or_default();
1817 (outcomes, self.node.close_inner())
1818 }
1819}
1820
1821impl Drop for ComposeHandle {
1822 fn drop(&mut self) {
1823 if let Some(w) = self.worker.take() {
1824 {
1825 let mut st = lock(&self.queue.st);
1826 st.closed = true;
1827 self.queue.cv.notify_all();
1828 }
1829 let _ = w.join();
1830 }
1831 }
1832}
1833
1834#[cfg(test)]
1835mod tests {
1836 use super::*;
1837
1838 #[test]
1839 fn spool_names_are_sanitized_and_stable() {
1840 let c = Core {
1841 cameras: vec!["camF".into()],
1842 resolver: Arc::new(Resolver::new(vec!["camF".into()], PathBuf::from("/tmp/x"), PathBuf::from(".live"))),
1843 policy: ReconcilePolicy::AppendOnly,
1844 source: None,
1845 accepted: BTreeMap::new(),
1846 order: Vec::new(),
1847 recent: VecDeque::new(),
1848 waiting: BTreeMap::new(),
1849 stats: ComposeStats::default(),
1850 pipeline: None,
1851 ended: None,
1852 origin: Arc::new(Mutex::new(None)),
1853 batching: false,
1854 deferred: Vec::new(),
1855 };
1856 let k = FrameKey::new("rtsp://drone 1", 42, 3);
1857 assert_eq!(c.resolver.spool_name(&k, "camF", "png"), ".live/camF/rtsp___drone_1_0000000042.png");
1858 }
1859
1860 #[test]
1861 fn key_display() {
1862 assert_eq!(FrameKey::new("d", 7, 1).to_string(), "d#7r1");
1863 }
1864}