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