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