use crate::ahead::Lookahead;
use crate::blocks::LiveStream;
use crate::declare::{AlignFrame, Dense, Features, Plan};
use crate::densewrap::{make_ba_backend, make_match_backend, make_sift_backend, make_sparse_backend, parse_profile};
use crate::events::{Command, Event, EventKind, Frame, ZoneRange};
use crate::intrinsics::{IntrinsicsPrior, DEFAULT_DIST_SIGMA, DEFAULT_FOCAL_SIGMA};
use crate::live::DisplayFeed;
use crate::pipeline::{Pipeline, QueuePolicy, Summary};
use crate::post::LiveOptions;
use crate::session::{FrameSet, Input, Session, SessionConfig};
use crate::sinks::{self, DefaultSinks, SinkOptions, SinkSet};
use cumulus3d_core::io::ply::PointCloud;
use cumulus3d_core::io::GpsRecord;
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use std::thread::JoinHandle;
use std::time::SystemTime;
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct FrameKey {
pub source_id: String,
pub sequence: u64,
pub revision: u32,
}
impl FrameKey {
pub fn new(source_id: impl Into<String>, sequence: u64, revision: u32) -> Self {
Self { source_id: source_id.into(), sequence, revision }
}
}
impl fmt::Display for FrameKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}#{}r{}", self.source_id, self.sequence, self.revision)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EncodedFormat {
Jpeg,
Png,
}
impl EncodedFormat {
fn extension(self) -> &'static str {
match self {
EncodedFormat::Jpeg => "jpg",
EncodedFormat::Png => "png",
}
}
}
#[derive(Clone)]
pub enum FramePayload {
File {
relative_name: String,
},
Rgb8 {
width: u32,
height: u32,
pixels: Arc<[u8]>,
},
Encoded {
bytes: Arc<[u8]>,
format: EncodedFormat,
},
}
impl fmt::Debug for FramePayload {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FramePayload::File { relative_name } => f.debug_struct("File").field("relative_name", relative_name).finish(),
FramePayload::Rgb8 { width, height, .. } => f.debug_struct("Rgb8").field("width", width).field("height", height).finish(),
FramePayload::Encoded { bytes, format } => {
f.debug_struct("Encoded").field("bytes", &bytes.len()).field("format", format).finish()
}
}
}
}
#[derive(Clone, Debug)]
pub struct LiveImage {
pub camera: String,
pub payload: FramePayload,
}
#[derive(Clone, Debug)]
pub struct LiveFrameSet {
pub key: FrameKey,
pub timestamp: SystemTime,
pub images: Vec<LiveImage>,
pub gps: Vec<GpsRecord>,
}
impl LiveFrameSet {
pub fn new(key: FrameKey) -> Self {
Self { key, timestamp: SystemTime::now(), images: Vec::new(), gps: Vec::new() }
}
pub fn image(mut self, camera: impl Into<String>, payload: FramePayload) -> Self {
self.images.push(LiveImage { camera: camera.into(), payload });
self
}
pub fn file(self, camera: impl Into<String>, relative_name: impl Into<String>) -> Self {
self.image(camera, FramePayload::File { relative_name: relative_name.into() })
}
pub fn rgb8(self, camera: impl Into<String>, width: u32, height: u32, pixels: impl Into<Arc<[u8]>>) -> Self {
self.image(camera, FramePayload::Rgb8 { width, height, pixels: pixels.into() })
}
pub fn encoded(self, camera: impl Into<String>, bytes: impl Into<Arc<[u8]>>, format: EncodedFormat) -> Self {
self.image(camera, FramePayload::Encoded { bytes: bytes.into(), format })
}
pub fn gps_fix(mut self, camera: impl Into<String>, lat: f64, lon: f64, alt: f64) -> Self {
self.gps.push(GpsRecord { name: camera.into(), lat, lon, alt });
self
}
pub fn at(mut self, t: SystemTime) -> Self {
self.timestamp = t;
self
}
}
#[derive(Clone, Debug)]
pub enum FrameState {
Ready(LiveFrameSet),
Pending {
key: FrameKey,
timestamp: SystemTime,
missing_cameras: Vec<String>,
},
End,
}
impl FrameState {
pub fn ready(set: LiveFrameSet) -> Self {
FrameState::Ready(set)
}
pub fn pending<S: Into<String>>(key: FrameKey, missing_cameras: impl IntoIterator<Item = S>) -> Self {
FrameState::Pending { key, timestamp: SystemTime::now(), missing_cameras: missing_cameras.into_iter().map(Into::into).collect() }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ReconcilePolicy {
#[default]
AppendOnly,
ReplayWindow {
positions: usize,
},
InvalidateAffectedZones,
Strict,
}
const BATCH_MAX: usize = 32;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Backpressure {
Unbounded,
Block {
max_pending: usize,
},
KeepLatest {
max_pending: usize,
},
}
impl Default for Backpressure {
fn default() -> Self {
Backpressure::Block { max_pending: 8 }
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum IgnoreReason {
StaleRevision {
accepted: u32,
},
CorrectionNotApplied,
Late,
OutsideReplayWindow {
position: usize,
},
ForeignSource {
expected: String,
},
Closed,
}
impl fmt::Display for IgnoreReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
IgnoreReason::StaleRevision { accepted } => write!(f, "stale revision (accepted revision {accepted})"),
IgnoreReason::CorrectionNotApplied => write!(f, "correction not applied by this policy"),
IgnoreReason::Late => write!(f, "late arrival not inserted by this policy"),
IgnoreReason::OutsideReplayWindow { position } => write!(f, "outside replay window (position {position})"),
IgnoreReason::ForeignSource { expected } => write!(f, "foreign source (node source is {expected})"),
IgnoreReason::Closed => write!(f, "node closed"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Outcome {
Appended {
position: usize,
skipped: u64,
},
Duplicate {
position: usize,
},
Waiting {
missing: Vec<String>,
},
Replayed {
from: usize,
positions: usize,
},
Invalidated {
position: usize,
zones: Vec<usize>,
},
Ignored(IgnoreReason),
Ended,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ComposeError {
Config(Vec<String>),
InvalidFrameSet(String),
Rejected(String),
Io(String),
Session(String),
UnknownNode(String),
DuplicateNode(String),
Closed,
}
impl fmt::Display for ComposeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ComposeError::Config(v) => write!(f, "invalid node configuration: {}", v.join("; ")),
ComposeError::InvalidFrameSet(m) => write!(f, "invalid frame set: {m}"),
ComposeError::Rejected(m) => write!(f, "rejected: {m}"),
ComposeError::Io(m) => write!(f, "i/o: {m}"),
ComposeError::Session(m) => write!(f, "session failed: {m}"),
ComposeError::UnknownNode(id) => write!(f, "no node {id:?} on this host"),
ComposeError::DuplicateNode(id) => write!(f, "node {id:?} already exists on this host"),
ComposeError::Closed => write!(f, "node already closed"),
}
}
}
impl std::error::Error for ComposeError {}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ComposeStats {
pub declared: u64,
pub appended: u64,
pub duplicates: u64,
pub waiting: u64,
pub skipped_sequences: u64,
pub replays: u64,
pub replayed_positions: u64,
pub invalidations: u64,
pub ignored: u64,
}
pub struct Closed {
pub session: Session,
pub summary: Summary,
pub stats: ComposeStats,
}
impl fmt::Debug for Closed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Closed")
.field("positions", &self.session.positions())
.field("summary", &self.summary)
.field("stats", &self.stats)
.finish()
}
}
#[derive(Clone, Default)]
pub struct CompositionHost {
nodes: Arc<Mutex<BTreeMap<String, ReconNode>>>,
}
impl fmt::Debug for CompositionHost {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CompositionHost").field("nodes", &self.ids()).finish()
}
}
impl CompositionHost {
pub fn new() -> Self {
Self::default()
}
pub fn node(&self, id: &str) -> Option<ReconNode> {
lock(&self.nodes).get(id).cloned()
}
pub fn ids(&self) -> Vec<String> {
lock(&self.nodes).keys().cloned().collect()
}
pub fn compose(&self, id: &str, state: FrameState) -> Result<Outcome, ComposeError> {
self.node(id).ok_or_else(|| ComposeError::UnknownNode(id.to_string()))?.compose(state)
}
pub fn close(&self, id: &str) -> Result<Closed, ComposeError> {
let node = lock(&self.nodes).remove(id).ok_or_else(|| ComposeError::UnknownNode(id.to_string()))?;
node.close_inner()
}
pub fn close_all(&self) -> Vec<(String, Result<Closed, ComposeError>)> {
let nodes = std::mem::take(&mut *lock(&self.nodes));
nodes.into_iter().map(|(id, n)| (id, n.close_inner())).collect()
}
fn register(&self, node: &ReconNode) -> Result<(), ComposeError> {
let mut m = lock(&self.nodes);
if m.contains_key(&node.id) {
return Err(ComposeError::DuplicateNode(node.id.clone()));
}
m.insert(node.id.clone(), node.clone());
Ok(())
}
}
pub fn reconstruct(host: &CompositionHost, id: &str, state: FrameState) -> Result<Outcome, ComposeError> {
host.compose(id, state)
}
type Attach = Box<dyn FnOnce(Pipeline<Session>) -> Pipeline<Session> + Send>;
pub struct NodeBuilder {
id: String,
plan: Plan,
policy: ReconcilePolicy,
backpressure: Backpressure,
source: Option<String>,
spool: PathBuf,
output: Option<(PathBuf, SinkOptions)>,
attach: Vec<Attach>,
frames: bool,
deltas: Option<LiveOptions>,
stream_origin: Option<[f64; 3]>,
stream_only: bool,
prior: Option<IntrinsicsPrior>,
prior_error: Option<String>,
}
impl fmt::Debug for NodeBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NodeBuilder").field("id", &self.id).field("policy", &self.policy).field("hooks", &self.attach.len()).finish()
}
}
impl ReconNode {
pub fn declare() -> NodeBuilder {
let mut plan = Plan::default();
plan.dense.enabled = false;
plan.gps = None;
plan.live = crate::declare::Live::live_node();
NodeBuilder {
id: "default".to_string(),
plan,
policy: ReconcilePolicy::default(),
backpressure: Backpressure::default(),
source: None,
spool: PathBuf::from(".live"),
output: None,
attach: Vec::new(),
frames: false,
deltas: None,
stream_origin: None,
stream_only: false,
prior: None,
prior_error: None,
}
}
}
impl NodeBuilder {
pub fn id(mut self, id: impl Into<String>) -> Self {
self.id = id.into();
self
}
pub fn plan(mut self, plan: Plan) -> Self {
self.plan = plan;
self
}
pub fn images(mut self, dir: impl Into<PathBuf>) -> Self {
self.plan.input.images = dir.into();
self
}
pub fn cameras<S: Into<String>>(mut self, cams: impl IntoIterator<Item = S>) -> Self {
self.plan.input.cameras = cams.into_iter().map(Into::into).collect();
self
}
pub fn zones(mut self, span: usize, overlap: usize) -> Self {
self.plan.zones.span = span;
self.plan.zones.overlap = overlap;
self
}
pub fn align(mut self, frame: AlignFrame) -> Self {
self.plan.align.frame = frame;
self
}
pub fn fixed_enu_origin(mut self, on: bool) -> Self {
self.plan.align.fixed_origin = on;
self
}
pub fn features(mut self, f: Features) -> Self {
self.plan.features = f;
self
}
pub fn match_backend(mut self, name: &str) -> Self {
self.plan.matching.backend = name.to_string();
self
}
pub fn gpu(mut self) -> Self {
self.plan.features.backend = "cuda".into();
self.plan.matching.backend = "cuda".into();
self.plan.sparse.backend = "cuda".into();
self.plan.dense.backend = "cuda".into();
self.plan.sparse.ba_backend = "cuda".into();
self.plan.dense.undistort_backend = "cuda".into();
self
}
pub fn stable_display(mut self, on: bool) -> Self {
self.plan.sparse.stable_display = on;
self
}
pub fn display_deltas(mut self, opts: LiveOptions) -> Self {
self.deltas = Some(opts);
self
}
pub fn incremental_triangulation(mut self, on: bool) -> Self {
self.plan.sparse.incremental_triangulation = on;
self
}
pub fn lookahead(mut self, positions: usize) -> Self {
self.plan.live.lookahead = positions;
self
}
pub fn batch(mut self, threshold: usize) -> Self {
self.plan.live.batch = threshold;
self
}
pub fn preview(mut self, on: bool) -> Self {
self.plan.live.preview = on;
self
}
pub fn settle(mut self, tol: f64, max_zone: usize) -> Self {
self.plan.sparse.settle_tol = tol;
self.plan.sparse.settle_max_zone = max_zone;
self
}
pub fn live_stride(mut self, stride: usize) -> Self {
self.plan.sinks.live_stride = stride;
self
}
pub fn stream_origin(mut self, lat: f64, lon: f64, alt: f64) -> Self {
self.stream_origin = Some([lat, lon, alt]);
self
}
pub fn stream_only(mut self, on: bool) -> Self {
self.stream_only = on;
self
}
pub fn dense(mut self, d: Dense) -> Self {
self.plan.dense = Dense { enabled: true, ..d };
self
}
pub fn no_dense(mut self) -> Self {
self.plan.dense.enabled = false;
self
}
pub fn seed(mut self, seed: u64) -> Self {
self.plan.seed = Some(seed);
self
}
pub fn reconcile(mut self, p: ReconcilePolicy) -> Self {
self.policy = p;
self
}
pub fn backpressure(mut self, b: Backpressure) -> Self {
self.backpressure = b;
self
}
pub fn source(mut self, id: impl Into<String>) -> Self {
self.source = Some(id.into());
self
}
pub fn spool(mut self, rel: impl Into<PathBuf>) -> Self {
self.spool = rel.into();
self
}
pub fn output(mut self, dir: impl Into<PathBuf>, opts: SinkOptions) -> Self {
self.output = Some((dir.into(), opts));
self
}
fn hook(mut self, f: impl FnOnce(Pipeline<Session>) -> Pipeline<Session> + Send + 'static) -> Self {
self.attach.push(Box::new(f));
self
}
pub fn on<F>(mut self, kind: EventKind, f: F) -> Self
where
F: FnMut(&Event) + Send + 'static,
{
self.frames |= kind == EventKind::FrameDecoded;
self.hook(move |p| p.on(kind, f))
}
pub fn on_event<F>(self, f: F) -> Self
where
F: FnMut(&Event) + Send + 'static,
{
self.hook(move |p| p.on_any(f))
}
pub fn on_frame<F>(mut self, f: F) -> Self
where
F: FnMut(&Arc<Frame>) + Send + 'static,
{
self.frames = true;
self.hook(move |p| p.on_frame(f))
}
pub fn on_zone_preview<F>(self, f: F) -> Self
where
F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
{
self.hook(move |p| p.on_zone_preview(f))
}
pub fn on_zone_refined<F>(self, f: F) -> Self
where
F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
{
self.hook(move |p| p.on_zone_refined(f))
}
pub fn on_message<F>(self, f: F) -> Self
where
F: FnMut(EventKind, &str) + Send + 'static,
{
self.hook(move |p| p.on_message(f))
}
pub fn event_policy(self, kind: EventKind, p: QueuePolicy) -> Self {
self.hook(move |pl| pl.policy(kind, p))
}
pub fn sync_hooks(self, on: bool) -> Self {
self.hook(move |pl| pl.sync(on))
}
pub fn intrinsics_prior(mut self, prior: IntrinsicsPrior) -> Self {
self.prior = Some(prior);
self.prior_error = None;
self
}
pub fn intrinsics_prior_json(mut self, path: impl AsRef<Path>) -> Self {
match IntrinsicsPrior::load(path, DEFAULT_FOCAL_SIGMA, DEFAULT_DIST_SIGMA) {
Ok(p) => {
self.prior = Some(p);
self.prior_error = None;
}
Err(e) => self.prior_error = Some(e),
}
self
}
pub fn build(self, host: &CompositionHost) -> Result<ReconNode, ComposeError> {
let NodeBuilder {
id,
plan,
policy,
backpressure,
source,
spool,
output,
attach,
frames,
deltas,
stream_origin,
stream_only,
prior,
prior_error,
} = self;
let mut pr = Vec::new();
let cams = &plan.input.cameras;
if cams.is_empty() {
pr.push("cameras: empty".to_string());
}
let mut seen = std::collections::BTreeSet::new();
for c in cams {
if c.is_empty() || c.contains(['/', '\\']) {
pr.push(format!("cameras: invalid name {c:?}"));
} else if !seen.insert(c.as_str()) {
pr.push(format!("cameras: duplicate {c}"));
}
}
let z = plan.zones;
if z.span == 0 {
pr.push("zones.span: must be at least 1".into());
} else if z.overlap >= z.span {
pr.push(format!("zones.overlap: must be smaller than the span ({}), got {}", z.span, z.overlap));
}
if !plan.input.images.is_dir() {
pr.push(format!("images: folder not found: {}", plan.input.images.display()));
}
if spool.is_absolute() || spool.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
pr.push(format!("spool: must be a relative path inside the image folder, got {}", spool.display()));
}
if plan.sinks.live_stride == 0 {
pr.push("live_stride: must be at least 1 (1 = keep all)".into());
}
if plan.sparse.settle_tol.is_nan() || plan.sparse.settle_tol < 0.0 {
pr.push(format!("settle tolerance: must be >= 0, got {}", plan.sparse.settle_tol));
}
if let ReconcilePolicy::ReplayWindow { positions: 0 } = policy {
pr.push("reconcile: replay window must be at least 1 position".into());
}
match backpressure {
Backpressure::Block { max_pending: 0 } | Backpressure::KeepLatest { max_pending: 0 } => {
pr.push("backpressure: max_pending must be at least 1".into())
}
_ => {}
}
if let Some(e) = prior_error {
pr.push(format!("intrinsics_prior: {e}"));
}
let prior = match prior {
Some(p) => Some(p),
None => plan.sparse.intrinsics_prior.as_ref().and_then(|f| {
IntrinsicsPrior::load(f, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma)
.map_err(|e| pr.push(format!("sparse.intrinsics_prior: {e}")))
.ok()
}),
};
if let Some(p) = &prior {
match p.validate() {
Err(e) => pr.push(format!("intrinsics_prior: {e}")),
Ok(()) => {
for n in p.named.keys().filter(|n| !cams.contains(n)) {
pr.push(format!("intrinsics_prior: camera {n:?} is not one of the node's cameras"));
}
}
}
}
let sift = make_sift_backend(&plan.features.backend).map_err(|e| pr.push(format!("features.backend: {e}"))).ok();
let matcher = make_match_backend(&plan.matching.backend).map_err(|e| pr.push(format!("matching.backend: {e}"))).ok();
let ba_backend = make_ba_backend(&plan.sparse.ba_backend).map_err(|e| pr.push(format!("sparse.ba_backend: {e}"))).ok();
let sparse = make_sparse_backend(&plan.sparse.backend).map_err(|e| pr.push(format!("sparse.backend: {e}"))).ok();
if plan.dense.enabled {
if let Err(e) = parse_profile(&plan.dense.profile) {
pr.push(format!("dense.profile: {e}"));
}
}
let dense = if plan.dense.enabled { plan.dense.config().map_err(|e| pr.push(format!("dense: {e}"))).ok() } else { None };
if !pr.is_empty() {
return Err(ComposeError::Config(pr));
}
if host.node(&id).is_some() {
return Err(ComposeError::DuplicateNode(id));
}
let mut sc = SessionConfig::new(plan.input.images.clone());
sc.span = z.span;
sc.overlap = z.overlap;
sc.total_positions = None;
sc.incremental_triangulation = plan.sparse.incremental_triangulation;
sc.fixed_enu_origin = plan.align.fixed_origin;
sc.seed = plan.seed;
sc.extraction.sift.max_num_features = plan.features.max_num_features;
sc.extraction.reader.max_image_size = plan.features.max_image_size;
sc.sift = sift.expect("checked");
sc.matcher = Arc::from(matcher.expect("checked"));
sc.ba_backend = ba_backend.expect("checked");
sc.sparse = sparse.expect("checked");
sc.dense = dense;
sc.history = match policy {
ReconcilePolicy::ReplayWindow { positions } => positions + 1,
_ => 0,
};
sc.decode_frames = frames;
sc.stable_display = plan.sparse.stable_display;
sc.settle_tol = plan.sparse.settle_tol;
sc.settle_max_zone = plan.sparse.settle_max_zone;
sc.intrinsics_prior = prior.map(Arc::new);
sc.lookahead = plan.live.lookahead;
sc.preview = plan.live.preview;
if deltas.as_ref().is_some_and(|o| o.stride == 0) {
return Err(ComposeError::Config(vec!["display_deltas: stride must be at least 1".into()]));
}
let origin = Arc::new(Mutex::new(None));
let mut pl = Pipeline::new(Session::new(sc));
if let Some(opts) = deltas {
let mut feed = DisplayFeed::new(opts).with_origin(Arc::clone(&origin));
pl = pl.derive(move |e| feed.handle(e).into_iter().collect());
}
if let Some((dir, opts)) = &output {
let io = |e: std::io::Error| ComposeError::Io(format!("{}: {e}", dir.display()));
pl = if plan.sparse.stable_display {
let set = SinkSet {
models: opts.save_models && !stream_only,
zone_ply: !stream_only,
live_files: !stream_only,
live_stride: plan.sinks.live_stride,
live_stream: Some(LiveStream { enu: stream_origin.is_some(), origin_gps: stream_origin }),
..SinkSet::all()
};
pl.on_any(DefaultSinks::with_set(dir, opts.echo, set).map_err(io)?.into_hook())
} else {
sinks::attach(pl, dir, opts).map_err(io)?
};
}
for a in attach {
pl = a(pl);
}
let resolver = Arc::new(Resolver::new(plan.input.cameras.clone(), plan.input.images.clone(), spool));
let core = Core {
cameras: plan.input.cameras.clone(),
resolver: Arc::clone(&resolver),
policy,
source,
accepted: BTreeMap::new(),
order: Vec::new(),
recent: VecDeque::new(),
waiting: BTreeMap::new(),
stats: ComposeStats::default(),
pipeline: Some(pl),
ended: None,
origin,
batching: false,
deferred: Vec::new(),
};
let ahead = core.pipeline.as_ref().expect("open pipeline").reducer().lookahead();
let batch = if matches!(policy, ReconcilePolicy::ReplayWindow { .. }) { 0 } else { plan.live.batch };
let node = ReconNode { id, backpressure, core: Arc::new(Mutex::new(core)), batch, ahead, resolver };
host.register(&node)?;
Ok(node)
}
}
#[derive(Clone)]
pub struct ReconNode {
id: String,
backpressure: Backpressure,
core: Arc<Mutex<Core>>,
batch: usize,
ahead: Lookahead,
resolver: Arc<Resolver>,
}
impl fmt::Debug for ReconNode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReconNode").field("id", &self.id).finish()
}
}
#[derive(Clone, Copy, Debug)]
struct Accepted {
revision: u32,
position: usize,
}
struct Core {
cameras: Vec<String>,
resolver: Arc<Resolver>,
policy: ReconcilePolicy,
source: Option<String>,
accepted: BTreeMap<u64, Accepted>,
order: Vec<u64>,
recent: VecDeque<FrameSet>,
waiting: BTreeMap<u64, Vec<String>>,
stats: ComposeStats,
pipeline: Option<Pipeline<Session>>,
ended: Option<Closed>,
origin: Arc<Mutex<Option<[f64; 3]>>>,
batching: bool,
deferred: Vec<FrameSet>,
}
impl ReconNode {
pub fn id(&self) -> &str {
&self.id
}
pub fn compose(&self, state: FrameState) -> Result<Outcome, ComposeError> {
lock(&self.core).compose(state)
}
pub fn compose_all(&self, states: impl IntoIterator<Item = FrameState>) -> Result<Vec<Outcome>, ComposeError> {
states.into_iter().map(|s| self.compose(s)).collect()
}
pub fn compose_batch(&self, states: impl IntoIterator<Item = FrameState>) -> Vec<Result<Outcome, ComposeError>> {
lock(&self.core).compose_batch(states.into_iter().collect())
}
pub fn poll(&self) -> usize {
lock(&self.core).pipeline.as_mut().map_or(0, |p| p.poll())
}
pub fn subscribe(&self) -> Option<Receiver<Event>> {
lock(&self.core).pipeline.as_ref().map(|p| p.subscribe())
}
pub fn stats(&self) -> ComposeStats {
lock(&self.core).stats.clone()
}
pub fn positions(&self) -> usize {
lock(&self.core).order.len()
}
pub fn journal(&self) -> Vec<(u64, u32)> {
let c = lock(&self.core);
c.order.iter().map(|s| (*s, c.accepted[s].revision)).collect()
}
pub fn waiting(&self) -> Vec<(u64, Vec<String>)> {
lock(&self.core).waiting.iter().map(|(k, v)| (*k, v.clone())).collect()
}
pub fn with_session<T>(&self, f: impl FnOnce(&Session) -> T) -> Option<T> {
lock(&self.core).pipeline.as_ref().map(|p| f(p.reducer()))
}
pub fn close(&self) -> Result<Closed, ComposeError> {
self.close_inner()
}
fn close_inner(&self) -> Result<Closed, ComposeError> {
let mut c = lock(&self.core);
if let Some(done) = c.ended.take() {
return Ok(done);
}
c.finish().ok_or(ComposeError::Closed)
}
pub fn spawn(&self) -> ComposeHandle {
let queue = Arc::new(Queue { st: Mutex::new(QueueState::default()), cv: Condvar::new() });
let (q, node) = (Arc::clone(&queue), self.clone());
let worker = std::thread::Builder::new()
.name(format!("compose-{}", self.id))
.spawn(move || {
let mut outcomes = Vec::new();
loop {
let next = {
let mut st = lock(&q.st);
loop {
if let Some(s) = st.items.pop_front() {
let mut taken = vec![s];
if node.batch >= 2 && matches!(taken[0], FrameState::Ready(_)) && st.items.len() + 1 >= node.batch {
while taken.len() < BATCH_MAX && !matches!(taken.last(), Some(FrameState::End)) {
match st.items.pop_front() {
Some(more) => taken.push(more),
None => break,
}
}
}
q.cv.notify_all();
break Some(taken);
}
if st.closed {
break None;
}
st = q.cv.wait(st).unwrap_or_else(|p| p.into_inner());
}
};
let Some(taken) = next else { break };
let end = taken.iter().any(|s| matches!(s, FrameState::End));
let n = taken.len() as u64;
let rs: Vec<_> = if n == 1 { taken.into_iter().map(|s| node.compose(s)).collect() } else { node.compose_batch(taken) };
lock(&q.st).processed += n;
let fatal = rs.iter().any(|r| matches!(r, Err(ComposeError::Session(_))));
outcomes.extend(rs);
if end || fatal {
break;
}
}
let mut st = lock(&q.st);
st.closed = true;
st.items.clear();
q.cv.notify_all();
outcomes
})
.expect("compose worker thread");
ComposeHandle { node: self.clone(), queue, policy: self.backpressure, worker: Some(worker) }
}
}
impl Core {
fn pl(&mut self) -> &mut Pipeline<Session> {
self.pipeline.as_mut().expect("open pipeline")
}
fn warn(&mut self, message: String) {
if let Some(p) = self.pipeline.as_mut() {
p.reducer().emit_external(|meta| Event::Warning { meta, message });
p.poll();
}
}
fn ignore(&mut self, key: &FrameKey, reason: IgnoreReason) -> Result<Outcome, ComposeError> {
if self.policy == ReconcilePolicy::Strict && reason != IgnoreReason::Closed {
return Err(ComposeError::Rejected(format!("{key}: {reason}")));
}
self.stats.ignored += 1;
self.warn(format!("compose: {key} ignored: {reason}"));
Ok(Outcome::Ignored(reason))
}
fn compose(&mut self, state: FrameState) -> Result<Outcome, ComposeError> {
self.stats.declared += 1;
if self.pipeline.is_none() {
return match state {
FrameState::End => Ok(Outcome::Ended),
_ => Ok(Outcome::Ignored(IgnoreReason::Closed)),
};
}
match state {
FrameState::End => {
let done = self.finish().ok_or(ComposeError::Closed)?;
self.ended = Some(done);
Ok(Outcome::Ended)
}
FrameState::Pending { key, missing_cameras, .. } => {
if let Some(r) = self.check_source(&key) {
return r;
}
self.stats.waiting += 1;
if !self.accepted.contains_key(&key.sequence) {
self.waiting.insert(key.sequence, missing_cameras.clone());
}
Ok(Outcome::Waiting { missing: missing_cameras })
}
FrameState::Ready(set) => self.ready(set),
}
}
fn check_source(&mut self, key: &FrameKey) -> Option<Result<Outcome, ComposeError>> {
match &self.source {
None => {
self.source = Some(key.source_id.clone());
None
}
Some(s) if *s == key.source_id => None,
Some(s) => {
let expected = s.clone();
Some(self.ignore(key, IgnoreReason::ForeignSource { expected }))
}
}
}
fn ready(&mut self, set: LiveFrameSet) -> Result<Outcome, ComposeError> {
let key = set.key.clone();
let mut by_cam: HashMap<&str, &LiveImage> = HashMap::new();
for im in &set.images {
if !self.cameras.contains(&im.camera) {
return Err(ComposeError::InvalidFrameSet(format!("{key}: unknown camera {}", im.camera)));
}
if by_cam.insert(im.camera.as_str(), im).is_some() {
return Err(ComposeError::InvalidFrameSet(format!("{key}: camera {} given twice", im.camera)));
}
}
if let Some(r) = self.check_source(&key) {
return r;
}
let missing: Vec<String> = self.cameras.iter().filter(|c| !by_cam.contains_key(c.as_str())).cloned().collect();
if !missing.is_empty() && !self.accepted.contains_key(&key.sequence) {
self.stats.waiting += 1;
self.waiting.insert(key.sequence, missing.clone());
return Ok(Outcome::Waiting { missing });
}
if let Some(acc) = self.accepted.get(&key.sequence).copied() {
if key.revision == acc.revision {
self.stats.duplicates += 1;
return Ok(Outcome::Duplicate { position: acc.position });
}
if key.revision < acc.revision {
return self.ignore(&key, IgnoreReason::StaleRevision { accepted: acc.revision });
}
if !missing.is_empty() {
return Err(ComposeError::InvalidFrameSet(format!("{key}: correction misses cameras {}", missing.join(", "))));
}
return self.correct(set, acc);
}
let newest = self.order.last().copied();
match newest {
Some(n) if key.sequence < n => self.late(set),
_ => {
let skipped = newest.map_or(0, |n| key.sequence - n - 1);
if skipped > 0 && self.policy == ReconcilePolicy::Strict {
return Err(ComposeError::Rejected(format!("{key}: {skipped} sequence(s) missing before it")));
}
let fs = self.resolver.resolve_cached(&set)?;
self.waiting.remove(&key.sequence);
let position = self.order.len();
self.push(fs)?;
self.order.push(key.sequence);
self.accepted.insert(key.sequence, Accepted { revision: key.revision, position });
self.stats.appended += 1;
if skipped > 0 {
self.stats.skipped_sequences += skipped;
self.warn(format!("compose: {key} appended at position {position} after {skipped} missing sequence(s)"));
}
Ok(Outcome::Appended { position, skipped })
}
}
}
fn late(&mut self, set: LiveFrameSet) -> Result<Outcome, ComposeError> {
self.flush_deferred()?;
let key = set.key.clone();
let ReconcilePolicy::ReplayWindow { positions: w } = self.policy else {
return self.ignore(&key, IgnoreReason::Late);
};
let p = self.order.partition_point(|&s| s < key.sequence);
if !self.in_window(p, w) {
return self.ignore(&key, IgnoreReason::OutsideReplayWindow { position: p });
}
let fs = self.resolve(&set, None)?;
let tail_sets: Vec<FrameSet> = self.recent.iter().skip(self.recent.len() - (self.order.len() - p)).cloned().collect();
let mut tail = vec![fs];
tail.extend(tail_sets);
let mut seqs = vec![key.sequence];
seqs.extend(self.order[p..].iter().copied());
self.waiting.remove(&key.sequence);
let n = self.replay(p, tail, &seqs, (key.sequence, key.revision))?;
self.warn(format!("compose: late {key} inserted at position {p}, replayed {n} position(s)"));
Ok(Outcome::Replayed { from: p, positions: n })
}
fn correct(&mut self, set: LiveFrameSet, acc: Accepted) -> Result<Outcome, ComposeError> {
self.flush_deferred()?;
let key = set.key.clone();
let p = acc.position;
match self.policy {
ReconcilePolicy::AppendOnly | ReconcilePolicy::Strict => self.ignore(&key, IgnoreReason::CorrectionNotApplied),
ReconcilePolicy::ReplayWindow { positions: w } => {
if !self.in_window(p, w) {
return self.ignore(&key, IgnoreReason::OutsideReplayWindow { position: p });
}
let fs = self.resolve(&set, None)?;
let start = self.recent.len() - (self.order.len() - p);
let mut tail: Vec<FrameSet> = self.recent.iter().skip(start).cloned().collect();
tail[0] = fs;
let seqs: Vec<u64> = self.order[p..].to_vec();
let n = self.replay(p, tail, &seqs, (key.sequence, key.revision))?;
self.warn(format!("compose: correction {key} applied from position {p}, replayed {n} position(s)"));
Ok(Outcome::Replayed { from: p, positions: n })
}
ReconcilePolicy::InvalidateAffectedZones => {
let names: Vec<String> = self.pl().reducer().frames()[p].iter().map(|f| f.name.clone()).collect();
self.resolve(&set, Some(&names))?;
let zones: Vec<usize> =
self.pl().reducer().arrived_zones().into_iter().filter(|z| z.lo <= p && p < z.hi).map(|z| z.zone).collect();
for &z in &zones {
self.pl().command(Command::InvalidateZone(z));
}
self.fail_check()?;
self.accepted.insert(key.sequence, Accepted { revision: key.revision, position: p });
self.stats.invalidations += 1;
Ok(Outcome::Invalidated { position: p, zones })
}
}
}
fn in_window(&self, p: usize, w: usize) -> bool {
let npos = self.order.len();
npos - p <= w && npos - p <= self.recent.len()
}
fn replay(&mut self, p: usize, tail: Vec<FrameSet>, seqs: &[u64], changed: (u64, u32)) -> Result<usize, ComposeError> {
let npos = self.order.len();
self.pl().command(Command::ResetFrom(p));
if self.pl().reducer().positions() != p {
return Err(ComposeError::Session(format!("rewind to position {p} was refused by the session")));
}
let revs: HashMap<u64, u32> = self.order[p..].iter().map(|s| (*s, self.accepted[s].revision)).collect();
for s in &self.order[p..] {
self.accepted.remove(s);
}
self.order.truncate(p);
let keep = self.recent.len() - (npos - p);
self.recent.truncate(keep);
let n = tail.len();
for (fs, &seq) in tail.into_iter().zip(seqs) {
let revision = if seq == changed.0 { changed.1 } else { revs[&seq] };
let position = self.order.len();
self.push(fs)?;
self.order.push(seq);
self.accepted.insert(seq, Accepted { revision, position });
}
self.stats.replays += 1;
self.stats.replayed_positions += n as u64;
Ok(n)
}
fn push(&mut self, fs: FrameSet) -> Result<(), ComposeError> {
let cap = match self.policy {
ReconcilePolicy::ReplayWindow { positions } => positions,
_ => 0,
};
if cap > 0 {
self.recent.push_back(fs.clone());
while self.recent.len() > cap {
self.recent.pop_front();
}
}
if let Some(g) = fs.gps.first() {
lock(&self.origin).get_or_insert([g.lat, g.lon, g.alt]);
}
if self.batching {
self.deferred.push(fs);
return Ok(());
}
self.pl().push(Input::Frames(fs));
self.fail_check()
}
fn flush_deferred(&mut self) -> Result<(), ComposeError> {
if self.deferred.is_empty() {
return Ok(());
}
let mut sets = std::mem::take(&mut self.deferred);
let input = if sets.len() == 1 { Input::Frames(sets.remove(0)) } else { Input::Batch(sets) };
self.pl().push(input);
self.fail_check()
}
fn compose_batch(&mut self, states: Vec<FrameState>) -> Vec<Result<Outcome, ComposeError>> {
let mut outs: Vec<Result<Outcome, ComposeError>> = Vec::with_capacity(states.len());
self.batching = true;
let mut last_deferred = None;
for st in states {
let before = self.deferred.len();
let r = self.compose(st);
if self.deferred.len() > before {
last_deferred = Some(outs.len());
} else if self.deferred.is_empty() {
last_deferred = None;
}
outs.push(r);
}
self.batching = false;
if let Err(e) = self.flush_deferred() {
match last_deferred {
Some(i) => outs[i] = Err(e),
None => outs.push(Err(e)),
}
}
outs
}
fn fail_check(&mut self) -> Result<(), ComposeError> {
match self.pl().reducer().failed() {
Some(e) => Err(ComposeError::Session(e.to_string())),
None => Ok(()),
}
}
fn resolve(&self, set: &LiveFrameSet, names: Option<&[String]>) -> Result<FrameSet, ComposeError> {
self.resolver.resolve(set, names)
}
fn finish(&mut self) -> Option<Closed> {
let _ = self.flush_deferred();
let pl = self.pipeline.take()?;
let (session, summary) = pl.finish();
Some(Closed { session, summary, stats: self.stats.clone() })
}
}
struct Resolver {
cameras: Vec<String>,
image_root: PathBuf,
spool: PathBuf,
ahead: Mutex<HashMap<(u64, u32), FrameSet>>,
}
impl Resolver {
fn new(cameras: Vec<String>, image_root: PathBuf, spool: PathBuf) -> Self {
Self { cameras, image_root, spool, ahead: Mutex::new(HashMap::new()) }
}
fn resolve_cached(&self, set: &LiveFrameSet) -> Result<FrameSet, ComposeError> {
let hit = lock(&self.ahead).remove(&(set.key.sequence, set.key.revision));
match hit {
Some(fs) => Ok(fs),
None => self.resolve(set, None),
}
}
fn resolve_ahead(&self, set: &LiveFrameSet) -> Option<FrameSet> {
if set.images.len() != self.cameras.len()
|| !self.cameras.iter().all(|c| set.images.iter().filter(|i| &i.camera == c).count() == 1)
|| set.images.iter().any(|i| matches!(i.payload, FramePayload::Rgb8 { .. }))
{
return None;
}
let fs = self.resolve(set, None).ok()?;
let mut m = lock(&self.ahead);
if m.len() >= 256 {
m.clear();
}
m.insert((set.key.sequence, set.key.revision), fs.clone());
Some(fs)
}
fn resolve(&self, set: &LiveFrameSet, names: Option<&[String]>) -> Result<FrameSet, ComposeError> {
let key = &set.key;
let mut fs = FrameSet::default();
for (i, cam) in self.cameras.iter().enumerate() {
let im = set.images.iter().find(|x| &x.camera == cam).expect("complete set");
let forced = names.map(|n| n[i].clone());
let name = match &im.payload {
FramePayload::File { relative_name } => {
let src = self.image_root.join(relative_name);
if !src.is_file() {
return Err(ComposeError::InvalidFrameSet(format!("{key}: file not found: {}", src.display())));
}
match forced {
Some(n) if n != *relative_name => {
let bytes = std::fs::read(&src).map_err(|e| ComposeError::Io(format!("{}: {e}", src.display())))?;
self.store_encoded(&n, &bytes, ext_of(relative_name))?;
n
}
_ => relative_name.clone(),
}
}
FramePayload::Rgb8 { width, height, pixels } => {
if pixels.len() != *width as usize * *height as usize * 3 || *width == 0 || *height == 0 {
return Err(ComposeError::InvalidFrameSet(format!(
"{key}: camera {cam}: {} bytes for {width}x{height} RGB8",
pixels.len()
)));
}
let n = forced.unwrap_or_else(|| self.spool_name(key, cam, "png"));
let img = image::RgbImage::from_raw(*width, *height, pixels.to_vec()).expect("size checked");
self.store_image(&n, &image::DynamicImage::ImageRgb8(img))?;
n
}
FramePayload::Encoded { bytes, format } => {
let n = forced.unwrap_or_else(|| self.spool_name(key, cam, format.extension()));
self.store_encoded(&n, bytes, Some(format.extension()))?;
n
}
};
fs.images.push(crate::session::FrameImage { camera: cam.clone(), name });
}
for g in &set.gps {
let mut g = g.clone();
if let Some(i) = self.cameras.iter().position(|c| *c == g.name) {
g.name = fs.images[i].name.clone();
}
fs.gps.push(g);
}
Ok(fs)
}
fn spool_name(&self, key: &FrameKey, cam: &str, ext: &str) -> String {
let src: String = key.source_id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
let p = self.spool.join(cam).join(format!("{src}_{:010}.{ext}", key.sequence));
p.to_string_lossy().replace('\\', "/")
}
fn store_encoded(&self, name: &str, bytes: &[u8], ext: Option<&str>) -> Result<(), ComposeError> {
if ext.is_some() && ext == ext_of(name) {
return self.atomic_write(name, |path| std::fs::write(path, bytes).map_err(|e| e.to_string()));
}
let img = image::load_from_memory(bytes).map_err(|e| ComposeError::InvalidFrameSet(format!("{name}: {e}")))?;
self.store_image(name, &img)
}
fn store_image(&self, name: &str, img: &image::DynamicImage) -> Result<(), ComposeError> {
let fmt = image::ImageFormat::from_path(name).map_err(|e| ComposeError::Io(format!("{name}: {e}")))?;
self.atomic_write(name, |path| img.save_with_format(path, fmt).map_err(|e| e.to_string()))
}
fn atomic_write(&self, name: &str, write: impl FnOnce(&Path) -> Result<(), String>) -> Result<(), ComposeError> {
let dst = self.image_root.join(name);
let dir = dst.parent().unwrap_or(&self.image_root);
std::fs::create_dir_all(dir).map_err(|e| ComposeError::Io(format!("{}: {e}", dir.display())))?;
let file = dst.file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
let ext = ext_of(name).unwrap_or("tmp");
let tmp = dir.join(format!(".{file}.partial.{ext}"));
write(&tmp).map_err(|e| ComposeError::Io(format!("{}: {e}", tmp.display())))?;
std::fs::rename(&tmp, &dst).map_err(|e| ComposeError::Io(format!("{}: {e}", dst.display())))
}
}
fn ext_of(name: &str) -> Option<&str> {
Path::new(name).extension().and_then(|e| e.to_str())
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|p| p.into_inner())
}
#[derive(Default)]
struct QueueState {
items: VecDeque<FrameState>,
closed: bool,
submitted: u64,
dropped: u64,
processed: u64,
max_depth: usize,
}
struct Queue {
st: Mutex<QueueState>,
cv: Condvar,
}
#[derive(Debug)]
pub enum SendError {
Full(FrameState),
Closed(FrameState),
}
impl fmt::Display for SendError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SendError::Full(_) => write!(f, "compose queue full"),
SendError::Closed(_) => write!(f, "compose worker stopped"),
}
}
}
impl std::error::Error for SendError {}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct QueueStats {
pub submitted: u64,
pub dropped: u64,
pub processed: u64,
pub depth: usize,
pub max_depth: usize,
}
pub struct ComposeHandle {
node: ReconNode,
queue: Arc<Queue>,
policy: Backpressure,
worker: Option<JoinHandle<Vec<Result<Outcome, ComposeError>>>>,
}
impl fmt::Debug for ComposeHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ComposeHandle").field("node", &self.node.id).field("policy", &self.policy).field("stats", &self.stats()).finish()
}
}
impl ComposeHandle {
pub fn node(&self) -> &ReconNode {
&self.node
}
pub fn send(&self, state: FrameState) -> Result<(), SendError> {
self.enqueue(state, true)
}
pub fn try_send(&self, state: FrameState) -> Result<(), SendError> {
self.enqueue(state, false)
}
pub fn send_all(&self, states: impl IntoIterator<Item = FrameState>) -> Result<(), SendError> {
states.into_iter().try_for_each(|s| self.send(s))
}
fn enqueue(&self, state: FrameState, wait: bool) -> Result<(), SendError> {
if let (FrameState::Ready(set), true) = (&state, self.node.ahead.limit() > 0) {
if let Some(fs) = self.node.resolver.resolve_ahead(set) {
self.node.ahead.hint(&fs);
}
}
let q = &self.queue;
let mut st = lock(&q.st);
if st.closed {
return Err(SendError::Closed(state));
}
let end = matches!(state, FrameState::End);
match self.policy {
Backpressure::Unbounded => {}
Backpressure::Block { max_pending } => {
while !end && st.items.len() >= max_pending && !st.closed {
if !wait {
return Err(SendError::Full(state));
}
st = q.cv.wait(st).unwrap_or_else(|p| p.into_inner());
}
if st.closed {
return Err(SendError::Closed(state));
}
}
Backpressure::KeepLatest { max_pending } => {
while !end && st.items.len() >= max_pending {
match st.items.iter().position(|s| matches!(s, FrameState::Ready(_))) {
Some(i) => {
st.items.remove(i);
st.dropped += 1;
}
None => {
st.items.pop_front();
}
}
}
}
}
st.items.push_back(state);
st.submitted += 1;
st.max_depth = st.max_depth.max(st.items.len());
q.cv.notify_all();
Ok(())
}
pub fn stats(&self) -> QueueStats {
let st = lock(&self.queue.st);
QueueStats { submitted: st.submitted, dropped: st.dropped, processed: st.processed, depth: st.items.len(), max_depth: st.max_depth }
}
pub fn close(mut self) -> (Vec<Result<Outcome, ComposeError>>, Result<Closed, ComposeError>) {
let _ = self.enqueue(FrameState::End, false);
let outcomes = self.worker.take().map(|w| w.join().unwrap_or_default()).unwrap_or_default();
(outcomes, self.node.close_inner())
}
}
impl Drop for ComposeHandle {
fn drop(&mut self) {
if let Some(w) = self.worker.take() {
{
let mut st = lock(&self.queue.st);
st.closed = true;
self.queue.cv.notify_all();
}
let _ = w.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spool_names_are_sanitized_and_stable() {
let c = Core {
cameras: vec!["camF".into()],
resolver: Arc::new(Resolver::new(vec!["camF".into()], PathBuf::from("/tmp/x"), PathBuf::from(".live"))),
policy: ReconcilePolicy::AppendOnly,
source: None,
accepted: BTreeMap::new(),
order: Vec::new(),
recent: VecDeque::new(),
waiting: BTreeMap::new(),
stats: ComposeStats::default(),
pipeline: None,
ended: None,
origin: Arc::new(Mutex::new(None)),
batching: false,
deferred: Vec::new(),
};
let k = FrameKey::new("rtsp://drone 1", 42, 3);
assert_eq!(c.resolver.spool_name(&k, "camF", "png"), ".live/camF/rtsp___drone_1_0000000042.png");
}
#[test]
fn key_display() {
assert_eq!(FrameKey::new("d", 7, 1).to_string(), "d#7r1");
}
}