Skip to main content

cumulus3d_cli/
compose.rs

1/*
2 * compose.rs
3 *
4 * Copyright (c) 2026 ParkSangWoo
5 *
6 * Author(s):
7 *
8 *      ParkSangWoo <dev.parksangwoo@gmail.com>
9 *
10 *
11 * This file is part of cumulus3d.
12 *
13 * Licensed under the Apache License, Version 2.0 (the "License");
14 * you may not use this file except in compliance with the License.
15 * You may obtain a copy of the License at
16 *
17 *      http://www.apache.org/licenses/LICENSE-2.0
18 *
19 * Unless required by applicable law or agreed to in writing, software
20 * distributed under the License is distributed on an "AS IS" BASIS,
21 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22 * See the License for the specific language governing permissions and
23 * limitations under the License.
24 *
25 * SPDX-License-Identifier: Apache-2.0
26 */
27
28//! Declarative incremental composition for live frame sources.
29//!
30//! [`crate::declare::Recon`] runs a finite, pre-discovered folder to completion. This module is the live counterpart:
31//! a retained node ([`ReconNode`]) owns a [`Session`] and its [`Pipeline`], and the caller only *declares* the current
32//! input state ([`FrameState`]). The node reconciles each declaration against what it has already accepted and does
33//! the minimal incremental work:
34//!
35//! | declared state | node action |
36//! |---|---|
37//! | new synchronized set (next sequence) | ingest one position |
38//! | same key and revision again | nothing ([`Outcome::Duplicate`]) |
39//! | set with cameras missing, or [`FrameState::Pending`] | wait, ingest nothing ([`Outcome::Waiting`]) |
40//! | sequence jump (dropped frames) | ingest, report the gap ([`Outcome::Appended`]`{ skipped }`) |
41//! | late arrival of an older sequence | per [`ReconcilePolicy`] |
42//! | higher revision of an accepted sequence | per [`ReconcilePolicy`] |
43//! | [`FrameState::End`] / [`ReconNode::close`] | close open zones, drain refinement, final events |
44//!
45//! Frames are identified by [`FrameKey`] (`source_id`, `sequence`, `revision`), not by path. Each accepted sequence
46//! becomes one position, in sequence order. Payloads can be files under the image folder or in-memory images
47//! ([`FramePayload`]); in-memory images are written atomically to a spool folder inside the image folder, because the
48//! reconstruction core reads images by name.
49//!
50//! Events are the ordinary pipeline events (hooks registered on the builder, or [`ReconNode::subscribe`]).
51//! Reconciliation decisions that drop or reorder input are also reported as [`Event::Warning`].
52//!
53//! # Example
54//!
55//! ```no_run
56//! use cumulus3d_cli::compose::{reconstruct, CompositionHost, FrameKey, FrameState, LiveFrameSet, ReconNode, ReconcilePolicy};
57//! use cumulus3d_cli::declare::Dense;
58//!
59//! fn main() -> Result<(), cumulus3d_cli::compose::ComposeError> {
60//!     let host = CompositionHost::new();
61//!     let recon = ReconNode::declare()
62//!         .images("live/images")
63//!         .cameras(["camF", "camR", "camL"])
64//!         .zones(12, 2)
65//!         .dense(Dense::profile("fast").fusion_min_views(3))
66//!         .reconcile(ReconcilePolicy::ReplayWindow { positions: 4 })
67//!         .on_zone_preview(|range, cloud| println!("preview {}: {} points", range.zone, cloud.len()))
68//!         .build(&host)?;
69//!
70//!     let set = |seq: u64| {
71//!         LiveFrameSet::new(FrameKey::new("drone-1", seq, 0))
72//!             .file("camF", format!("camF/camF_{:04}.jpg", seq * 3))
73//!             .file("camR", format!("camR/camR_{:04}.jpg", seq * 3))
74//!             .file("camL", format!("camL/camL_{:04}.jpg", seq * 3))
75//!     };
76//!     recon.compose(FrameState::ready(set(0)))?;
77//!     recon.compose(FrameState::ready(set(1)))?;
78//!     recon.compose(FrameState::ready(set(1)))?; // no-op: same identity and revision
79//!     // The same node, found by id (positional-memoization style):
80//!     reconstruct(&host, "default", FrameState::ready(set(2)))?;
81//!     let closed = recon.close()?;
82//!     println!("{} events, {:?}", closed.summary.events, closed.stats);
83//!     Ok(())
84//! }
85//! ```
86//!
87//! For a producer thread (decoder, network receiver) use [`ReconNode::spawn`]: the node then runs on its own thread
88//! behind a bounded queue with an explicit [`Backpressure`] policy, and the producer only calls
89//! [`ComposeHandle::send`] / [`ComposeHandle::try_send`] (both usable from async code; `try_send` never blocks).
90
91use crate::blocks::LiveStream;
92use crate::declare::{AlignFrame, Dense, Features, Plan};
93use crate::densewrap::{make_ba_backend, make_match_backend, make_sift_backend, make_sparse_backend, parse_profile};
94use crate::events::{Command, Event, EventKind, Frame, ZoneRange};
95use crate::live::DisplayFeed;
96use crate::pipeline::{Pipeline, QueuePolicy, Summary};
97use crate::post::LiveOptions;
98use crate::session::{FrameSet, Input, Session, SessionConfig};
99use crate::sinks::{self, DefaultSinks, SinkOptions, SinkSet};
100use cumulus3d_core::io::ply::PointCloud;
101use cumulus3d_core::io::GpsRecord;
102use std::collections::{BTreeMap, HashMap, VecDeque};
103use std::fmt;
104use std::path::{Path, PathBuf};
105use std::sync::mpsc::Receiver;
106use std::sync::{Arc, Condvar, Mutex, MutexGuard};
107use std::thread::JoinHandle;
108use std::time::SystemTime;
109
110// ============================================================================================
111// Input model
112// ============================================================================================
113
114/// Stable identity of one synchronized frame set.
115#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
116pub struct FrameKey {
117    /// Source (stream) identity. A node accepts one source.
118    pub source_id: String,
119    /// Synchronized position sequence number (increasing along the stream).
120    pub sequence: u64,
121    /// Correction/replacement version of this sequence (0 = original).
122    pub revision: u32,
123}
124
125impl FrameKey {
126    /// Key from its three parts.
127    pub fn new(source_id: impl Into<String>, sequence: u64, revision: u32) -> Self {
128        Self { source_id: source_id.into(), sequence, revision }
129    }
130}
131
132impl fmt::Display for FrameKey {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        write!(f, "{}#{}r{}", self.source_id, self.sequence, self.revision)
135    }
136}
137
138/// Encoded image container for [`FramePayload::Encoded`].
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum EncodedFormat {
141    /// JPEG bytes.
142    Jpeg,
143    /// PNG bytes.
144    Png,
145}
146
147impl EncodedFormat {
148    fn extension(self) -> &'static str {
149        match self {
150            EncodedFormat::Jpeg => "jpg",
151            EncodedFormat::Png => "png",
152        }
153    }
154}
155
156/// Where the pixels of one camera frame come from.
157#[derive(Clone)]
158pub enum FramePayload {
159    /// Existing file, relative to the node's image folder. The producer must finish writing it before declaring it.
160    File {
161        /// Path relative to the image folder (also the image name in models and GPS records).
162        relative_name: String,
163    },
164    /// Decoded RGB8 pixels (row-major, `width * height * 3` bytes). Spooled as PNG.
165    Rgb8 {
166        /// Width in pixels.
167        width: u32,
168        /// Height in pixels.
169        height: u32,
170        /// Pixels.
171        pixels: Arc<[u8]>,
172    },
173    /// Encoded image bytes. Spooled as-is.
174    Encoded {
175        /// Image bytes.
176        bytes: Arc<[u8]>,
177        /// Container format.
178        format: EncodedFormat,
179    },
180}
181
182impl fmt::Debug for FramePayload {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        match self {
185            FramePayload::File { relative_name } => f.debug_struct("File").field("relative_name", relative_name).finish(),
186            FramePayload::Rgb8 { width, height, .. } => f.debug_struct("Rgb8").field("width", width).field("height", height).finish(),
187            FramePayload::Encoded { bytes, format } => {
188                f.debug_struct("Encoded").field("bytes", &bytes.len()).field("format", format).finish()
189            }
190        }
191    }
192}
193
194/// One camera frame of a synchronized set.
195#[derive(Clone, Debug)]
196pub struct LiveImage {
197    /// Camera name (one of the node's cameras).
198    pub camera: String,
199    /// Pixels.
200    pub payload: FramePayload,
201}
202
203/// One synchronized frame set (one camera frame per camera) with its identity.
204#[derive(Clone, Debug)]
205pub struct LiveFrameSet {
206    /// Identity.
207    pub key: FrameKey,
208    /// Capture time.
209    pub timestamp: SystemTime,
210    /// Camera frames, any order.
211    pub images: Vec<LiveImage>,
212    /// GPS fixes for this set. A record named after a camera (e.g. `"camF"`) is renamed to that camera's stored
213    /// image name; a record already named after the stored image is kept.
214    pub gps: Vec<GpsRecord>,
215}
216
217impl LiveFrameSet {
218    /// Empty set with this key, stamped now.
219    pub fn new(key: FrameKey) -> Self {
220        Self { key, timestamp: SystemTime::now(), images: Vec::new(), gps: Vec::new() }
221    }
222    /// Adds a frame.
223    pub fn image(mut self, camera: impl Into<String>, payload: FramePayload) -> Self {
224        self.images.push(LiveImage { camera: camera.into(), payload });
225        self
226    }
227    /// Adds a file frame (path relative to the image folder).
228    pub fn file(self, camera: impl Into<String>, relative_name: impl Into<String>) -> Self {
229        self.image(camera, FramePayload::File { relative_name: relative_name.into() })
230    }
231    /// Adds an RGB8 frame.
232    pub fn rgb8(self, camera: impl Into<String>, width: u32, height: u32, pixels: impl Into<Arc<[u8]>>) -> Self {
233        self.image(camera, FramePayload::Rgb8 { width, height, pixels: pixels.into() })
234    }
235    /// Adds an encoded frame.
236    pub fn encoded(self, camera: impl Into<String>, bytes: impl Into<Arc<[u8]>>, format: EncodedFormat) -> Self {
237        self.image(camera, FramePayload::Encoded { bytes: bytes.into(), format })
238    }
239    /// Adds a GPS fix for one camera (`name` = camera name, see [`LiveFrameSet::gps`]).
240    pub fn gps_fix(mut self, camera: impl Into<String>, lat: f64, lon: f64, alt: f64) -> Self {
241        self.gps.push(GpsRecord { name: camera.into(), lat, lon, alt });
242        self
243    }
244    /// Sets the capture time.
245    pub fn at(mut self, t: SystemTime) -> Self {
246        self.timestamp = t;
247        self
248    }
249}
250
251/// Declared input state.
252#[derive(Clone, Debug)]
253pub enum FrameState {
254    /// A synchronized set is available. If cameras are missing it is treated like [`FrameState::Pending`].
255    Ready(LiveFrameSet),
256    /// A set is being assembled; nothing is ingested until it is declared ready.
257    Pending {
258        /// Identity of the set being assembled.
259        key: FrameKey,
260        /// Capture time.
261        timestamp: SystemTime,
262        /// Cameras not yet received.
263        missing_cameras: Vec<String>,
264    },
265    /// The stream ended: same as [`ReconNode::close`], the summary is kept for a later `close`.
266    End,
267}
268
269impl FrameState {
270    /// [`FrameState::Ready`].
271    pub fn ready(set: LiveFrameSet) -> Self {
272        FrameState::Ready(set)
273    }
274    /// [`FrameState::Pending`] stamped now.
275    pub fn pending<S: Into<String>>(key: FrameKey, missing_cameras: impl IntoIterator<Item = S>) -> Self {
276        FrameState::Pending { key, timestamp: SystemTime::now(), missing_cameras: missing_cameras.into_iter().map(Into::into).collect() }
277    }
278}
279
280// ============================================================================================
281// Policies and results
282// ============================================================================================
283
284/// What to do with input that is not a plain append (late arrivals and corrected revisions).
285#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
286pub enum ReconcilePolicy {
287    /// Append new sequences only. Late arrivals and corrections are ignored (with a warning). Gaps are accepted.
288    #[default]
289    AppendOnly,
290    /// Like `AppendOnly`, but a late arrival or a correction within the last `positions` positions rewinds the session
291    /// to that position (`Command::ResetFrom`) and replays the corrected/inserted set and every later set.
292    /// Older ones are ignored. Costs memory for `positions + 1` rewind points.
293    ReplayWindow {
294        /// Replay window in positions.
295        positions: usize,
296    },
297    /// A correction keeps the registered geometry and rebuilds the outputs (preview and refined clouds) of every
298    /// arrived zone containing the position, with the corrected pixels (`Command::InvalidateZone`). The corrected
299    /// frame is stored under the original image name; features, matches and poses are not recomputed and GPS fixes of
300    /// the correction are not applied (use `ReplayWindow` for those). Late arrivals are ignored.
301    InvalidateAffectedZones,
302    /// Accept only the exact next sequence; everything else (gap, late, correction, foreign source) is an error.
303    Strict,
304}
305
306/// Queue policy between a producer and a spawned node ([`ReconNode::spawn`]).
307#[derive(Clone, Copy, Debug, PartialEq, Eq)]
308pub enum Backpressure {
309    /// Never drop; the queue grows without bound.
310    Unbounded,
311    /// `send` blocks while `max_pending` states are queued (`try_send` returns [`SendError::Full`]).
312    Block {
313        /// Queue capacity.
314        max_pending: usize,
315    },
316    /// Keep the newest states: when `max_pending` are queued, the oldest queued ready set is dropped (it then shows up
317    /// as a sequence gap). Capture never waits for reconstruction.
318    KeepLatest {
319        /// Queue capacity.
320        max_pending: usize,
321    },
322}
323
324impl Default for Backpressure {
325    fn default() -> Self {
326        Backpressure::Block { max_pending: 8 }
327    }
328}
329
330/// Why a declared state was not applied.
331#[derive(Clone, Debug, PartialEq, Eq)]
332pub enum IgnoreReason {
333    /// Revision lower than the accepted one.
334    StaleRevision {
335        /// Accepted revision.
336        accepted: u32,
337    },
338    /// Higher revision of an accepted sequence, but the policy does not apply corrections.
339    CorrectionNotApplied,
340    /// Older sequence than the newest accepted one, never seen before, and the policy does not insert.
341    Late,
342    /// Correction or late arrival older than the replay window (or no rewind point left).
343    OutsideReplayWindow {
344        /// Position it would have to rewind to.
345        position: usize,
346    },
347    /// Different `source_id` than the node's source.
348    ForeignSource {
349        /// The node's source.
350        expected: String,
351    },
352    /// The node is closed.
353    Closed,
354}
355
356impl fmt::Display for IgnoreReason {
357    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358        match self {
359            IgnoreReason::StaleRevision { accepted } => write!(f, "stale revision (accepted revision {accepted})"),
360            IgnoreReason::CorrectionNotApplied => write!(f, "correction not applied by this policy"),
361            IgnoreReason::Late => write!(f, "late arrival not inserted by this policy"),
362            IgnoreReason::OutsideReplayWindow { position } => write!(f, "outside replay window (position {position})"),
363            IgnoreReason::ForeignSource { expected } => write!(f, "foreign source (node source is {expected})"),
364            IgnoreReason::Closed => write!(f, "node closed"),
365        }
366    }
367}
368
369/// Result of one declaration.
370#[derive(Clone, Debug, PartialEq, Eq)]
371pub enum Outcome {
372    /// Ingested as a new position. `skipped` = sequences missing between the previous accepted one and this one.
373    Appended {
374        /// New position.
375        position: usize,
376        /// Missing sequences before this one.
377        skipped: u64,
378    },
379    /// Same key and revision as an accepted set; nothing done.
380    Duplicate {
381        /// Its position.
382        position: usize,
383    },
384    /// Set incomplete; waiting for these cameras.
385    Waiting {
386        /// Missing cameras.
387        missing: Vec<String>,
388    },
389    /// Rewound to `from` and replayed `positions` sets (correction or late arrival under `ReplayWindow`).
390    Replayed {
391        /// Rewind position.
392        from: usize,
393        /// Sets pushed again (including the new one).
394        positions: usize,
395    },
396    /// Correction applied by rebuilding these zones (`InvalidateAffectedZones`).
397    Invalidated {
398        /// Corrected position.
399        position: usize,
400        /// Rebuilt zones (empty if no zone containing the position had arrived yet).
401        zones: Vec<usize>,
402    },
403    /// Not applied.
404    Ignored(IgnoreReason),
405    /// The stream ended and the node closed.
406    Ended,
407}
408
409/// Errors.
410#[derive(Clone, Debug, PartialEq, Eq)]
411pub enum ComposeError {
412    /// Builder configuration problems (all of them).
413    Config(Vec<String>),
414    /// Malformed frame set (unknown or duplicate camera, missing file, bad pixel buffer).
415    InvalidFrameSet(String),
416    /// Rejected by [`ReconcilePolicy::Strict`] (or a gap/late/correction under it).
417    Rejected(String),
418    /// File I/O while spooling.
419    Io(String),
420    /// The reconstruction session stopped on a fatal error.
421    Session(String),
422    /// No node with this id on the host.
423    UnknownNode(String),
424    /// A node with this id already exists on the host.
425    DuplicateNode(String),
426    /// The node was already closed and its summary taken.
427    Closed,
428}
429
430impl fmt::Display for ComposeError {
431    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
432        match self {
433            ComposeError::Config(v) => write!(f, "invalid node configuration: {}", v.join("; ")),
434            ComposeError::InvalidFrameSet(m) => write!(f, "invalid frame set: {m}"),
435            ComposeError::Rejected(m) => write!(f, "rejected: {m}"),
436            ComposeError::Io(m) => write!(f, "i/o: {m}"),
437            ComposeError::Session(m) => write!(f, "session failed: {m}"),
438            ComposeError::UnknownNode(id) => write!(f, "no node {id:?} on this host"),
439            ComposeError::DuplicateNode(id) => write!(f, "node {id:?} already exists on this host"),
440            ComposeError::Closed => write!(f, "node already closed"),
441        }
442    }
443}
444
445impl std::error::Error for ComposeError {}
446
447/// Reconciliation counters.
448#[derive(Clone, Debug, Default, PartialEq, Eq)]
449pub struct ComposeStats {
450    /// Declarations received (all states).
451    pub declared: u64,
452    /// Sets ingested as new positions.
453    pub appended: u64,
454    /// Duplicate declarations (no-ops).
455    pub duplicates: u64,
456    /// Pending/incomplete declarations.
457    pub waiting: u64,
458    /// Sequences missing between accepted ones (dropped frames).
459    pub skipped_sequences: u64,
460    /// Rewind-and-replay operations.
461    pub replays: u64,
462    /// Sets pushed again by replays.
463    pub replayed_positions: u64,
464    /// Corrections applied by zone invalidation.
465    pub invalidations: u64,
466    /// Declarations ignored.
467    pub ignored: u64,
468}
469
470/// What [`ReconNode::close`] returns.
471pub struct Closed {
472    /// Final session state.
473    pub session: Session,
474    /// Pipeline summary (event counts, hook calls).
475    pub summary: Summary,
476    /// Reconciliation counters.
477    pub stats: ComposeStats,
478}
479
480impl fmt::Debug for Closed {
481    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
482        f.debug_struct("Closed")
483            .field("positions", &self.session.positions())
484            .field("summary", &self.summary)
485            .field("stats", &self.stats)
486            .finish()
487    }
488}
489
490// ============================================================================================
491// Host
492// ============================================================================================
493
494/// Owner of retained nodes, addressed by id. Cheap to clone (shared).
495#[derive(Clone, Default)]
496pub struct CompositionHost {
497    nodes: Arc<Mutex<BTreeMap<String, ReconNode>>>,
498}
499
500impl fmt::Debug for CompositionHost {
501    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
502        f.debug_struct("CompositionHost").field("nodes", &self.ids()).finish()
503    }
504}
505
506impl CompositionHost {
507    /// Empty host.
508    pub fn new() -> Self {
509        Self::default()
510    }
511    /// Node by id.
512    pub fn node(&self, id: &str) -> Option<ReconNode> {
513        lock(&self.nodes).get(id).cloned()
514    }
515    /// Ids of the open nodes.
516    pub fn ids(&self) -> Vec<String> {
517        lock(&self.nodes).keys().cloned().collect()
518    }
519    /// Declares a state on node `id` ([`ReconNode::compose`]).
520    pub fn compose(&self, id: &str, state: FrameState) -> Result<Outcome, ComposeError> {
521        self.node(id).ok_or_else(|| ComposeError::UnknownNode(id.to_string()))?.compose(state)
522    }
523    /// Closes node `id` and removes it from the host.
524    pub fn close(&self, id: &str) -> Result<Closed, ComposeError> {
525        let node = lock(&self.nodes).remove(id).ok_or_else(|| ComposeError::UnknownNode(id.to_string()))?;
526        node.close_inner()
527    }
528    /// Closes every node (id order) and removes them.
529    pub fn close_all(&self) -> Vec<(String, Result<Closed, ComposeError>)> {
530        let nodes = std::mem::take(&mut *lock(&self.nodes));
531        nodes.into_iter().map(|(id, n)| (id, n.close_inner())).collect()
532    }
533    fn register(&self, node: &ReconNode) -> Result<(), ComposeError> {
534        let mut m = lock(&self.nodes);
535        if m.contains_key(&node.id) {
536            return Err(ComposeError::DuplicateNode(node.id.clone()));
537        }
538        m.insert(node.id.clone(), node.clone());
539        Ok(())
540    }
541}
542
543/// Declares `state` on the host's node `id`: the declarative entry point
544/// (`reconstruct(&host, "main", FrameState::ready(set))` on every new input).
545pub fn reconstruct(host: &CompositionHost, id: &str, state: FrameState) -> Result<Outcome, ComposeError> {
546    host.compose(id, state)
547}
548
549// ============================================================================================
550// Builder
551// ============================================================================================
552
553type Attach = Box<dyn FnOnce(Pipeline<Session>) -> Pipeline<Session> + Send>;
554
555/// Records a node configuration; [`NodeBuilder::build`] checks it and creates the node on a host.
556///
557/// The reconstruction settings are a [`Plan`] (same fields and TOML as [`crate::declare`]); the input folder is the
558/// image root, positions/stride are not used (input is declared live), and GPS comes with each set.
559pub struct NodeBuilder {
560    id: String,
561    plan: Plan,
562    policy: ReconcilePolicy,
563    backpressure: Backpressure,
564    source: Option<String>,
565    spool: PathBuf,
566    output: Option<(PathBuf, SinkOptions)>,
567    attach: Vec<Attach>,
568    frames: bool,
569    deltas: Option<LiveOptions>,
570    /// Geodetic origin written to the live diff stream manifest (`origin_gps`).
571    stream_origin: Option<[f64; 3]>,
572    /// Only run.log, the timeline and the live diff stream are written to the output folder.
573    stream_only: bool,
574}
575
576impl fmt::Debug for NodeBuilder {
577    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
578        f.debug_struct("NodeBuilder").field("id", &self.id).field("policy", &self.policy).field("hooks", &self.attach.len()).finish()
579    }
580}
581
582impl ReconNode {
583    /// Starts a node declaration with the default plan (`aerial-formation` cameras, zones 12/2), dense off, no output files.
584    pub fn declare() -> NodeBuilder {
585        let mut plan = Plan::default();
586        plan.dense.enabled = false;
587        plan.gps = None;
588        NodeBuilder {
589            id: "default".to_string(),
590            plan,
591            policy: ReconcilePolicy::default(),
592            backpressure: Backpressure::default(),
593            source: None,
594            spool: PathBuf::from(".live"),
595            output: None,
596            attach: Vec::new(),
597            frames: false,
598            deltas: None,
599            stream_origin: None,
600            stream_only: false,
601        }
602    }
603}
604
605impl NodeBuilder {
606    /// Node id on the host (default `"default"`).
607    pub fn id(mut self, id: impl Into<String>) -> Self {
608        self.id = id.into();
609        self
610    }
611    /// Replaces the reconstruction settings with a plan (e.g. read from TOML). Input positions/stride and
612    /// the GPS file are ignored; sinks are ignored (use [`NodeBuilder::output`]).
613    pub fn plan(mut self, plan: Plan) -> Self {
614        self.plan = plan;
615        self
616    }
617    /// Image folder: file payloads are relative to it, in-memory payloads are spooled inside it.
618    pub fn images(mut self, dir: impl Into<PathBuf>) -> Self {
619        self.plan.input.images = dir.into();
620        self
621    }
622    /// Cameras of one synchronized set, in position order.
623    pub fn cameras<S: Into<String>>(mut self, cams: impl IntoIterator<Item = S>) -> Self {
624        self.plan.input.cameras = cams.into_iter().map(Into::into).collect();
625        self
626    }
627    /// Zone size and overlap in positions.
628    pub fn zones(mut self, span: usize, overlap: usize) -> Self {
629        self.plan.zones.span = span;
630        self.plan.zones.overlap = overlap;
631        self
632    }
633    /// Coordinate alignment (ENU needs GPS fixes in the sets).
634    pub fn align(mut self, frame: AlignFrame) -> Self {
635        self.plan.align.frame = frame;
636        self
637    }
638    /// Fixes the ENU origin at the first GPS fix.
639    pub fn fixed_enu_origin(mut self, on: bool) -> Self {
640        self.plan.align.fixed_origin = on;
641        self
642    }
643    /// Feature extraction settings.
644    pub fn features(mut self, f: Features) -> Self {
645        self.plan.features = f;
646        self
647    }
648    /// Descriptor matching backend (`cpu` | `cuda`).
649    pub fn match_backend(mut self, name: &str) -> Self {
650        self.plan.matching.backend = name.to_string();
651        self
652    }
653    /// CUDA for features, matching, the sparse stages, densification, undistortion and bundle adjustment.
654    pub fn gpu(mut self) -> Self {
655        self.plan.features.backend = "cuda".into();
656        self.plan.matching.backend = "cuda".into();
657        self.plan.sparse.backend = "cuda".into();
658        self.plan.dense.backend = "cuda".into();
659        self.plan.sparse.ba_backend = "cuda".into();
660        self.plan.dense.undistort_backend = "cuda".into();
661        self
662    }
663    /// Stable display (see [`SessionConfig::stable_display`]): intrinsics are settled then frozen, later zones are anchored to the
664    /// shown ones with shared 3D points, and shown zones are re-densified in place (`ZoneRefined` of an earlier zone again), so
665    /// the displayed cloud only gets refined and never jumps. Settle parameters come from the plan (`sparse.settle_tol`,
666    /// `sparse.settle_max_zone`). Combine with [`NodeBuilder::fixed_enu_origin`]`(true)` for a display in a fixed ENU frame.
667    pub fn stable_display(mut self, on: bool) -> Self {
668        self.plan.sparse.stable_display = on;
669        self
670    }
671    /// Computes the in-memory display updates in the library: after every dense zone result a point-keyed
672    /// [`Event::DisplayDelta`] is delivered to subscribers and hooks (`EventKind::DisplayDelta`). Thinning (`stride`), overlap
673    /// voxel ownership and zone composition follow [`crate::post::LiveDisplay`]; `relocate` is decided by the node (on only
674    /// when previews exist). Needs dense clouds with point keys (`.dense(..)`, which the session always keys). The composition runs on
675    /// the composing thread right after the causing event; the ENU origin reported in the delta frame is the first GPS fix
676    /// declared.
677    pub fn display_deltas(mut self, opts: LiveOptions) -> Self {
678        self.deltas = Some(opts);
679        self
680    }
681    /// Triangulate only newly registered images.
682    pub fn incremental_triangulation(mut self, on: bool) -> Self {
683        self.plan.sparse.incremental_triangulation = on;
684        self
685    }
686    /// Stable display settling: intrinsics change tolerance and the last zone that may still settle
687    /// (see [`SessionConfig::settle_tol`], [`SessionConfig::settle_max_zone`]).
688    pub fn settle(mut self, tol: f64, max_zone: usize) -> Self {
689        self.plan.sparse.settle_tol = tol;
690        self.plan.sparse.settle_max_zone = max_zone;
691        self
692    }
693    /// Live display thinning of the live diff stream: keep 1 of `stride` points by key hash (1 = keep all; default 6).
694    pub fn live_stride(mut self, stride: usize) -> Self {
695        self.plan.sinks.live_stride = stride;
696        self
697    }
698    /// Geodetic origin `[lat, lon, alt]` written to the live diff stream manifest. Give the first GPS fix the node will see
699    /// (with [`NodeBuilder::fixed_enu_origin`] it is the origin of every zone); without it the manifest frame is `display`.
700    pub fn stream_origin(mut self, lat: f64, lon: f64, alt: f64) -> Self {
701        self.stream_origin = Some([lat, lon, alt]);
702        self
703    }
704    /// With [`NodeBuilder::stable_display`] and [`NodeBuilder::output`]: write only run.log, the timeline and the live diff
705    /// stream, not the zone PLYs, the display PLYs, the per-zone models or the post-processing snapshots (default off).
706    pub fn stream_only(mut self, on: bool) -> Self {
707        self.stream_only = on;
708        self
709    }
710    /// Densification settings (enables it).
711    pub fn dense(mut self, d: Dense) -> Self {
712        self.plan.dense = Dense { enabled: true, ..d };
713        self
714    }
715    /// No densification (default).
716    pub fn no_dense(mut self) -> Self {
717        self.plan.dense.enabled = false;
718        self
719    }
720    /// RANSAC seed.
721    pub fn seed(mut self, seed: u64) -> Self {
722        self.plan.seed = Some(seed);
723        self
724    }
725    /// Reconciliation policy (default [`ReconcilePolicy::AppendOnly`]).
726    pub fn reconcile(mut self, p: ReconcilePolicy) -> Self {
727        self.policy = p;
728        self
729    }
730    /// Producer queue policy for [`ReconNode::spawn`] (default `Block { max_pending: 8 }`).
731    pub fn backpressure(mut self, b: Backpressure) -> Self {
732        self.backpressure = b;
733        self
734    }
735    /// Accept only this source id (default: the first declared source).
736    pub fn source(mut self, id: impl Into<String>) -> Self {
737        self.source = Some(id.into());
738        self
739    }
740    /// Spool folder for in-memory payloads, relative to the image folder (default `.live`).
741    pub fn spool(mut self, rel: impl Into<PathBuf>) -> Self {
742        self.spool = rel.into();
743        self
744    }
745    /// Writes the standard output files (timeline, run.log, zone PLYs, snapshots) to `dir`. With
746    /// [`NodeBuilder::stable_display`] also the live diff stream (`live/stream/`).
747    pub fn output(mut self, dir: impl Into<PathBuf>, opts: SinkOptions) -> Self {
748        self.output = Some((dir.into(), opts));
749        self
750    }
751    fn hook(mut self, f: impl FnOnce(Pipeline<Session>) -> Pipeline<Session> + Send + 'static) -> Self {
752        self.attach.push(Box::new(f));
753        self
754    }
755    /// Hook for one event kind ([`Pipeline::on`]). `FrameDecoded` turns frame decoding on.
756    pub fn on<F>(mut self, kind: EventKind, f: F) -> Self
757    where
758        F: FnMut(&Event) + Send + 'static,
759    {
760        self.frames |= kind == EventKind::FrameDecoded;
761        self.hook(move |p| p.on(kind, f))
762    }
763    /// Hook for every event ([`Pipeline::on_any`]).
764    pub fn on_event<F>(self, f: F) -> Self
765    where
766        F: FnMut(&Event) + Send + 'static,
767    {
768        self.hook(move |p| p.on_any(f))
769    }
770    /// Decoded input frames ([`Pipeline::on_frame`]); turns frame decoding on.
771    pub fn on_frame<F>(mut self, f: F) -> Self
772    where
773        F: FnMut(&Arc<Frame>) + Send + 'static,
774    {
775        self.frames = true;
776        self.hook(move |p| p.on_frame(f))
777    }
778    /// Zone preview clouds ([`Pipeline::on_zone_preview`]).
779    pub fn on_zone_preview<F>(self, f: F) -> Self
780    where
781        F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
782    {
783        self.hook(move |p| p.on_zone_preview(f))
784    }
785    /// Zone refined clouds ([`Pipeline::on_zone_refined`]).
786    pub fn on_zone_refined<F>(self, f: F) -> Self
787    where
788        F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
789    {
790        self.hook(move |p| p.on_zone_refined(f))
791    }
792    /// Warnings and errors ([`Pipeline::on_message`]); includes reconciliation warnings.
793    pub fn on_message<F>(self, f: F) -> Self
794    where
795        F: FnMut(EventKind, &str) + Send + 'static,
796    {
797        self.hook(move |p| p.on_message(f))
798    }
799    /// Per-kind hook queue policy ([`Pipeline::policy`]), e.g. `LatestPerKey` for `ZonePreview`.
800    pub fn event_policy(self, kind: EventKind, p: QueuePolicy) -> Self {
801        self.hook(move |pl| pl.policy(kind, p))
802    }
803    /// Runs hooks synchronously on the composing thread ([`Pipeline::sync`]).
804    pub fn sync_hooks(self, on: bool) -> Self {
805        self.hook(move |pl| pl.sync(on))
806    }
807
808    /// Checks the configuration, creates the session and pipeline, and registers the node on `host`.
809    /// Creates the image folder's spool folder; nothing else is written until frames are declared.
810    pub fn build(self, host: &CompositionHost) -> Result<ReconNode, ComposeError> {
811        let NodeBuilder { id, plan, policy, backpressure, source, spool, output, attach, frames, deltas, stream_origin, stream_only } =
812            self;
813        let mut pr = Vec::new();
814        let cams = &plan.input.cameras;
815        if cams.is_empty() {
816            pr.push("cameras: empty".to_string());
817        }
818        let mut seen = std::collections::BTreeSet::new();
819        for c in cams {
820            if c.is_empty() || c.contains(['/', '\\']) {
821                pr.push(format!("cameras: invalid name {c:?}"));
822            } else if !seen.insert(c.as_str()) {
823                pr.push(format!("cameras: duplicate {c}"));
824            }
825        }
826        let z = plan.zones;
827        if z.span == 0 {
828            pr.push("zones.span: must be at least 1".into());
829        } else if z.overlap >= z.span {
830            pr.push(format!("zones.overlap: must be smaller than the span ({}), got {}", z.span, z.overlap));
831        }
832        if !plan.input.images.is_dir() {
833            pr.push(format!("images: folder not found: {}", plan.input.images.display()));
834        }
835        if spool.is_absolute() || spool.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
836            pr.push(format!("spool: must be a relative path inside the image folder, got {}", spool.display()));
837        }
838        if plan.sinks.live_stride == 0 {
839            pr.push("live_stride: must be at least 1 (1 = keep all)".into());
840        }
841        if plan.sparse.settle_tol.is_nan() || plan.sparse.settle_tol < 0.0 {
842            pr.push(format!("settle tolerance: must be >= 0, got {}", plan.sparse.settle_tol));
843        }
844        if let ReconcilePolicy::ReplayWindow { positions: 0 } = policy {
845            pr.push("reconcile: replay window must be at least 1 position".into());
846        }
847        match backpressure {
848            Backpressure::Block { max_pending: 0 } | Backpressure::KeepLatest { max_pending: 0 } => {
849                pr.push("backpressure: max_pending must be at least 1".into())
850            }
851            _ => {}
852        }
853        let sift = make_sift_backend(&plan.features.backend).map_err(|e| pr.push(format!("features.backend: {e}"))).ok();
854        let matcher = make_match_backend(&plan.matching.backend).map_err(|e| pr.push(format!("matching.backend: {e}"))).ok();
855        let ba_backend = make_ba_backend(&plan.sparse.ba_backend).map_err(|e| pr.push(format!("sparse.ba_backend: {e}"))).ok();
856        let sparse = make_sparse_backend(&plan.sparse.backend).map_err(|e| pr.push(format!("sparse.backend: {e}"))).ok();
857        if plan.dense.enabled {
858            if let Err(e) = parse_profile(&plan.dense.profile) {
859                pr.push(format!("dense.profile: {e}"));
860            }
861        }
862        let dense = if plan.dense.enabled { plan.dense.config().map_err(|e| pr.push(format!("dense: {e}"))).ok() } else { None };
863        if !pr.is_empty() {
864            return Err(ComposeError::Config(pr));
865        }
866        if host.node(&id).is_some() {
867            return Err(ComposeError::DuplicateNode(id));
868        }
869
870        let mut sc = SessionConfig::new(plan.input.images.clone());
871        sc.span = z.span;
872        sc.overlap = z.overlap;
873        sc.total_positions = None;
874        sc.incremental_triangulation = plan.sparse.incremental_triangulation;
875        sc.fixed_enu_origin = plan.align.fixed_origin;
876        sc.seed = plan.seed;
877        sc.extraction.sift.max_num_features = plan.features.max_num_features;
878        sc.extraction.reader.max_image_size = plan.features.max_image_size;
879        sc.sift = sift.expect("checked");
880        sc.matcher = Arc::from(matcher.expect("checked"));
881        sc.ba_backend = ba_backend.expect("checked");
882        sc.sparse = sparse.expect("checked");
883        sc.dense = dense;
884        sc.history = match policy {
885            ReconcilePolicy::ReplayWindow { positions } => positions + 1,
886            _ => 0,
887        };
888        sc.decode_frames = frames;
889        sc.stable_display = plan.sparse.stable_display;
890        sc.settle_tol = plan.sparse.settle_tol;
891        sc.settle_max_zone = plan.sparse.settle_max_zone;
892        if deltas.as_ref().is_some_and(|o| o.stride == 0) {
893            return Err(ComposeError::Config(vec!["display_deltas: stride must be at least 1".into()]));
894        }
895
896        let origin = Arc::new(Mutex::new(None));
897        let mut pl = Pipeline::new(Session::new(sc));
898        if let Some(opts) = deltas {
899            let mut feed = DisplayFeed::new(opts).with_origin(Arc::clone(&origin));
900            pl = pl.derive(move |e| feed.handle(e).into_iter().collect());
901        }
902        if let Some((dir, opts)) = &output {
903            let io = |e: std::io::Error| ComposeError::Io(format!("{}: {e}", dir.display()));
904            pl = if plan.sparse.stable_display {
905                // Same files as `sinks::attach`, plus the live diff stream (stable display only, as in `Recon::run`).
906                let set = SinkSet {
907                    models: opts.save_models && !stream_only,
908                    zone_ply: !stream_only,
909                    live_files: !stream_only,
910                    live_stride: plan.sinks.live_stride,
911                    live_stream: Some(LiveStream { enu: stream_origin.is_some(), origin_gps: stream_origin }),
912                    ..SinkSet::all()
913                };
914                pl.on_any(DefaultSinks::with_set(dir, opts.echo, set).map_err(io)?.into_hook())
915            } else {
916                sinks::attach(pl, dir, opts).map_err(io)?
917            };
918        }
919        for a in attach {
920            pl = a(pl);
921        }
922        let core = Core {
923            cameras: plan.input.cameras.clone(),
924            image_root: plan.input.images.clone(),
925            spool,
926            policy,
927            source,
928            accepted: BTreeMap::new(),
929            order: Vec::new(),
930            recent: VecDeque::new(),
931            waiting: BTreeMap::new(),
932            stats: ComposeStats::default(),
933            pipeline: Some(pl),
934            ended: None,
935            origin,
936        };
937        let node = ReconNode { id, backpressure, core: Arc::new(Mutex::new(core)) };
938        host.register(&node)?;
939        Ok(node)
940    }
941}
942
943// ============================================================================================
944// Node
945// ============================================================================================
946
947/// A retained reconstruction node: session, pipeline, accepted-frame journal and lifecycle.
948/// Cheap to clone (shared); all clones address the same node.
949#[derive(Clone)]
950pub struct ReconNode {
951    id: String,
952    backpressure: Backpressure,
953    core: Arc<Mutex<Core>>,
954}
955
956impl fmt::Debug for ReconNode {
957    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
958        f.debug_struct("ReconNode").field("id", &self.id).finish()
959    }
960}
961
962#[derive(Clone, Copy, Debug)]
963struct Accepted {
964    revision: u32,
965    position: usize,
966}
967
968struct Core {
969    cameras: Vec<String>,
970    image_root: PathBuf,
971    spool: PathBuf,
972    policy: ReconcilePolicy,
973    source: Option<String>,
974    /// sequence -> accepted revision and position.
975    accepted: BTreeMap<u64, Accepted>,
976    /// sequence at each position (increasing).
977    order: Vec<u64>,
978    /// Resolved sets of the last positions (replay buffer), aligned to the end of `order`.
979    recent: VecDeque<FrameSet>,
980    /// Sets being assembled: sequence -> missing cameras.
981    waiting: BTreeMap<u64, Vec<String>>,
982    stats: ComposeStats,
983    pipeline: Option<Pipeline<Session>>,
984    /// Summary of a node closed by `FrameState::End`, until `close` takes it.
985    ended: Option<Closed>,
986    /// First GPS fix declared `[lat, lon, alt]` (ENU origin reported by display deltas).
987    origin: Arc<Mutex<Option<[f64; 3]>>>,
988}
989
990impl ReconNode {
991    /// Node id on its host.
992    pub fn id(&self) -> &str {
993        &self.id
994    }
995
996    /// Declares the current input state and reconciles it (see the module table).
997    pub fn compose(&self, state: FrameState) -> Result<Outcome, ComposeError> {
998        lock(&self.core).compose(state)
999    }
1000
1001    /// Declares several states in order, stopping at the first error.
1002    pub fn compose_all(&self, states: impl IntoIterator<Item = FrameState>) -> Result<Vec<Outcome>, ComposeError> {
1003        states.into_iter().map(|s| self.compose(s)).collect()
1004    }
1005
1006    /// Collects finished background (refinement) events without new input.
1007    pub fn poll(&self) -> usize {
1008        lock(&self.core).pipeline.as_mut().map_or(0, |p| p.poll())
1009    }
1010
1011    /// Channel receiving every event of this node (see [`Pipeline::subscribe`]). `None` after close.
1012    pub fn subscribe(&self) -> Option<Receiver<Event>> {
1013        lock(&self.core).pipeline.as_ref().map(|p| p.subscribe())
1014    }
1015
1016    /// Reconciliation counters so far.
1017    pub fn stats(&self) -> ComposeStats {
1018        lock(&self.core).stats.clone()
1019    }
1020
1021    /// Positions ingested so far.
1022    pub fn positions(&self) -> usize {
1023        lock(&self.core).order.len()
1024    }
1025
1026    /// Accepted (sequence, revision) per position.
1027    pub fn journal(&self) -> Vec<(u64, u32)> {
1028        let c = lock(&self.core);
1029        c.order.iter().map(|s| (*s, c.accepted[s].revision)).collect()
1030    }
1031
1032    /// Sets currently waiting for cameras: (sequence, missing cameras).
1033    pub fn waiting(&self) -> Vec<(u64, Vec<String>)> {
1034        lock(&self.core).waiting.iter().map(|(k, v)| (*k, v.clone())).collect()
1035    }
1036
1037    /// Read access to the session (e.g. arrived zones, models).
1038    pub fn with_session<T>(&self, f: impl FnOnce(&Session) -> T) -> Option<T> {
1039        lock(&self.core).pipeline.as_ref().map(|p| f(p.reducer()))
1040    }
1041
1042    /// Closes the node: closes open zones, waits for background refinement and emits the final events.
1043    /// The node stays listed on its host; [`CompositionHost::close`] also removes it.
1044    pub fn close(&self) -> Result<Closed, ComposeError> {
1045        self.close_inner()
1046    }
1047
1048    fn close_inner(&self) -> Result<Closed, ComposeError> {
1049        let mut c = lock(&self.core);
1050        if let Some(done) = c.ended.take() {
1051            return Ok(done);
1052        }
1053        c.finish().ok_or(ComposeError::Closed)
1054    }
1055
1056    /// Moves the node onto its own thread behind a queue with the builder's [`Backpressure`] policy.
1057    /// The node stays registered on its host; composing on it directly while spawned is serialized with the worker.
1058    pub fn spawn(&self) -> ComposeHandle {
1059        let queue = Arc::new(Queue { st: Mutex::new(QueueState::default()), cv: Condvar::new() });
1060        let (q, node) = (Arc::clone(&queue), self.clone());
1061        let worker = std::thread::Builder::new()
1062            .name(format!("compose-{}", self.id))
1063            .spawn(move || {
1064                let mut outcomes = Vec::new();
1065                loop {
1066                    let next = {
1067                        let mut st = lock(&q.st);
1068                        loop {
1069                            if let Some(s) = st.items.pop_front() {
1070                                q.cv.notify_all();
1071                                break Some(s);
1072                            }
1073                            if st.closed {
1074                                break None;
1075                            }
1076                            st = q.cv.wait(st).unwrap_or_else(|p| p.into_inner());
1077                        }
1078                    };
1079                    let Some(state) = next else { break };
1080                    let end = matches!(state, FrameState::End);
1081                    let r = node.compose(state);
1082                    lock(&q.st).processed += 1;
1083                    let fatal = matches!(r, Err(ComposeError::Session(_)));
1084                    outcomes.push(r);
1085                    if end || fatal {
1086                        break;
1087                    }
1088                }
1089                let mut st = lock(&q.st);
1090                st.closed = true;
1091                st.items.clear();
1092                q.cv.notify_all();
1093                outcomes
1094            })
1095            .expect("compose worker thread");
1096        ComposeHandle { node: self.clone(), queue, policy: self.backpressure, worker: Some(worker) }
1097    }
1098}
1099
1100impl Core {
1101    fn pl(&mut self) -> &mut Pipeline<Session> {
1102        self.pipeline.as_mut().expect("open pipeline")
1103    }
1104
1105    fn warn(&mut self, message: String) {
1106        if let Some(p) = self.pipeline.as_mut() {
1107            p.reducer().emit_external(|meta| Event::Warning { meta, message });
1108            p.poll();
1109        }
1110    }
1111
1112    fn ignore(&mut self, key: &FrameKey, reason: IgnoreReason) -> Result<Outcome, ComposeError> {
1113        if self.policy == ReconcilePolicy::Strict && reason != IgnoreReason::Closed {
1114            return Err(ComposeError::Rejected(format!("{key}: {reason}")));
1115        }
1116        self.stats.ignored += 1;
1117        self.warn(format!("compose: {key} ignored: {reason}"));
1118        Ok(Outcome::Ignored(reason))
1119    }
1120
1121    fn compose(&mut self, state: FrameState) -> Result<Outcome, ComposeError> {
1122        self.stats.declared += 1;
1123        if self.pipeline.is_none() {
1124            return match state {
1125                FrameState::End => Ok(Outcome::Ended),
1126                _ => Ok(Outcome::Ignored(IgnoreReason::Closed)),
1127            };
1128        }
1129        match state {
1130            FrameState::End => {
1131                let done = self.finish().ok_or(ComposeError::Closed)?;
1132                self.ended = Some(done);
1133                Ok(Outcome::Ended)
1134            }
1135            FrameState::Pending { key, missing_cameras, .. } => {
1136                if let Some(r) = self.check_source(&key) {
1137                    return r;
1138                }
1139                self.stats.waiting += 1;
1140                if !self.accepted.contains_key(&key.sequence) {
1141                    self.waiting.insert(key.sequence, missing_cameras.clone());
1142                }
1143                Ok(Outcome::Waiting { missing: missing_cameras })
1144            }
1145            FrameState::Ready(set) => self.ready(set),
1146        }
1147    }
1148
1149    fn check_source(&mut self, key: &FrameKey) -> Option<Result<Outcome, ComposeError>> {
1150        match &self.source {
1151            None => {
1152                self.source = Some(key.source_id.clone());
1153                None
1154            }
1155            Some(s) if *s == key.source_id => None,
1156            Some(s) => {
1157                let expected = s.clone();
1158                Some(self.ignore(key, IgnoreReason::ForeignSource { expected }))
1159            }
1160        }
1161    }
1162
1163    fn ready(&mut self, set: LiveFrameSet) -> Result<Outcome, ComposeError> {
1164        let key = set.key.clone();
1165        // Shape checks first: a malformed set is an error under every policy.
1166        let mut by_cam: HashMap<&str, &LiveImage> = HashMap::new();
1167        for im in &set.images {
1168            if !self.cameras.contains(&im.camera) {
1169                return Err(ComposeError::InvalidFrameSet(format!("{key}: unknown camera {}", im.camera)));
1170            }
1171            if by_cam.insert(im.camera.as_str(), im).is_some() {
1172                return Err(ComposeError::InvalidFrameSet(format!("{key}: camera {} given twice", im.camera)));
1173            }
1174        }
1175        if let Some(r) = self.check_source(&key) {
1176            return r;
1177        }
1178        let missing: Vec<String> = self.cameras.iter().filter(|c| !by_cam.contains_key(c.as_str())).cloned().collect();
1179        if !missing.is_empty() && !self.accepted.contains_key(&key.sequence) {
1180            self.stats.waiting += 1;
1181            self.waiting.insert(key.sequence, missing.clone());
1182            return Ok(Outcome::Waiting { missing });
1183        }
1184
1185        if let Some(acc) = self.accepted.get(&key.sequence).copied() {
1186            if key.revision == acc.revision {
1187                self.stats.duplicates += 1;
1188                return Ok(Outcome::Duplicate { position: acc.position });
1189            }
1190            if key.revision < acc.revision {
1191                return self.ignore(&key, IgnoreReason::StaleRevision { accepted: acc.revision });
1192            }
1193            if !missing.is_empty() {
1194                return Err(ComposeError::InvalidFrameSet(format!("{key}: correction misses cameras {}", missing.join(", "))));
1195            }
1196            return self.correct(set, acc);
1197        }
1198
1199        let newest = self.order.last().copied();
1200        match newest {
1201            Some(n) if key.sequence < n => self.late(set),
1202            _ => {
1203                let skipped = newest.map_or(0, |n| key.sequence - n - 1);
1204                if skipped > 0 && self.policy == ReconcilePolicy::Strict {
1205                    return Err(ComposeError::Rejected(format!("{key}: {skipped} sequence(s) missing before it")));
1206                }
1207                let fs = self.resolve(&set, None)?;
1208                self.waiting.remove(&key.sequence);
1209                let position = self.order.len();
1210                self.push(fs)?;
1211                self.order.push(key.sequence);
1212                self.accepted.insert(key.sequence, Accepted { revision: key.revision, position });
1213                self.stats.appended += 1;
1214                if skipped > 0 {
1215                    self.stats.skipped_sequences += skipped;
1216                    self.warn(format!("compose: {key} appended at position {position} after {skipped} missing sequence(s)"));
1217                }
1218                Ok(Outcome::Appended { position, skipped })
1219            }
1220        }
1221    }
1222
1223    /// Late arrival of an unseen older sequence.
1224    fn late(&mut self, set: LiveFrameSet) -> Result<Outcome, ComposeError> {
1225        let key = set.key.clone();
1226        let ReconcilePolicy::ReplayWindow { positions: w } = self.policy else {
1227            return self.ignore(&key, IgnoreReason::Late);
1228        };
1229        let p = self.order.partition_point(|&s| s < key.sequence);
1230        if !self.in_window(p, w) {
1231            return self.ignore(&key, IgnoreReason::OutsideReplayWindow { position: p });
1232        }
1233        let fs = self.resolve(&set, None)?;
1234        let tail_sets: Vec<FrameSet> = self.recent.iter().skip(self.recent.len() - (self.order.len() - p)).cloned().collect();
1235        let mut tail = vec![fs];
1236        tail.extend(tail_sets);
1237        let mut seqs = vec![key.sequence];
1238        seqs.extend(self.order[p..].iter().copied());
1239        self.waiting.remove(&key.sequence);
1240        let n = self.replay(p, tail, &seqs, (key.sequence, key.revision))?;
1241        self.warn(format!("compose: late {key} inserted at position {p}, replayed {n} position(s)"));
1242        Ok(Outcome::Replayed { from: p, positions: n })
1243    }
1244
1245    /// Higher revision of an accepted sequence.
1246    fn correct(&mut self, set: LiveFrameSet, acc: Accepted) -> Result<Outcome, ComposeError> {
1247        let key = set.key.clone();
1248        let p = acc.position;
1249        match self.policy {
1250            ReconcilePolicy::AppendOnly | ReconcilePolicy::Strict => self.ignore(&key, IgnoreReason::CorrectionNotApplied),
1251            ReconcilePolicy::ReplayWindow { positions: w } => {
1252                if !self.in_window(p, w) {
1253                    return self.ignore(&key, IgnoreReason::OutsideReplayWindow { position: p });
1254                }
1255                let fs = self.resolve(&set, None)?;
1256                let start = self.recent.len() - (self.order.len() - p);
1257                let mut tail: Vec<FrameSet> = self.recent.iter().skip(start).cloned().collect();
1258                tail[0] = fs;
1259                let seqs: Vec<u64> = self.order[p..].to_vec();
1260                let n = self.replay(p, tail, &seqs, (key.sequence, key.revision))?;
1261                self.warn(format!("compose: correction {key} applied from position {p}, replayed {n} position(s)"));
1262                Ok(Outcome::Replayed { from: p, positions: n })
1263            }
1264            ReconcilePolicy::InvalidateAffectedZones => {
1265                // Store the corrected pixels under the original names so rebuilt outputs read them.
1266                let names: Vec<String> = self.pl().reducer().frames()[p].iter().map(|f| f.name.clone()).collect();
1267                self.resolve(&set, Some(&names))?;
1268                let zones: Vec<usize> =
1269                    self.pl().reducer().arrived_zones().into_iter().filter(|z| z.lo <= p && p < z.hi).map(|z| z.zone).collect();
1270                for &z in &zones {
1271                    self.pl().command(Command::InvalidateZone(z));
1272                }
1273                self.fail_check()?;
1274                self.accepted.insert(key.sequence, Accepted { revision: key.revision, position: p });
1275                self.stats.invalidations += 1;
1276                Ok(Outcome::Invalidated { position: p, zones })
1277            }
1278        }
1279    }
1280
1281    /// Whether position `p` can be rewound to with window `w`.
1282    fn in_window(&self, p: usize, w: usize) -> bool {
1283        let npos = self.order.len();
1284        npos - p <= w && npos - p <= self.recent.len()
1285    }
1286
1287    /// Rewinds to `p` and pushes `tail` (sequences `seqs`), updating the journal and the replay buffer.
1288    /// `changed` = the (sequence, revision) that differs from the journal (corrected or inserted set).
1289    fn replay(&mut self, p: usize, tail: Vec<FrameSet>, seqs: &[u64], changed: (u64, u32)) -> Result<usize, ComposeError> {
1290        let npos = self.order.len();
1291        self.pl().command(Command::ResetFrom(p));
1292        if self.pl().reducer().positions() != p {
1293            return Err(ComposeError::Session(format!("rewind to position {p} was refused by the session")));
1294        }
1295        let revs: HashMap<u64, u32> = self.order[p..].iter().map(|s| (*s, self.accepted[s].revision)).collect();
1296        for s in &self.order[p..] {
1297            self.accepted.remove(s);
1298        }
1299        self.order.truncate(p);
1300        let keep = self.recent.len() - (npos - p);
1301        self.recent.truncate(keep);
1302        let n = tail.len();
1303        for (fs, &seq) in tail.into_iter().zip(seqs) {
1304            let revision = if seq == changed.0 { changed.1 } else { revs[&seq] };
1305            let position = self.order.len();
1306            self.push(fs)?;
1307            self.order.push(seq);
1308            self.accepted.insert(seq, Accepted { revision, position });
1309        }
1310        self.stats.replays += 1;
1311        self.stats.replayed_positions += n as u64;
1312        Ok(n)
1313    }
1314
1315    /// Pushes one resolved set and keeps it in the replay buffer.
1316    fn push(&mut self, fs: FrameSet) -> Result<(), ComposeError> {
1317        let cap = match self.policy {
1318            ReconcilePolicy::ReplayWindow { positions } => positions,
1319            _ => 0,
1320        };
1321        if cap > 0 {
1322            self.recent.push_back(fs.clone());
1323            while self.recent.len() > cap {
1324                self.recent.pop_front();
1325            }
1326        }
1327        if let Some(g) = fs.gps.first() {
1328            lock(&self.origin).get_or_insert([g.lat, g.lon, g.alt]);
1329        }
1330        self.pl().push(Input::Frames(fs));
1331        self.fail_check()
1332    }
1333
1334    fn fail_check(&mut self) -> Result<(), ComposeError> {
1335        match self.pl().reducer().failed() {
1336            Some(e) => Err(ComposeError::Session(e.to_string())),
1337            None => Ok(()),
1338        }
1339    }
1340
1341    /// Materializes payloads (spooling in-memory ones) and builds the session's frame set in camera order.
1342    /// `names` forces the stored names (corrections under `InvalidateAffectedZones`).
1343    fn resolve(&self, set: &LiveFrameSet, names: Option<&[String]>) -> Result<FrameSet, ComposeError> {
1344        let key = &set.key;
1345        let mut fs = FrameSet::default();
1346        for (i, cam) in self.cameras.iter().enumerate() {
1347            let im = set.images.iter().find(|x| &x.camera == cam).expect("complete set");
1348            let forced = names.map(|n| n[i].clone());
1349            let name = match &im.payload {
1350                FramePayload::File { relative_name } => {
1351                    let src = self.image_root.join(relative_name);
1352                    if !src.is_file() {
1353                        return Err(ComposeError::InvalidFrameSet(format!("{key}: file not found: {}", src.display())));
1354                    }
1355                    match forced {
1356                        Some(n) if n != *relative_name => {
1357                            let bytes = std::fs::read(&src).map_err(|e| ComposeError::Io(format!("{}: {e}", src.display())))?;
1358                            self.store_encoded(&n, &bytes, ext_of(relative_name))?;
1359                            n
1360                        }
1361                        _ => relative_name.clone(),
1362                    }
1363                }
1364                FramePayload::Rgb8 { width, height, pixels } => {
1365                    if pixels.len() != *width as usize * *height as usize * 3 || *width == 0 || *height == 0 {
1366                        return Err(ComposeError::InvalidFrameSet(format!(
1367                            "{key}: camera {cam}: {} bytes for {width}x{height} RGB8",
1368                            pixels.len()
1369                        )));
1370                    }
1371                    let n = forced.unwrap_or_else(|| self.spool_name(key, cam, "png"));
1372                    let img = image::RgbImage::from_raw(*width, *height, pixels.to_vec()).expect("size checked");
1373                    self.store_image(&n, &image::DynamicImage::ImageRgb8(img))?;
1374                    n
1375                }
1376                FramePayload::Encoded { bytes, format } => {
1377                    let n = forced.unwrap_or_else(|| self.spool_name(key, cam, format.extension()));
1378                    self.store_encoded(&n, bytes, Some(format.extension()))?;
1379                    n
1380                }
1381            };
1382            fs.images.push(crate::session::FrameImage { camera: cam.clone(), name });
1383        }
1384        for g in &set.gps {
1385            let mut g = g.clone();
1386            if let Some(i) = self.cameras.iter().position(|c| *c == g.name) {
1387                g.name = fs.images[i].name.clone();
1388            }
1389            fs.gps.push(g);
1390        }
1391        Ok(fs)
1392    }
1393
1394    fn spool_name(&self, key: &FrameKey, cam: &str, ext: &str) -> String {
1395        let src: String = key.source_id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
1396        let p = self.spool.join(cam).join(format!("{src}_{:010}.{ext}", key.sequence));
1397        p.to_string_lossy().replace('\\', "/")
1398    }
1399
1400    /// Writes encoded bytes to `name`; re-encodes when the target extension differs from the bytes' format.
1401    fn store_encoded(&self, name: &str, bytes: &[u8], ext: Option<&str>) -> Result<(), ComposeError> {
1402        if ext.is_some() && ext == ext_of(name) {
1403            return self.atomic_write(name, |path| std::fs::write(path, bytes).map_err(|e| e.to_string()));
1404        }
1405        let img = image::load_from_memory(bytes).map_err(|e| ComposeError::InvalidFrameSet(format!("{name}: {e}")))?;
1406        self.store_image(name, &img)
1407    }
1408
1409    fn store_image(&self, name: &str, img: &image::DynamicImage) -> Result<(), ComposeError> {
1410        let fmt = image::ImageFormat::from_path(name).map_err(|e| ComposeError::Io(format!("{name}: {e}")))?;
1411        self.atomic_write(name, |path| img.save_with_format(path, fmt).map_err(|e| e.to_string()))
1412    }
1413
1414    /// Writes through a temporary file in the same folder and renames, so readers never see a partial image.
1415    fn atomic_write(&self, name: &str, write: impl FnOnce(&Path) -> Result<(), String>) -> Result<(), ComposeError> {
1416        let dst = self.image_root.join(name);
1417        let dir = dst.parent().unwrap_or(&self.image_root);
1418        std::fs::create_dir_all(dir).map_err(|e| ComposeError::Io(format!("{}: {e}", dir.display())))?;
1419        let file = dst.file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
1420        let ext = ext_of(name).unwrap_or("tmp");
1421        let tmp = dir.join(format!(".{file}.partial.{ext}"));
1422        write(&tmp).map_err(|e| ComposeError::Io(format!("{}: {e}", tmp.display())))?;
1423        std::fs::rename(&tmp, &dst).map_err(|e| ComposeError::Io(format!("{}: {e}", dst.display())))
1424    }
1425
1426    fn finish(&mut self) -> Option<Closed> {
1427        let pl = self.pipeline.take()?;
1428        let (session, summary) = pl.finish();
1429        Some(Closed { session, summary, stats: self.stats.clone() })
1430    }
1431}
1432
1433fn ext_of(name: &str) -> Option<&str> {
1434    Path::new(name).extension().and_then(|e| e.to_str())
1435}
1436
1437fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
1438    m.lock().unwrap_or_else(|p| p.into_inner())
1439}
1440
1441// ============================================================================================
1442// Spawned node
1443// ============================================================================================
1444
1445#[derive(Default)]
1446struct QueueState {
1447    items: VecDeque<FrameState>,
1448    closed: bool,
1449    submitted: u64,
1450    dropped: u64,
1451    processed: u64,
1452    max_depth: usize,
1453}
1454
1455struct Queue {
1456    st: Mutex<QueueState>,
1457    cv: Condvar,
1458}
1459
1460/// Why a state could not be queued.
1461#[derive(Debug)]
1462pub enum SendError {
1463    /// `try_send` under `Block`: the queue is full. The state is handed back.
1464    Full(FrameState),
1465    /// The worker stopped (closed, or the session failed). The state is handed back.
1466    Closed(FrameState),
1467}
1468
1469impl fmt::Display for SendError {
1470    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1471        match self {
1472            SendError::Full(_) => write!(f, "compose queue full"),
1473            SendError::Closed(_) => write!(f, "compose worker stopped"),
1474        }
1475    }
1476}
1477
1478impl std::error::Error for SendError {}
1479
1480/// Queue counters of a spawned node.
1481#[derive(Clone, Debug, Default, PartialEq, Eq)]
1482pub struct QueueStats {
1483    /// States accepted into the queue.
1484    pub submitted: u64,
1485    /// Ready sets dropped by `KeepLatest`.
1486    pub dropped: u64,
1487    /// States composed by the worker.
1488    pub processed: u64,
1489    /// Current queue depth.
1490    pub depth: usize,
1491    /// Largest queue depth seen.
1492    pub max_depth: usize,
1493}
1494
1495/// Producer side of a spawned node ([`ReconNode::spawn`]).
1496pub struct ComposeHandle {
1497    node: ReconNode,
1498    queue: Arc<Queue>,
1499    policy: Backpressure,
1500    worker: Option<JoinHandle<Vec<Result<Outcome, ComposeError>>>>,
1501}
1502
1503impl fmt::Debug for ComposeHandle {
1504    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1505        f.debug_struct("ComposeHandle").field("node", &self.node.id).field("policy", &self.policy).field("stats", &self.stats()).finish()
1506    }
1507}
1508
1509impl ComposeHandle {
1510    /// The node.
1511    pub fn node(&self) -> &ReconNode {
1512        &self.node
1513    }
1514
1515    /// Queues a state. Under `Block` waits for room; never waits otherwise.
1516    pub fn send(&self, state: FrameState) -> Result<(), SendError> {
1517        self.enqueue(state, true)
1518    }
1519
1520    /// Queues a state without waiting (`Block` returns [`SendError::Full`] when full).
1521    pub fn try_send(&self, state: FrameState) -> Result<(), SendError> {
1522        self.enqueue(state, false)
1523    }
1524
1525    /// Queues every state of a source (iterator, channel receiver, ...) with [`ComposeHandle::send`].
1526    pub fn send_all(&self, states: impl IntoIterator<Item = FrameState>) -> Result<(), SendError> {
1527        states.into_iter().try_for_each(|s| self.send(s))
1528    }
1529
1530    fn enqueue(&self, state: FrameState, wait: bool) -> Result<(), SendError> {
1531        let q = &self.queue;
1532        let mut st = lock(&q.st);
1533        if st.closed {
1534            return Err(SendError::Closed(state));
1535        }
1536        // End is never dropped or refused for room.
1537        let end = matches!(state, FrameState::End);
1538        match self.policy {
1539            Backpressure::Unbounded => {}
1540            Backpressure::Block { max_pending } => {
1541                while !end && st.items.len() >= max_pending && !st.closed {
1542                    if !wait {
1543                        return Err(SendError::Full(state));
1544                    }
1545                    st = q.cv.wait(st).unwrap_or_else(|p| p.into_inner());
1546                }
1547                if st.closed {
1548                    return Err(SendError::Closed(state));
1549                }
1550            }
1551            Backpressure::KeepLatest { max_pending } => {
1552                while !end && st.items.len() >= max_pending {
1553                    match st.items.iter().position(|s| matches!(s, FrameState::Ready(_))) {
1554                        Some(i) => {
1555                            st.items.remove(i);
1556                            st.dropped += 1;
1557                        }
1558                        None => {
1559                            st.items.pop_front();
1560                        }
1561                    }
1562                }
1563            }
1564        }
1565        st.items.push_back(state);
1566        st.submitted += 1;
1567        st.max_depth = st.max_depth.max(st.items.len());
1568        q.cv.notify_all();
1569        Ok(())
1570    }
1571
1572    /// Queue counters.
1573    pub fn stats(&self) -> QueueStats {
1574        let st = lock(&self.queue.st);
1575        QueueStats { submitted: st.submitted, dropped: st.dropped, processed: st.processed, depth: st.items.len(), max_depth: st.max_depth }
1576    }
1577
1578    /// Sends `End`, waits for the worker to compose everything queued, and closes the node.
1579    /// Returns the per-state outcomes (in queue order) and the close result.
1580    pub fn close(mut self) -> (Vec<Result<Outcome, ComposeError>>, Result<Closed, ComposeError>) {
1581        let _ = self.enqueue(FrameState::End, false);
1582        let outcomes = self.worker.take().map(|w| w.join().unwrap_or_default()).unwrap_or_default();
1583        (outcomes, self.node.close_inner())
1584    }
1585}
1586
1587impl Drop for ComposeHandle {
1588    fn drop(&mut self) {
1589        if let Some(w) = self.worker.take() {
1590            {
1591                let mut st = lock(&self.queue.st);
1592                st.closed = true;
1593                self.queue.cv.notify_all();
1594            }
1595            let _ = w.join();
1596        }
1597    }
1598}
1599
1600#[cfg(test)]
1601mod tests {
1602    use super::*;
1603
1604    #[test]
1605    fn spool_names_are_sanitized_and_stable() {
1606        let c = Core {
1607            cameras: vec!["camF".into()],
1608            image_root: PathBuf::from("/tmp/x"),
1609            spool: PathBuf::from(".live"),
1610            policy: ReconcilePolicy::AppendOnly,
1611            source: None,
1612            accepted: BTreeMap::new(),
1613            order: Vec::new(),
1614            recent: VecDeque::new(),
1615            waiting: BTreeMap::new(),
1616            stats: ComposeStats::default(),
1617            pipeline: None,
1618            ended: None,
1619            origin: Arc::new(Mutex::new(None)),
1620        };
1621        let k = FrameKey::new("rtsp://drone 1", 42, 3);
1622        assert_eq!(c.spool_name(&k, "camF", "png"), ".live/camF/rtsp___drone_1_0000000042.png");
1623    }
1624
1625    #[test]
1626    fn key_display() {
1627        assert_eq!(FrameKey::new("d", 7, 1).to_string(), "d#7r1");
1628    }
1629}