From f7949f38379640f49b99aa66704545adc98c4d93 Mon Sep 17 00:00:00 2001 From: breis Date: Sat, 22 Aug 2026 16:31:18 -0400 Subject: [PATCH 1/2] feat(sim): replay a directory of real images as captures Adds a `playlist` pattern to the simulator backend so a downstream vision component receives genuine imagery through the real camera path instead of synthetic colour bars (D-CAM-31, for image-processor D-IP-18). `frame.pattern` now accepts `{ "playlist": { directory, include, order, loop, advance } }` beside the four synthetic tokens. The directory is walked once at connect - deterministic, symbolic links refused, bounded at 10,000 files and 32 levels, and a directory matching no file fails the connect - and ordered `sorted` by relative path or `seeded` by a SplitMix64 Fisher-Yates shuffle keyed by the simulator seed. Each capture yields the next file with that file's own dimensions and format and runs the unchanged finalize path: temp write, fsync, sidecar first, atomic visibility, catalog row, `ImageCaptured`. A JPEG member captured under `passthrough` or `raw` is delivered as the bytes on disk, so the announced and sidecar `image.sha256` is the source file's own digest; every other combination decodes to RGB8/Mono8 and re-encodes through the ordinary encoding stage. Byte-preserving replay stays JPEG-only because `OutputEncoding::Passthrough` already requires a complete JPEG source. The terminal body carries `backendMetadata.playlist` (`sourcePath`, `index`), and the backend session-status surface carries `playlist{count,index,directory}`. `CaptureRequest` gains an opaque `trigger_key`, derived adapter-side from the durable `CaptureTrigger`, which `advance: perTrigger` compares for equality, so no EdgeCommons message shape crosses the backend seam. `config.schema.json`, `docs/reference/configuration.md`, `docs/how-to-guides.md`, and `DESIGN.md` (section 6.2.1 plus decision D-CAM-31) are updated in the same change. --- DESIGN.md | 31 +- config.schema.json | 48 +- docs/how-to-guides.md | 47 + docs/reference/configuration.md | 17 + src/backend/genicam_aravis.rs | 4 + src/backend/mod.rs | 8 + src/backend/onvif.rs | 3 + src/backend/rtsp_backend.rs | 1 + src/backend/sim.rs | 1164 ++++++++++++++++- src/config.rs | 171 ++- src/jobs.rs | 23 + src/runtime/tests/simulator_runtime.rs | 1 + .../simulator_runtime/coverage_command.rs | 2 + .../simulator_runtime/coverage_playlist.rs | 157 +++ 14 files changed, 1633 insertions(+), 44 deletions(-) create mode 100644 src/runtime/tests/simulator_runtime/coverage_playlist.rs diff --git a/DESIGN.md b/DESIGN.md index 9a6852f..2c49705 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -148,6 +148,7 @@ The words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are no | D-CAM-29 | Core 0.4.0 adoption: scoped instance routing + conditional availability | Adopt core 0.4.0's immediate-reply scoped registration: the delivery topic's `{instance}` token authoritative for every immediate-reply verb, adapter-side conflict refusal, and `sb/discover`'s configuration-conditional availability published into `describe` via `set_command_availability` (`disabled` with a reason while `global.discovery.enabled` is false; reapplied on committed reloads through the configuration listener — this part of the decision stands unchanged). `receivedTs` is **N/A**: the adapter is a direct camera client with no upstream broker hop to stamp a receive time. PTZ capability is per-camera and runtime-discovered, so it is deliberately NOT mirrored into component-scope availability. | **The recorded gap is CLOSED by D-CAM-30 (core 0.5.0).** Core 0.4.0 exposed the addressed-instance token only to the immediate-reply registration, so `sb/capture`/`sb/capture-group` routed by the body and the topic token did not route them — surfaced up front here as a gap needing a core scoped-outcome registration. Core 0.5.0's breaking two-form surface delivers exactly that: the deferred verbs now receive the addressed instance like every other verb, with deferred settlement unchanged. The adapter-side routing/conflict layer this entry introduced (`register_scoped` + `scoped_request`) is deleted — addressing enforcement is library-owned. | | D-CAM-30 | Core 0.5.0 adoption: declared verb scope + scoped deferred captures + keepalive instance state | Pin `edgecommons` at rust-lib/v0.5.0 (a14a3285). Every verb registers through the two-form surface `register(verb, scope, handler)` / `register_outcome(verb, scope, handler)` with a declared `CommandScope` (SOUTHBOUND §2.2 / D-SC-2), derived from its closed request schema: **`Component`** for the selector-less verbs (`sb/list`, `sb/discover`, `sb/capture-group`, `sb/capture-group-submit`, `sb/capture-cancel` — fleet answers, `instances[]` targets, durable capture/group ids), **`Instance`** for per-camera actuation (`sb/capture`, `sb/capture-submit`, `sb/reconnect`, `sb/ptz`, `sb/ptz-presets`, `sb/pause`, `sb/resume`), **`Both`** for the dual-semantics verbs where no addressing means the whole component (`sb/status` every camera, `sb/queue-status` the fleet, `sb/capture-status` component-wide lookups, `sb/queue-clear` the `allCameras` drain). The library enforces addressing ahead of dispatch (conflict-first `BAD_ARGS`, `Component`-scope rejection, D-SC-4); the adapter's hand-rolled `scoped_request` layer is deleted, keeping only the D-SC-4 component-side policies: the optional-iff-one configured-camera default and `NO_SUCH_INSTANCE` for an unknown name (`addressed_request` seeds the library-resolved token into the body selector those policies read). **The deferred verbs are scoped too — closing the D-CAM-29 gap:** `sb/capture` (`Instance`) and `sb/capture-group` (`Component`) register through the scoped outcome form, so the topic token routes a deferred capture while deferred settlement, permit release, and `sb/capture-cancel` settling the held reply are unchanged. Companion (D-SC-7): the state keepalive's `instances[]` `state` comes from the single instance state model that answers `sb/status` — a paused camera reports `PAUSED` (shared `CONNECTING`/`ONLINE`/`BACKOFF`/`PAUSED` vocabulary) while `connected` keeps reporting reachability; the exact wire element is pinned via the now-public `InstanceConnectivity::to_json`. | The 0.4.0 model needed an adapter-side enforcement layer and still left the two most consequential verbs blind to the envelope. With the declared scope the library owns addressing for every registration form, the camera class of gap is structurally impossible (D-SC-1), and `describe` advertises each verb's scope for the console. Dual-meaning verbs gain first-class component-wide semantics instead of overloading "no instance named". `PAUSED` in the keepalive lets a console distinguish expected-quiet from silently-stale (D-SC-8) without a second bookkeeping path. | | D-CAM-22 | Bare-RTSP backend | A distinct `rtsp` backend addresses a camera by a raw `rtsp://`/`rtsps://` URL, with no ONVIF. It is still-image only, reuses the shared RTSP engine (`RtspCaptureController`) and the network/credential/TLS primitives, and advertises `capture_modes=[rtsp-frame]` with all PTZ/snapshot/discovery capabilities off. To make it buildable without ONVIF, the protocol-neutral net/auth primitives and the credential-resolution seam are lifted from the `onvif` module into a shared `backend::net` module, and the `rtsp` cargo feature is decoupled from `onvif`. | ONVIF gives identity, capability discovery, media profiles, snapshot, PTZ, and the governed stream URI; a raw RTSP URL gives none of these, so it is a genuinely different camera kind rather than a mode of `onvif-rtsp` — a separate backend keeps the ONVIF backend's required-field invariants (`deviceServiceUrl`/`mediaProfile`) intact. `connect()` performs the RTSP `DESCRIBE`/`SETUP` + auth + SDP/codec validation so a dead URL, bad auth, or unsupported codec fails at connect (the supervisor keeps such a camera OFFLINE rather than falsely ONLINE, since reachability is inferred from a successful connect). The URL carries no credentials (userinfo is rejected); credentials are `$secret` references resolved through the same bounded EdgeCommons path as ONVIF, and the same host-allowlist / DNS-pin / RTSPS-SNI / forbidden-address policy applies to the user-supplied URL. Decoupling the feature lets an operator ship an RTSP-only binary without the ONVIF surface. | +| D-CAM-31 | Simulator `playlist` pattern | `frame.pattern` accepts `{ "playlist": { "directory", "include", "order", "loop", "advance" } }` beside the four synthetic tokens. The directory is walked once at connect — deterministic, symbolic links refused, bounded at 10,000 files and 32 levels, and empty is a connect failure — ordered `sorted` (by relative path) or `seeded` (SplitMix64 Fisher-Yates over the sorted list, keyed by the simulator seed), and replayed one file per capture through the **unchanged** finalize path: temp write, fsync, sidecar first, atomic visibility, catalog row, `ImageCaptured`, thumbnail where configured. A JPEG member captured under `passthrough`/`raw` is delivered as the bytes on disk, so `image.sha256` is the source file's digest; every other combination decodes to `RGB8`/`Mono8` and re-encodes through the ordinary encoding stage. `backendMetadata.playlist` carries `sourcePath` and `index`; the session-status surface carries `playlist{count,index,directory}`. The backend seam gains an opaque `CaptureRequest.trigger_key`, derived adapter-side from the durable `CaptureTrigger`, which `advance: perTrigger` compares for equality. | The synthetic patterns prove plumbing, not vision: a downstream anomaly or classification model needs real imagery, and `image-processor`'s tier-4 end-to-end rehearsal (D-IP-18) needs it arriving through the real camera path — genuine sidecars, digests, and announcements — rather than from a fixture that writes files behind the adapter's back. Reading the directory at connect rather than per capture keeps an unbounded filesystem walk off every frame's acquisition deadline and gives the replay a fixed list to be deterministic about; `sb/reconnect` is the re-read. **Byte-preserving replay is JPEG-only**, because `OutputEncoding::Passthrough` already requires a declared complete JPEG source: extending it to PNG would mean a new `PixelFormat` variant and a changed encoding contract for every backend, so a PNG member is decoded and re-encoded instead and only a JPEG member yields a sidecar digest equal to the file's. `trigger_key` is a flat opaque string rather than the `CaptureTrigger` type so that no EdgeCommons message shape crosses the backend seam (§6.2). | ## 5. System context @@ -281,6 +282,34 @@ classDiagram The production interface MUST be mockable without a native camera library. The in-process `sim` backend is a required implementation, not only a test fixture hidden behind conditional compilation. +#### 6.2.1 Simulator frame sources + +`frame.pattern` selects what a simulated capture acquires. The four synthetic generators (`color-bars`, +`gradient`, `checkerboard`, `solid`) draw pixels from the simulator seed and the capture ordinal. The +`playlist` pattern replays a directory of real image files: the directory is walked once at connect, +ordered `sorted` (ascending by relative path) or `seeded` (a SplitMix64 Fisher-Yates shuffle of that sorted +list, keyed by the simulator seed), and each capture yields the next file's bytes with that file's own +dimensions and format. `loop` restarts replay after the last file; with `loop: false` a capture past the end +fails `DEVICE_UNAVAILABLE`. `advance` selects when the cursor moves: `perCapture` on every capture, or +`perTrigger` only when the capture's trigger differs from the previous capture's. + +A replayed frame takes the same path as any other frame. A JPEG member captured under a byte-preserving +output (`passthrough` or `raw`) is handed on as the bytes on disk, so the announced `image.sha256` is the +source file's digest; every other combination is decoded to `RGB8`/`Mono8` pixels and re-encoded by the +ordinary encoding stage. The originating file travels with the frame as `backendMetadata.playlist` +(`sourcePath`, `index`) and so reaches the terminal announcement, the catalog's terminal result, and the +metadata sidecar; the session-status surface reports `playlist` (`count`, `index`, `directory`). + +Containment is structural: the walk refuses every symbolic link and descends only real directories under the +canonicalized root, and the same check runs again at capture time because the directory is a live +filesystem. The walk is bounded at 10,000 matching files and 32 levels, and a directory that matches no file +fails the connect rather than accepting captures it would refuse one at a time. + +`CaptureRequest` carries a `trigger_key`: an opaque string identifying the operator action or schedule +occurrence a capture belongs to, derived adapter-side from the durable `CaptureTrigger`. A backend compares +it for equality and never parses it, which is what `advance: perTrigger` reads and what keeps the seam free +of EdgeCommons message shapes. + ### 6.3 Threading and blocking I/O - Tokio tasks MAY manage camera state, timers, queues, messaging, HTTP, and durable catalog work. @@ -2267,7 +2296,7 @@ build checks. | Simulator | Purpose | Required scenarios | Limitation | |---|---|---|---| -| In-process `SimBackend` | Fast deterministic camera fleet | 1–1,024 cameras, delays, disconnects, bad frames, PTZ ranges, cancellation, memory pressure | Does not validate a protocol stack. | +| In-process `SimBackend` | Fast deterministic camera fleet, and replay of real imagery | 1–1,024 cameras, delays, disconnects, bad frames, PTZ ranges, cancellation, memory pressure, `playlist` replay of a directory of JPEG/PNG files | Does not validate a protocol stack. | | Aravis fake GigE Vision camera (`arv-fake-gv-camera`, sometimes version-suffixed by distribution) | Real Aravis discovery and acquisition path | Software trigger, payload size, incomplete/timeout injection where supported, reconnect | Primarily GigE Vision; not a USB3 Vision substitute. | | GStreamer `videotestsrc` with `gst-rtsp-server`, or a pinned MediaMTX test service fed by generated video | RTSP negotiation and frame extraction | H.264/H.265 where licensed/available, reconnect, codec change, slow first frame, invalid stream | Does not provide ONVIF control. | | In-repository ONVIF device simulator | Deterministic SOAP, auth, capability, snapshot, and PTZ behavior | GetCapabilities, media profiles, GetSnapshotUri, Digest auth, PTZ operations/presets, faults, hostile URI/redirect | Must be maintained with the component contract. | diff --git a/config.schema.json b/config.schema.json index 6c69042..8de2906 100644 --- a/config.schema.json +++ b/config.schema.json @@ -527,9 +527,53 @@ "description": "Capture mechanism." }, "simPattern": { - "enum": ["color-bars", "gradient", "checkerboard", "solid"], + "description": "Simulator frame pattern: one of the synthetic generators, or a playlist that replays a directory of real image files.", "default": "color-bars", - "description": "Simulator frame pattern." + "oneOf": [ + { "enum": ["color-bars", "gradient", "checkerboard", "solid"] }, + { + "type": "object", + "additionalProperties": false, + "required": ["playlist"], + "properties": { "playlist": { "$ref": "#/$defs/simPlaylist" } } + } + ] + }, + "simPlaylist": { + "type": "object", + "additionalProperties": false, + "required": ["directory"], + "description": "Replay of a directory of real image files, read once when the camera connects.", + "properties": { + "directory": { + "type": "string", + "description": "Absolute directory holding the image files." + }, + "include": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "default": ["**/*.jpg", "**/*.jpeg", "**/*.png"], + "description": "Case-sensitive globs matched against each file's `/`-separated path relative to `directory`. `**` spans path segments, `*` matches within one segment, `?` matches one character." + }, + "order": { "$ref": "#/$defs/simPlaylistOrder" }, + "loop": { + "type": "boolean", + "default": true, + "description": "Whether replay restarts at the first file after the last one." + }, + "advance": { "$ref": "#/$defs/simPlaylistAdvance" } + } + }, + "simPlaylistOrder": { + "enum": ["sorted", "seeded"], + "default": "sorted", + "description": "Playlist replay order: ascending by relative path, or a deterministic shuffle derived from the simulator seed." + }, + "simPlaylistAdvance": { + "enum": ["perCapture", "perTrigger"], + "default": "perCapture", + "description": "When the playlist cursor moves to the next file." }, "genicamTransport": { "enum": ["auto", "gige-vision", "usb3-vision"], diff --git a/docs/how-to-guides.md b/docs/how-to-guides.md index 4fa9fb4..61f02ba 100644 --- a/docs/how-to-guides.md +++ b/docs/how-to-guides.md @@ -107,6 +107,53 @@ Put no credentials in the `url` (`rtsp://user:pass@…` is rejected) — supply or discovery. It is built with the `rtsp` feature (which no longer requires `onvif`) plus the GStreamer runtime. See the [sample configuration](sample-configurations.md#5-bare-rtsp-camera-no-onvif). +## Replay real images with the simulator + +Point the `sim` backend at a directory of images to feed a downstream vision component real pictures through +the real camera path. Each capture takes the next file and finalizes it the way a camera frame is finalized: +the metadata sidecar lands first, the image becomes visible atomically, the catalog records the job, and +`ImageCaptured` announces the result. + +To replay a directory of images: + +1. Put the images in a directory the adapter can read. Only regular files join the playlist, and the walk + rejects symbolic links, so a camera whose directory holds one refuses to connect. +2. Set the camera's `frame.pattern` to a `playlist` object that names that absolute `directory`. +3. Choose the capture profile's output encoding. `passthrough` installs a JPEG member byte for byte, so + `image.sha256` is the digest of the source file. `jpeg`, `png`, and `tiff` decode the member and re-encode + it. +4. Start the camera and capture. `sb/capture` and schedules both draw from the same playlist. + +```json +"backend": { + "type": "sim", + "frame": { + "pattern": { + "playlist": { + "directory": "/srv/line-clearance/reference-images", + "include": ["**/*.jpg", "**/*.jpeg", "**/*.png"], + "order": "sorted", + "loop": true, + "advance": "perCapture" + } + } + } +} +``` + +The adapter reads the directory once, when the camera connects, so images added later take effect on +`sb/reconnect`. `order: "seeded"` shuffles the list deterministically from the camera's `seed`, which gives a +repeatable order that is not alphabetical. `loop: false` replays each file once and then fails further +captures with `DEVICE_UNAVAILABLE`, which is how you drive a fixed-length rehearsal. `advance: "perTrigger"` +holds one file for every capture that shares a trigger — one command request, one capture-group request, or +one schedule occurrence. + +Every replayed capture names its source. The terminal `ImageCaptured` body and the metadata sidecar beside +the image both carry `backendMetadata.playlist.sourcePath`, the file's path relative to the playlist +directory, and `backendMetadata.playlist.index`, its position in the replay order. With `passthrough` output +the installed file is the source file, so a consumer that verifies `image.sha256` is verifying the image an +operator put in the directory. + ## Hand completed files to file-replicator The adapter and [file-replicator](https://docs.edgecommons.mbreissi.com/components/file-replicator/) couple diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index fa29362..978f368 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -120,6 +120,23 @@ authority; they do not disable per-connection address validation. `pattern`), `connectDelayMs`, `captureDelayMs` (default 10), PTZ capability switches, and deterministic fault counters. It is intended for configured test and development cameras. +`frame.pattern` is either one of the four synthetic generators (`color-bars` by default, `gradient`, +`checkerboard`, `solid`), which draw pixels from the seed and the capture ordinal, or a `playlist` object +that replays a directory of real image files. A playlist requires an absolute `directory` and accepts +`include` (case-sensitive globs matched against each file's `/`-separated path relative to `directory`, +default `**/*.jpg`, `**/*.jpeg`, `**/*.png`; `**` spans path segments, `*` matches within one segment, `?` +matches one character), `order` (`sorted` by relative path, or `seeded` for a deterministic shuffle keyed by +the camera's `seed`), `loop` (default true; with `false` a capture past the last file fails +`DEVICE_UNAVAILABLE`), and `advance` (`perCapture`, or `perTrigger` to hold one file for every capture that +shares a command request, capture-group request, or schedule occurrence). The directory is read once when +the camera connects and holds at most 10,000 files nested at most 32 levels deep; symbolic links are +refused. `frame.width`, `frame.height`, and `frame.pixelFormat` configure the synthetic generators and are +not consulted for a playlist — each capture reports the replayed file's own dimensions and format. A JPEG +member captured under a `passthrough` or `raw` profile is installed byte for byte, so `image.sha256` is the +source file's digest; every other combination decodes the member to pixels and re-encodes it. The terminal +body and the metadata sidecar carry `backendMetadata.playlist.sourcePath` and +`backendMetadata.playlist.index`. + `rtsp` is a bare-RTSP backend for a camera addressed directly by an `rtsp://` or `rtsps://` `url`, with no ONVIF device. It captures still frames only — `captureMode` is `rtsp-frame`, its single valid value — and exposes no PTZ, snapshot, or discovery. The `url` must carry no embedded credentials (`rtsp://user:pass@…` diff --git a/src/backend/genicam_aravis.rs b/src/backend/genicam_aravis.rs index c5a7479..ad88587 100644 --- a/src/backend/genicam_aravis.rs +++ b/src/backend/genicam_aravis.rs @@ -2228,6 +2228,7 @@ mod tests { fn fake_gige_request(capture_id: &str) -> CaptureRequest { CaptureRequest { capture_id: capture_id.to_owned(), + trigger_key: None, profile: fake_gige_profile(), maximum_frame_bytes: 76_800, timeout: Duration::from_secs(5), @@ -2254,6 +2255,7 @@ mod tests { let frame = session .capture(CaptureRequest { capture_id: "cap-1".to_owned(), + trigger_key: None, profile: profile(), maximum_frame_bytes: 1, timeout: Duration::from_secs(1), @@ -3311,6 +3313,7 @@ mod tests { let frame = session .capture(CaptureRequest { capture_id: "cap-frame-fidelity".to_owned(), + trigger_key: None, profile: requested, maximum_frame_bytes, timeout: Duration::from_secs(5), @@ -3387,6 +3390,7 @@ mod tests { let error = session .capture(CaptureRequest { capture_id: "cap-over-bound".to_owned(), + trigger_key: None, profile: profile(), maximum_frame_bytes: 1, timeout: Duration::from_secs(5), diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 1d80f93..55e9e40 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -85,6 +85,14 @@ pub struct ConnectRequest { pub struct CaptureRequest { /// Adapter-generated durable capture id. pub capture_id: String, + /// Opaque identity of the operator action or schedule occurrence this capture belongs to. + /// + /// Captures produced by one command request, one capture-group request, or one schedule + /// occurrence carry the same key. A backend compares it for equality and never parses it, which + /// is what keeps the seam protocol-neutral: nothing about EdgeCommons triggers crosses it beyond + /// "same action" or "different action". `None` means the caller has no trigger to report, and a + /// backend that does not model triggers ignores the field. + pub trigger_key: Option, /// Immutable effective profile. pub profile: CaptureProfile, /// Hard accepted source-frame ceiling. diff --git a/src/backend/onvif.rs b/src/backend/onvif.rs index 925dbaf..3b80110 100644 --- a/src/backend/onvif.rs +++ b/src/backend/onvif.rs @@ -4877,6 +4877,7 @@ wkWsh7u3nnr9fXRpWsamYEAKGzNo0istMB6rD6cMzNfRZCMk4rXuokYWOw== session .capture(CaptureRequest { capture_id: "closed-session".to_owned(), + trigger_key: None, profile, maximum_frame_bytes: 1_024, timeout: Duration::from_secs(1), @@ -6491,6 +6492,7 @@ wkWsh7u3nnr9fXRpWsamYEAKGzNo0istMB6rD6cMzNfRZCMk4rXuokYWOw== let frame = session .capture(CaptureRequest { capture_id: "capture-1".to_owned(), + trigger_key: None, profile, maximum_frame_bytes: 1_048_576, timeout: Duration::from_secs(2), @@ -6779,6 +6781,7 @@ wkWsh7u3nnr9fXRpWsamYEAKGzNo0istMB6rD6cMzNfRZCMk4rXuokYWOw== .expect("capture profile"); CaptureRequest { capture_id: "byte-fidelity".to_owned(), + trigger_key: None, profile, maximum_frame_bytes: 1_048_576, timeout: Duration::from_secs(5), diff --git a/src/backend/rtsp_backend.rs b/src/backend/rtsp_backend.rs index ca964fd..d6b1117 100644 --- a/src/backend/rtsp_backend.rs +++ b/src/backend/rtsp_backend.rs @@ -433,6 +433,7 @@ mod tests { let frame = session .capture(CaptureRequest { capture_id: "cap-live-1".to_string(), + trigger_key: None, profile, maximum_frame_bytes: 1_048_576, timeout: Duration::from_secs(15), diff --git a/src/backend/sim.rs b/src/backend/sim.rs index 7ae81ae..2226dbf 100644 --- a/src/backend/sim.rs +++ b/src/backend/sim.rs @@ -3,9 +3,15 @@ //! `SimBackend` is a production-configurable backend: its frames, timing, failures, //! capabilities, PTZ state, and presets pass through the same runtime as physical cameras. //! It allocates no image-sized buffer while idle. +//! +//! Its frames are synthetic by default. The `playlist` pattern instead replays a directory of real +//! image files, one per capture, so a downstream vision component receives genuine imagery through +//! the ordinary camera path: the same encoding stage, the same sidecar-before-image finalization, +//! the same catalog record, and the same `ImageCaptured` announcement. use std::collections::BTreeMap; use std::io::Cursor; +use std::path::{Path, PathBuf}; use std::time::Duration; use async_trait::async_trait; @@ -13,19 +19,22 @@ use tokio::time::Instant; use tokio_util::sync::CancellationToken; use bytes::Bytes; use chrono::Utc; -use image::ExtendedColorType; -use image::codecs::jpeg::JpegEncoder; -use serde_json::json; +use image::codecs::jpeg::{JpegDecoder, JpegEncoder}; +use image::{ExtendedColorType, GenericImageView, ImageDecoder, ImageFormat}; +use serde_json::{Value, json}; use super::{ CameraBackendFactory, CameraSession, CameraStatus, CaptureRequest, ConnectRequest, DiscoveryCandidate, DiscoveryRequest, }; -use crate::config::{BackendConfig, SimBackendConfig, SimPattern}; +use crate::config::{ + BackendConfig, SimBackendConfig, SimPattern, SimPlaylistAdvance, SimPlaylistConfig, + SimPlaylistOrder, +}; use crate::error::{CameraError, ErrorCode, Result}; use crate::model::{ - BackendKind, CameraCapabilities, CaptureFrame, CaptureMode, FrameTimestampQuality, PixelFormat, - PtzPreset, PtzRequest, PtzResult, PtzStatus, PtzVector, + BackendKind, CameraCapabilities, CaptureFrame, CaptureMode, FrameTimestampQuality, + OutputEncoding, PixelFormat, PtzPreset, PtzRequest, PtzResult, PtzStatus, PtzVector, }; /// Stateless factory for deterministic simulator sessions. @@ -65,7 +74,31 @@ impl CameraBackendFactory for SimBackendFactory { } () = tokio::time::sleep(delay) => {} } - Ok(Box::new(SimSession::new(request.instance_id, config))) + // The playlist is read HERE, once, and never again for this session: a directory walk is + // filesystem work with an unbounded worst case, and doing it per capture would put it on the + // acquisition deadline of every single frame. Reading it at connect also gives the replay a + // fixed list to be deterministic about -- `sb/reconnect` is what re-reads a changed directory. + let identity = simulated_id(&config, &request.instance_id); + let seed = simulated_seed(&config, &identity); + let playlist = match config.frame.pattern.playlist() { + Some(settings) => { + let settings = settings.clone(); + Some( + tokio::task::spawn_blocking(move || Playlist::load(&settings, seed)) + .await + .map_err(|error| CameraError::Backend { + backend: "sim", + message: format!("simulated playlist load task failed: {error}"), + })??, + ) + } + None => None, + }; + Ok(Box::new(SimSession::new( + request.instance_id, + config, + playlist, + ))) } } @@ -78,21 +111,28 @@ struct SimSession { position: PtzVector, moving: bool, presets: BTreeMap, PtzVector)>, + /// Present exactly when the configured pattern replays files. + playlist: Option, } impl SimSession { - fn new(instance_id: String, config: SimBackendConfig) -> Self { - let id = config - .simulated_id - .clone() - .unwrap_or_else(|| instance_id.clone()); + fn new(instance_id: String, config: SimBackendConfig, playlist: Option) -> Self { + let id = simulated_id(&config, &instance_id); let ptz = config.ptz.supported; let presets = config.ptz.presets_supported; Self { id: id.clone(), capabilities: CameraCapabilities { capture_modes: vec![CaptureMode::Simulated], - pixel_formats: vec![config.frame.pixel_format], + // A playlist reports what a replayed FILE is, not what the synthetic generator was + // configured to emit: a JPEG passed through byte for byte, or the pixels a decoded + // file yields. Advertising `frame.pixelFormat` here would describe a generator this + // session never runs. + pixel_formats: if playlist.is_some() { + vec![PixelFormat::Jpeg, PixelFormat::Rgb8, PixelFormat::Mono8] + } else { + vec![config.frame.pixel_format] + }, software_trigger: false, snapshot_uri: false, rtsp: false, @@ -116,6 +156,7 @@ impl SimSession { }, moving: false, presets: BTreeMap::new(), + playlist, } } @@ -149,7 +190,7 @@ impl SimSession { fn frame_recipe(&self) -> FrameRecipe { FrameRecipe { frame: self.config.frame.clone(), - seed: self.config.seed.unwrap_or_else(|| stable_seed(&self.id)), + seed: simulated_seed(&self.config, &self.id), } } } @@ -193,7 +234,7 @@ fn synthesize_frame(recipe: &FrameRecipe, ordinal: u64, limit: u64) -> Result { + let index = playlist.take(request.trigger_key.as_deref())?; + let entry = playlist.entry(index).clone(); + let root = playlist.root.clone(); + let encoding = request.profile.output.encoding; + tokio::task::spawn_blocking(move || { + read_playlist_frame(&entry, &root, index, limit, encoding) + }) + .await + .map_err(|error| CameraError::Backend { + backend: "sim", + message: format!("simulated playlist read task failed: {error}"), + })?? + } + None => { + let recipe = self.frame_recipe(); + let frame = recipe.frame.clone(); + let bytes = + tokio::task::spawn_blocking(move || synthesize_frame(&recipe, ordinal, limit)) + .await + .map_err(|error| CameraError::Backend { + backend: "sim", + message: format!("simulated frame synthesis task failed: {error}"), + })??; + AcquiredFrame { + bytes, + width: frame.width, + height: frame.height, + pixel_format: frame.pixel_format, + playlist: None, + } + } + }; if Self::should_fire(self.config.faults.incomplete_every_nth_capture, ordinal) { - bytes.truncate(bytes.len().saturating_sub(1)); + acquired + .bytes + .truncate(acquired.bytes.len().saturating_sub(1)); return Err(CameraError::Backend { backend: "sim", message: "configured deterministic incomplete frame".to_string(), @@ -302,19 +376,29 @@ impl CameraSession for SimSession { } } let now = Utc::now(); + let mut backend_metadata = BTreeMap::from([ + ("simulatedId".to_string(), json!(self.id)), + ("captureOrdinal".to_string(), json!(ordinal)), + ("captureId".to_string(), json!(request.capture_id)), + ]); + // The originating file travels with the frame, so the terminal announcement, the catalog + // record, and the metadata sidecar all name the image that was replayed. Without it a replayed + // capture is indistinguishable from a synthetic one once it is on disk. + if let Some(facts) = acquired.playlist { + backend_metadata.insert( + "playlist".to_string(), + json!({ "sourcePath": facts.source_path, "index": facts.index }), + ); + } Ok(CaptureFrame { - bytes: Bytes::from(bytes), - width: self.config.frame.width, - height: self.config.frame.height, - pixel_format: self.config.frame.pixel_format, + bytes: Bytes::from(acquired.bytes), + width: acquired.width, + height: acquired.height, + pixel_format: acquired.pixel_format, capture_mode: CaptureMode::Simulated, source_timestamp: Some(now), timestamp_quality: FrameTimestampQuality::Camera, - backend_metadata: BTreeMap::from([ - ("simulatedId".to_string(), json!(self.id)), - ("captureOrdinal".to_string(), json!(ordinal)), - ("captureId".to_string(), json!(request.capture_id)), - ]), + backend_metadata, }) } @@ -465,6 +549,416 @@ impl SimSession { } +/// The simulated device identity: the configured override, or the camera instance id. +fn simulated_id(config: &SimBackendConfig, instance_id: &str) -> String { + config + .simulated_id + .clone() + .unwrap_or_else(|| instance_id.to_owned()) +} + +/// The generator/shuffle seed: the configured value, or a stable hash of the simulated identity. +fn simulated_seed(config: &SimBackendConfig, id: &str) -> u64 { + config.seed.unwrap_or_else(|| stable_seed(id)) +} + +/// Hard ceiling on playlist members, so a directory pointed at a whole filesystem fails fast. +const MAX_PLAYLIST_FILES: usize = 10_000; +/// Hard ceiling on directory nesting, for the same reason. +const MAX_PLAYLIST_DEPTH: usize = 32; + +const JPEG_MAGIC: [u8; 3] = [0xff, 0xd8, 0xff]; +const PNG_MAGIC: [u8; 8] = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +/// One acquisition's bytes plus the facts the frame reports about them. +struct AcquiredFrame { + bytes: Vec, + width: u32, + height: u32, + pixel_format: PixelFormat, + playlist: Option, +} + +/// Where a replayed frame came from. +struct PlaylistFacts { + /// `/`-separated path relative to the playlist directory. + source_path: String, + /// Position of the file in the replay order. + index: usize, +} + +/// One playlist member. +#[derive(Debug, Clone, PartialEq, Eq)] +struct PlaylistEntry { + /// `/`-separated path relative to the playlist directory, and the `sorted` order's sort key. + relative: String, + /// Absolute path opened at capture time. + absolute: PathBuf, +} + +/// A directory of real image files, read once at connect and replayed as captures. +/// +/// Containment is structural rather than checked after the fact: the walk rejects every symbolic +/// link it meets and descends only real directories under the canonicalized root, so no member can +/// name a file outside the directory. [`read_playlist_frame`] re-checks at capture time, because the +/// directory is a live filesystem and a member can be replaced between connect and capture. +#[derive(Debug)] +struct Playlist { + /// Directory as configured, for diagnostics. + directory: PathBuf, + /// Canonicalized directory, for containment. + root: PathBuf, + entries: Vec, + /// Position the next capture replays. + cursor: usize, + loop_playlist: bool, + advance: SimPlaylistAdvance, + /// Trigger the current position was taken for, under `perTrigger`. + last_trigger: Option, + /// Position the last capture replayed. + current: Option, +} + +impl Playlist { + /// Reads and orders the directory. Blocking: callers run it on the blocking pool. + /// + /// # Errors + /// `DEVICE_UNAVAILABLE` when the directory cannot be read, holds a symbolic link, nests or grows + /// past the bounds above, or matches no file at all -- a camera that can never produce a frame + /// must fail to connect rather than accept captures it will refuse one at a time. + fn load(config: &SimPlaylistConfig, seed: u64) -> Result { + let root = std::fs::canonicalize(&config.directory).map_err(|error| { + playlist_unavailable(format!("playlist directory cannot be opened: {error}")) + })?; + let mut entries = Vec::new(); + collect_playlist(&root, &root, &config.include, 0, &mut entries)?; + if entries.is_empty() { + return Err(playlist_unavailable( + "playlist directory holds no file matching the include globs", + )); + } + entries.sort_by(|left, right| left.relative.cmp(&right.relative)); + if config.order == SimPlaylistOrder::Seeded { + shuffle_playlist(&mut entries, seed); + } + Ok(Self { + directory: config.directory.clone(), + root, + entries, + cursor: 0, + loop_playlist: config.loop_playlist, + advance: config.advance, + last_trigger: None, + current: None, + }) + } + + /// The member at `index`. + fn entry(&self, index: usize) -> &PlaylistEntry { + &self.entries[index] + } + + /// The position this capture replays, moving the cursor when the advance policy says to. + /// + /// # Errors + /// `DEVICE_UNAVAILABLE` once an unlooped playlist has replayed its last file. + fn take(&mut self, trigger: Option<&str>) -> Result { + let repeat = self.advance == SimPlaylistAdvance::PerTrigger + && trigger.is_some() + && self.last_trigger.as_deref() == trigger; + if let Some(current) = self.current.filter(|_| repeat) { + return Ok(current); + } + if self.cursor >= self.entries.len() { + if !self.loop_playlist { + return Err(playlist_unavailable( + "playlist is spent: every file has been replayed and loop is false", + )); + } + self.cursor = 0; + } + let index = self.cursor; + self.cursor += 1; + self.current = Some(index); + self.last_trigger = trigger.map(str::to_owned); + Ok(index) + } + + /// Position the next capture replays, or the file count once a spent playlist cannot restart. + fn next_index(&self) -> usize { + if self.cursor >= self.entries.len() && self.loop_playlist { + 0 + } else { + self.cursor + } + } + + /// The playlist as session status reports it. + fn diagnostics(&self) -> Value { + json!({ + "count": self.entries.len(), + "index": self.next_index(), + "directory": self.directory.display().to_string(), + }) + } +} + +fn playlist_unavailable(message: impl std::fmt::Display) -> CameraError { + CameraError::rejected(ErrorCode::DeviceUnavailable, format!("sim {message}")) +} + +/// Walks `directory` and appends every included file. Deterministic, and it follows no link. +fn collect_playlist( + root: &Path, + directory: &Path, + include: &[String], + depth: usize, + entries: &mut Vec, +) -> Result<()> { + if depth > MAX_PLAYLIST_DEPTH { + return Err(playlist_unavailable(format!( + "playlist directory nests deeper than {MAX_PLAYLIST_DEPTH} levels" + ))); + } + let listing = std::fs::read_dir(directory).map_err(|error| { + playlist_unavailable(format!("playlist directory cannot be listed: {error}")) + })?; + let mut children = Vec::new(); + for child in listing { + let child = child.map_err(|error| { + playlist_unavailable(format!("playlist directory entry cannot be read: {error}")) + })?; + children.push(child.path()); + } + children.sort(); + for path in children { + let relative = relative_token(root, &path).ok_or_else(|| { + playlist_unavailable("playlist paths must be valid UTF-8 with no parent references") + })?; + let metadata = std::fs::symlink_metadata(&path).map_err(|error| { + playlist_unavailable(format!("playlist entry {relative} cannot be read: {error}")) + })?; + if metadata.is_symlink() { + return Err(playlist_unavailable(format!( + "playlist entry {relative} is a symbolic link, which can name a file outside the playlist directory" + ))); + } + if metadata.is_dir() { + collect_playlist(root, &path, include, depth + 1, entries)?; + continue; + } + if !metadata.is_file() { + continue; + } + if !include.iter().any(|glob| glob_matches(glob, &relative)) { + continue; + } + if entries.len() >= MAX_PLAYLIST_FILES { + return Err(playlist_unavailable(format!( + "playlist directory holds more than {MAX_PLAYLIST_FILES} matching files" + ))); + } + entries.push(PlaylistEntry { + relative, + absolute: path, + }); + } + Ok(()) +} + +/// `path` relative to `root` as a `/`-separated token, or `None` when it is not plainly nested. +fn relative_token(root: &Path, path: &Path) -> Option { + let relative = path.strip_prefix(root).ok()?; + let mut token = String::new(); + for component in relative.components() { + let std::path::Component::Normal(part) = component else { + return None; + }; + if !token.is_empty() { + token.push('/'); + } + token.push_str(part.to_str()?); + } + Some(token) +} + +/// Fisher-Yates over the sorted list, so `seeded` is a shuffle and still reproducible. +fn shuffle_playlist(entries: &mut [PlaylistEntry], seed: u64) { + let mut state = seed; + for index in (1..entries.len()).rev() { + let pick = (next_random(&mut state) % (index as u64 + 1)) as usize; + entries.swap(index, pick); + } +} + +/// SplitMix64. +/// +/// Owned rather than taken from `rand` on purpose: the order a `seeded` playlist replays in is +/// configuration-visible behaviour, and pinning the generator here means a dependency bump cannot +/// silently reorder a fixture that a downstream test asserts against. +fn next_random(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut value = *state; + value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + value ^ (value >> 31) +} + +/// Matches one include glob against a `/`-separated relative path. +/// +/// `**` spans any number of path segments, `*` matches within one segment, `?` matches one +/// character, and everything else is a literal. Matching is case-sensitive, so a directory behaves +/// the same on a case-insensitive filesystem as on a case-sensitive one. +fn glob_matches(pattern: &str, text: &str) -> bool { + let pattern: Vec = pattern.chars().collect(); + let text: Vec = text.chars().collect(); + glob_match(&pattern, &text) +} + +fn glob_match(pattern: &[char], text: &[char]) -> bool { + match pattern.first() { + None => text.is_empty(), + Some('*') if pattern.get(1) == Some(&'*') => { + let rest = &pattern[2..]; + // `**/name` also matches `name`: the separator after `**` stands for zero segments too. + if rest.first() == Some(&'/') && glob_match(&rest[1..], text) { + return true; + } + (0..=text.len()).any(|split| glob_match(rest, &text[split..])) + } + Some('*') => { + let rest = &pattern[1..]; + let bound = text + .iter() + .position(|value| *value == '/') + .unwrap_or(text.len()); + (0..=bound).any(|split| glob_match(rest, &text[split..])) + } + Some('?') => { + matches!(text.first(), Some(value) if *value != '/') + && glob_match(&pattern[1..], &text[1..]) + } + Some(literal) => { + matches!(text.first(), Some(value) if value == literal) + && glob_match(&pattern[1..], &text[1..]) + } + } +} + +/// Reads one playlist file and turns it into a frame. Blocking: callers run it on the blocking pool. +/// +/// A JPEG replayed into a byte-preserving output (`passthrough` or `raw`) is handed on exactly as it +/// sits on disk, so the announced `image.sha256` IS the file's digest and a consumer can verify the +/// installed artifact against its source. Every other combination decodes to pixels and lets the +/// ordinary encoding stage produce the requested file, because this component's passthrough contract +/// is a complete JPEG source and a re-encode is the only honest way to answer for anything else. +/// +/// # Errors +/// `RESOURCE_LIMIT` when the file or its decoded pixels exceed the accepted frame ceiling, +/// `UNSUPPORTED_PIXEL_FORMAT` when the file is not a decodable JPEG or PNG, and +/// `DEVICE_UNAVAILABLE` when it has disappeared or no longer resolves inside the playlist directory. +fn read_playlist_frame( + entry: &PlaylistEntry, + root: &Path, + index: usize, + limit: u64, + encoding: OutputEncoding, +) -> Result { + let relative = entry.relative.as_str(); + let metadata = std::fs::symlink_metadata(&entry.absolute).map_err(|error| { + playlist_unavailable(format!("playlist file {relative} cannot be read: {error}")) + })?; + if metadata.is_symlink() { + return Err(playlist_unavailable(format!( + "playlist file {relative} became a symbolic link after the playlist was read" + ))); + } + let canonical = std::fs::canonicalize(&entry.absolute).map_err(|error| { + playlist_unavailable(format!( + "playlist file {relative} cannot be resolved: {error}" + )) + })?; + if !canonical.starts_with(root) { + return Err(playlist_unavailable(format!( + "playlist file {relative} resolves outside the playlist directory" + ))); + } + if metadata.len() > limit { + return Err(CameraError::rejected( + ErrorCode::ResourceLimit, + format!("playlist file {relative} exceeds the accepted maximum frame size"), + )); + } + let bytes = std::fs::read(&entry.absolute).map_err(|error| { + playlist_unavailable(format!("playlist file {relative} cannot be read: {error}")) + })?; + let format = playlist_format(&bytes).ok_or_else(|| { + CameraError::rejected( + ErrorCode::UnsupportedPixelFormat, + format!("playlist file {relative} is neither JPEG nor PNG"), + ) + })?; + let facts = PlaylistFacts { + source_path: entry.relative.clone(), + index, + }; + if format == ImageFormat::Jpeg + && matches!(encoding, OutputEncoding::Passthrough | OutputEncoding::Raw) + { + let (width, height) = JpegDecoder::new(Cursor::new(&bytes)) + .map_err(|error| { + CameraError::rejected( + ErrorCode::UnsupportedPixelFormat, + format!("playlist file {relative} is not a decodable JPEG: {error}"), + ) + })? + .dimensions(); + return Ok(AcquiredFrame { + bytes, + width, + height, + pixel_format: PixelFormat::Jpeg, + playlist: Some(facts), + }); + } + let decoded = image::load_from_memory_with_format(&bytes, format).map_err(|error| { + CameraError::rejected( + ErrorCode::UnsupportedPixelFormat, + format!("playlist file {relative} cannot be decoded: {error}"), + ) + })?; + let (width, height) = decoded.dimensions(); + let (bytes, pixel_format) = if decoded.color().has_color() { + (decoded.to_rgb8().into_raw(), PixelFormat::Rgb8) + } else { + (decoded.to_luma8().into_raw(), PixelFormat::Mono8) + }; + if bytes.len() as u64 > limit { + return Err(CameraError::rejected( + ErrorCode::ResourceLimit, + format!("playlist file {relative} decodes past the accepted maximum frame size"), + )); + } + Ok(AcquiredFrame { + bytes, + width, + height, + pixel_format, + playlist: Some(facts), + }) +} + +/// The file's real format, from its magic bytes rather than its name. +fn playlist_format(bytes: &[u8]) -> Option { + if bytes.starts_with(&JPEG_MAGIC) { + Some(ImageFormat::Jpeg) + } else if bytes.starts_with(&PNG_MAGIC) { + Some(ImageFormat::Png) + } else { + None + } +} + fn stable_seed(value: &str) -> u64 { use sha2::{Digest, Sha256}; let digest = Sha256::digest(value.as_bytes()); @@ -478,7 +972,7 @@ fn fill_pattern( width: u32, height: u32, format: PixelFormat, - pattern: SimPattern, + pattern: &SimPattern, seed: u64, ordinal: u64, ) { @@ -503,7 +997,7 @@ fn fill_pattern( } fn pixel( - pattern: SimPattern, + pattern: &SimPattern, x: u32, y: u32, width: u32, @@ -539,6 +1033,9 @@ fn pixel( let value = seed.wrapping_add(ordinal); [value as u8, (value >> 8) as u8, (value >> 16) as u8] } + SimPattern::Playlist(_) => { + unreachable!("a playlist frame is read from disk, never generated pixel by pixel") + } } } @@ -600,6 +1097,7 @@ mod tests { let mut second = session(config).await; let request = || CaptureRequest { capture_id: "cap-1".to_string(), + trigger_key: None, profile: profile(), maximum_frame_bytes: 1_000_000, timeout: Duration::from_secs(1), @@ -616,6 +1114,7 @@ mod tests { let mut camera = session(json!({"type":"sim","faults":{"failEveryNthCapture":2}})).await; let request = || CaptureRequest { capture_id: "cap".to_string(), + trigger_key: None, profile: profile(), maximum_frame_bytes: 1_000_000, timeout: Duration::from_secs(1), @@ -669,6 +1168,7 @@ mod tests { let error = camera .capture(CaptureRequest { capture_id: "cap".to_string(), + trigger_key: None, profile: profile(), maximum_frame_bytes: 1_000_000, timeout: Duration::from_secs(1), @@ -685,6 +1185,7 @@ mod tests { session(json!({"type":"sim","faults":{"incompleteEveryNthCapture":1}})).await; let request = || CaptureRequest { capture_id: "cap-fault".to_string(), + trigger_key: None, profile: profile(), maximum_frame_bytes: 1_000_000, timeout: Duration::from_secs(1), @@ -763,6 +1264,7 @@ mod tests { async fn simulator_emits_declared_raw_and_jpeg_formats_with_frame_bounds() { let request = || CaptureRequest { capture_id: "format-check".to_owned(), + trigger_key: None, profile: profile(), maximum_frame_bytes: 1_000_000, timeout: Duration::from_secs(1), @@ -988,4 +1490,588 @@ mod tests { ErrorCode::PtzRangeError ); } + + // ---- playlist pattern ------------------------------------------------- + + use std::fs; + use tempfile::TempDir; + + /// A deterministic RGB8 buffer whose content depends on `tint`. + fn rgb_pixels(width: u32, height: u32, tint: u8) -> Vec { + (0..(width * height * 3)) + .map(|index| (index as u8).wrapping_mul(7).wrapping_add(tint)) + .collect() + } + + fn jpeg_bytes(width: u32, height: u32, tint: u8) -> Vec { + let mut bytes = Vec::new(); + JpegEncoder::new_with_quality(Cursor::new(&mut bytes), 92) + .encode( + &rgb_pixels(width, height, tint), + width, + height, + ExtendedColorType::Rgb8, + ) + .expect("the fixture encoder produces a JPEG"); + bytes + } + + fn png_bytes(width: u32, height: u32, tint: u8) -> Vec { + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(Cursor::new(&mut bytes), width, height); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().expect("PNG header"); + writer + .write_image_data(&rgb_pixels(width, height, tint)) + .expect("PNG pixels"); + } + bytes + } + + fn grayscale_png_bytes(width: u32, height: u32) -> Vec { + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(Cursor::new(&mut bytes), width, height); + encoder.set_color(png::ColorType::Grayscale); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().expect("PNG header"); + writer + .write_image_data(&vec![0x40_u8; (width * height) as usize]) + .expect("PNG pixels"); + } + bytes + } + + /// Writes one fixture file, creating whatever directories its relative path names. + fn write_fixture(root: &Path, relative: &str, bytes: &[u8]) -> PathBuf { + let path = root.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("fixture parent directory"); + } + fs::write(&path, bytes).expect("fixture file"); + path + } + + /// A three-JPEG playlist directory: `a.jpg`, `b.jpg`, `nested/c.jpg`. + fn three_jpeg_directory() -> TempDir { + let directory = TempDir::new().expect("playlist directory"); + write_fixture(directory.path(), "a.jpg", &jpeg_bytes(8, 4, 1)); + write_fixture(directory.path(), "b.jpg", &jpeg_bytes(8, 4, 2)); + write_fixture(directory.path(), "nested/c.jpg", &jpeg_bytes(8, 4, 3)); + directory + } + + fn playlist_settings(directory: &Path, overrides: serde_json::Value) -> SimPlaylistConfig { + let mut value = json!({ "directory": directory.display().to_string() }); + let object = value.as_object_mut().expect("a playlist object"); + for (key, entry) in overrides.as_object().expect("overrides object") { + object.insert(key.clone(), entry.clone()); + } + serde_json::from_value(value).expect("valid playlist settings") + } + + fn playlist_backend(directory: &Path, overrides: serde_json::Value) -> serde_json::Value { + let mut playlist = json!({ "directory": directory.display().to_string() }); + let object = playlist.as_object_mut().expect("a playlist object"); + for (key, entry) in overrides.as_object().expect("overrides object") { + object.insert(key.clone(), entry.clone()); + } + json!({ + "type": "sim", + "seed": 11, + "captureDelayMs": 0, + "frame": { "pattern": { "playlist": playlist } } + }) + } + + fn encoding_profile(encoding: &str) -> CaptureProfile { + serde_json::from_value(json!({ "output": { "encoding": encoding } })) + .expect("valid capture profile") + } + + fn playlist_request(capture_id: &str, encoding: &str, trigger: Option<&str>) -> CaptureRequest { + CaptureRequest { + capture_id: capture_id.to_owned(), + trigger_key: trigger.map(str::to_owned), + profile: encoding_profile(encoding), + maximum_frame_bytes: 1_000_000, + timeout: Duration::from_secs(1), + cancellation: CancellationToken::new(), + } + } + + /// A connect attempt whose failure is the point. + async fn connect_failure(value: serde_json::Value) -> CameraError { + match SimBackendFactory::new() + .connect(ConnectRequest { + instance_id: "cam-a".to_string(), + backend: backend(value), + timeout: Duration::from_secs(1), + cancellation: CancellationToken::new(), + }) + .await + { + Err(error) => error, + Ok(_) => panic!("this configuration must not produce a live session"), + } + } + + #[test] + fn include_globs_span_segments_bound_wildcards_and_stay_case_sensitive() { + assert!(glob_matches("**/*.jpg", "a.jpg")); + assert!(glob_matches("**/*.jpg", "one/two/a.jpg")); + assert!(glob_matches("*.jpg", "a.jpg")); + // A single `*` stops at a separator, which is the whole reason `**` exists. + assert!(!glob_matches("*.jpg", "one/a.jpg")); + assert!(glob_matches("one/*/b.png", "one/two/b.png")); + assert!(!glob_matches("one/*/b.png", "one/two/three/b.png")); + assert!(glob_matches("cam?/a.jpg", "cam1/a.jpg")); + assert!(!glob_matches("cam?/a.jpg", "cam/a.jpg")); + assert!(!glob_matches("**/*.jpg", "a.JPG")); + assert!(glob_matches("**", "any/depth/at/all.png")); + assert!(!glob_matches("", "a.jpg")); + } + + #[test] + fn a_playlist_takes_only_included_files_and_orders_them_by_relative_path() { + let directory = TempDir::new().expect("playlist directory"); + write_fixture(directory.path(), "b.jpg", &jpeg_bytes(4, 4, 1)); + write_fixture(directory.path(), "a.png", &png_bytes(4, 4, 2)); + write_fixture(directory.path(), "nested/c.jpeg", &jpeg_bytes(4, 4, 3)); + write_fixture(directory.path(), "notes.txt", b"not an image"); + write_fixture(directory.path(), "nested/thumbnail.gif", b"not included"); + + let playlist = Playlist::load(&playlist_settings(directory.path(), json!({})), 7) + .expect("a playlist of the default image extensions"); + assert_eq!( + playlist + .entries + .iter() + .map(|entry| entry.relative.as_str()) + .collect::>(), + vec!["a.png", "b.jpg", "nested/c.jpeg"], + "the default includes take jpg/jpeg/png at any depth and nothing else" + ); + + let narrowed = Playlist::load( + &playlist_settings(directory.path(), json!({ "include": ["nested/**/*.jpeg"] })), + 7, + ) + .expect("a narrowed playlist"); + assert_eq!( + narrowed + .entries + .iter() + .map(|entry| entry.relative.as_str()) + .collect::>(), + vec!["nested/c.jpeg"] + ); + } + + #[test] + fn a_seeded_order_is_a_stable_shuffle_of_the_sorted_order() { + let directory = TempDir::new().expect("playlist directory"); + for index in 0..8 { + write_fixture( + directory.path(), + &format!("frame-{index}.jpg"), + &jpeg_bytes(4, 4, index as u8), + ); + } + let settings = playlist_settings(directory.path(), json!({ "order": "seeded" })); + let sorted = Playlist::load(&playlist_settings(directory.path(), json!({})), 4_242) + .expect("sorted playlist"); + let first = Playlist::load(&settings, 4_242).expect("seeded playlist"); + let again = Playlist::load(&settings, 4_242).expect("seeded playlist"); + let other_seed = Playlist::load(&settings, 99).expect("seeded playlist"); + + assert_eq!( + first.entries, again.entries, + "the same seed must replay the same order, or a downstream fixture cannot be asserted" + ); + assert_ne!( + first.entries, sorted.entries, + "a seeded order that equals the sorted order is not a shuffle" + ); + assert_ne!(first.entries, other_seed.entries); + let mut permuted = first.entries.clone(); + permuted.sort_by(|left, right| left.relative.cmp(&right.relative)); + assert_eq!( + permuted, sorted.entries, + "a shuffle reorders the playlist; it never adds or drops a file" + ); + } + + #[test] + fn a_looping_playlist_restarts_and_an_unlooped_one_refuses_after_its_last_file() { + let directory = three_jpeg_directory(); + let mut looping = Playlist::load(&playlist_settings(directory.path(), json!({})), 1) + .expect("looping playlist"); + assert_eq!( + (0..5) + .map(|_| looping + .take(None) + .expect("a looping playlist never runs out")) + .collect::>(), + vec![0, 1, 2, 0, 1] + ); + + let mut once = Playlist::load( + &playlist_settings(directory.path(), json!({ "loop": false })), + 1, + ) + .expect("single-pass playlist"); + assert_eq!( + (0..3) + .map(|_| once.take(None).expect("every file is replayed once")) + .collect::>(), + vec![0, 1, 2] + ); + let spent = once.take(None).expect_err("a spent playlist has no frame"); + assert_eq!(spent.code(), ErrorCode::DeviceUnavailable); + assert!(spent.to_string().contains("spent")); + assert_eq!( + once.diagnostics()["index"], + 3, + "a spent playlist reports the end rather than pretending to be back at the start" + ); + + let mut wrapped = Playlist::load(&playlist_settings(directory.path(), json!({})), 1) + .expect("looping playlist"); + for _ in 0..3 { + wrapped.take(None).expect("every file replayed once"); + } + assert_eq!( + wrapped.diagnostics()["index"], + 0, + "a looping playlist reports the file the next capture will replay" + ); + } + + #[test] + fn per_trigger_holds_one_file_for_captures_that_share_a_trigger() { + let directory = three_jpeg_directory(); + let mut per_trigger = Playlist::load( + &playlist_settings(directory.path(), json!({ "advance": "perTrigger" })), + 1, + ) + .expect("per-trigger playlist"); + assert_eq!(per_trigger.take(Some("schedule:minute@0")).unwrap(), 0); + assert_eq!( + per_trigger.take(Some("schedule:minute@0")).unwrap(), + 0, + "a second capture under the same trigger replays the same file" + ); + assert_eq!(per_trigger.take(Some("schedule:minute@60000")).unwrap(), 1); + assert_eq!( + per_trigger.take(None).unwrap(), + 2, + "a capture with no trigger to compare always takes the next file" + ); + + let mut per_capture = Playlist::load(&playlist_settings(directory.path(), json!({})), 1) + .expect("per-capture playlist"); + assert_eq!(per_capture.take(Some("command:one")).unwrap(), 0); + assert_eq!( + per_capture.take(Some("command:one")).unwrap(), + 1, + "the default advances on every capture whatever the trigger says" + ); + } + + #[test] + fn a_relative_token_refuses_a_path_that_is_not_plainly_nested() { + let root = Path::new("/playlist"); + assert_eq!( + relative_token(root, &root.join("nested").join("b.jpg")).as_deref(), + Some("nested/b.jpg") + ); + assert_eq!( + relative_token(root, Path::new("/playlist/../b.jpg")), + None, + "a parent reference is how a relative path leaves the directory it is relative to" + ); + assert_eq!(relative_token(root, Path::new("/elsewhere/b.jpg")), None); + } + + #[test] + fn the_playlist_walk_is_bounded_in_depth_and_in_file_count() { + let directory = three_jpeg_directory(); + let include = vec!["**/*.jpg".to_string()]; + + let mut entries = Vec::new(); + let too_deep = collect_playlist( + directory.path(), + directory.path(), + &include, + MAX_PLAYLIST_DEPTH + 1, + &mut entries, + ) + .expect_err("a tree deeper than the bound is refused rather than walked"); + assert_eq!(too_deep.code(), ErrorCode::DeviceUnavailable); + assert!(too_deep.to_string().contains("nests deeper")); + + let mut already_full: Vec = (0..MAX_PLAYLIST_FILES) + .map(|index| PlaylistEntry { + relative: format!("{index}.jpg"), + absolute: directory.path().join(format!("{index}.jpg")), + }) + .collect(); + let too_many = collect_playlist( + directory.path(), + directory.path(), + &include, + 0, + &mut already_full, + ) + .expect_err("a directory with more matching files than the bound is refused"); + assert!(too_many.to_string().contains("more than")); + } + + #[tokio::test] + async fn a_member_that_disappears_after_the_playlist_was_read_fails_only_its_capture() { + let directory = TempDir::new().expect("playlist directory"); + write_fixture(directory.path(), "a.jpg", &jpeg_bytes(4, 4, 1)); + write_fixture(directory.path(), "b.jpg", &jpeg_bytes(4, 4, 2)); + let mut camera = session(playlist_backend( + directory.path(), + json!({ "advance": "perCapture" }), + )) + .await; + fs::remove_file(directory.path().join("a.jpg")).expect("remove the first member"); + + let gone = camera + .capture(playlist_request("cap-1", "passthrough", None)) + .await + .expect_err("a member that is no longer there is not a frame"); + assert_eq!(gone.code(), ErrorCode::DeviceUnavailable); + assert!(gone.to_string().contains("cannot be read")); + camera + .capture(playlist_request("cap-2", "passthrough", None)) + .await + .expect("the rest of the playlist still replays"); + } + + #[tokio::test] + async fn a_member_the_decoder_or_the_frame_ceiling_rejects_fails_its_capture() { + let directory = TempDir::new().expect("playlist directory"); + // Intact PNG signature, ruined payload: the format is recognized and the decode still fails. + let mut corrupt = png_bytes(4, 4, 1); + corrupt.truncate(40); + write_fixture(directory.path(), "a.png", &corrupt); + write_fixture(directory.path(), "b.png", &grayscale_png_bytes(128, 128)); + + let mut camera = session(playlist_backend(directory.path(), json!({}))).await; + let undecodable = camera + .capture(playlist_request("cap-1", "png", None)) + .await + .expect_err("a truncated PNG is not a frame"); + assert_eq!(undecodable.code(), ErrorCode::UnsupportedPixelFormat); + assert!(undecodable.to_string().contains("cannot be decoded")); + + // A compressed file can sit well inside the ceiling that its pixels blow straight through, + // which is why the decoded buffer is measured as well as the file. + let over_ceiling = camera + .capture(CaptureRequest { + maximum_frame_bytes: 4_096, + ..playlist_request("cap-2", "png", None) + }) + .await + .expect_err("the decoded frame is bounded too"); + assert_eq!(over_ceiling.code(), ErrorCode::ResourceLimit); + assert!(over_ceiling.to_string().contains("decodes past")); + } + + #[tokio::test] + async fn a_replayed_jpeg_reaches_the_pipeline_as_the_bytes_on_disk() { + let directory = TempDir::new().expect("playlist directory"); + let first = jpeg_bytes(16, 12, 5); + let second = jpeg_bytes(8, 8, 9); + write_fixture(directory.path(), "a.jpg", &first); + write_fixture(directory.path(), "b.jpg", &second); + + let mut camera = session(playlist_backend(directory.path(), json!({}))).await; + let frame = camera + .capture(playlist_request("cap-1", "passthrough", None)) + .await + .expect("the first playlist file"); + assert_eq!( + frame.bytes.as_ref(), + first.as_slice(), + "a passthrough capture must install the file byte for byte, or its sha256 is not the \ + digest of the image it claims to have replayed" + ); + assert_eq!((frame.width, frame.height), (16, 12)); + assert_eq!(frame.pixel_format, PixelFormat::Jpeg); + assert_eq!(frame.capture_mode, CaptureMode::Simulated); + assert_eq!(frame.backend_metadata["playlist"]["sourcePath"], "a.jpg"); + assert_eq!(frame.backend_metadata["playlist"]["index"], 0); + + let next = camera + .capture(playlist_request("cap-2", "passthrough", None)) + .await + .expect("the second playlist file"); + assert_eq!(next.bytes.as_ref(), second.as_slice()); + assert_eq!((next.width, next.height), (8, 8)); + assert_eq!(next.backend_metadata["playlist"]["sourcePath"], "b.jpg"); + assert_eq!(next.backend_metadata["playlist"]["index"], 1); + } + + #[tokio::test] + async fn a_replayed_file_is_decoded_when_the_profile_asks_for_a_re_encode() { + let directory = TempDir::new().expect("playlist directory"); + write_fixture(directory.path(), "colour.png", &png_bytes(6, 5, 3)); + write_fixture(directory.path(), "grey.png", &grayscale_png_bytes(6, 5)); + write_fixture(directory.path(), "photo.jpg", &jpeg_bytes(6, 5, 4)); + + let mut camera = session(playlist_backend(directory.path(), json!({}))).await; + let colour = camera + .capture(playlist_request("cap-1", "png", None)) + .await + .expect("a colour PNG decodes to RGB8"); + assert_eq!(colour.pixel_format, PixelFormat::Rgb8); + assert_eq!((colour.width, colour.height), (6, 5)); + assert_eq!(colour.bytes.len(), 6 * 5 * 3); + + let grey = camera + .capture(playlist_request("cap-2", "png", None)) + .await + .expect("a grayscale PNG decodes to Mono8"); + assert_eq!(grey.pixel_format, PixelFormat::Mono8); + assert_eq!(grey.bytes.len(), 6 * 5); + + let photo = camera + .capture(playlist_request("cap-3", "png", None)) + .await + .expect("a JPEG asked for as PNG is decoded rather than refused"); + assert_eq!(photo.pixel_format, PixelFormat::Rgb8); + assert_eq!(photo.bytes.len(), 6 * 5 * 3); + assert_eq!( + photo.backend_metadata["playlist"]["sourcePath"], + "photo.jpg" + ); + } + + #[tokio::test] + async fn playlist_capabilities_and_status_describe_the_replay() { + let directory = three_jpeg_directory(); + let mut camera = session(playlist_backend(directory.path(), json!({}))).await; + assert_eq!( + camera.capabilities().pixel_formats, + vec![PixelFormat::Jpeg, PixelFormat::Rgb8, PixelFormat::Mono8], + "a playlist reports what a replayed file is, not what the generator was configured to emit" + ); + + let before = camera.status().await.expect("session status"); + assert_eq!(before.backend["playlist"]["count"], 3); + assert_eq!(before.backend["playlist"]["index"], 0); + assert_eq!( + before.backend["playlist"]["directory"], + directory.path().display().to_string(), + "diagnostics name the directory as it was configured" + ); + + camera + .capture(playlist_request("cap-1", "passthrough", None)) + .await + .expect("one replayed capture"); + let after = camera.status().await.expect("session status"); + assert_eq!(after.backend["playlist"]["index"], 1); + + let synthetic = session(json!({"type": "sim"})).await; + assert!( + synthetic + .capabilities() + .pixel_formats + .contains(&PixelFormat::Rgb8) + ); + } + + #[tokio::test] + async fn a_playlist_that_can_never_produce_a_frame_refuses_to_connect() { + let empty = TempDir::new().expect("playlist directory"); + write_fixture(empty.path(), "notes.txt", b"not an image"); + let no_match = connect_failure(playlist_backend(empty.path(), json!({}))).await; + assert_eq!(no_match.code(), ErrorCode::DeviceUnavailable); + assert!(no_match.to_string().contains("no file matching")); + + let missing = empty.path().join("absent"); + let unopenable = connect_failure(playlist_backend(&missing, json!({}))).await; + assert_eq!(unopenable.code(), ErrorCode::DeviceUnavailable); + } + + #[tokio::test] + async fn a_playlist_file_the_pipeline_cannot_accept_fails_the_capture_not_the_session() { + let directory = TempDir::new().expect("playlist directory"); + write_fixture(directory.path(), "a.jpg", &jpeg_bytes(32, 32, 1)); + write_fixture(directory.path(), "b.jpg", b"\xff\xd8\xffnot really a JPEG"); + write_fixture(directory.path(), "c.png", &png_bytes(4, 4, 2)); + + let mut camera = session(playlist_backend(directory.path(), json!({}))).await; + let oversized = camera + .capture(CaptureRequest { + maximum_frame_bytes: 16, + ..playlist_request("cap-1", "passthrough", None) + }) + .await + .expect_err("the frame ceiling is checked before the file is read"); + assert_eq!(oversized.code(), ErrorCode::ResourceLimit); + + let undecodable = camera + .capture(playlist_request("cap-2", "passthrough", None)) + .await + .expect_err("a truncated JPEG is not a frame"); + assert_eq!(undecodable.code(), ErrorCode::UnsupportedPixelFormat); + + let still_serving = camera + .capture(playlist_request("cap-3", "png", None)) + .await + .expect("a refused file does not close the session"); + assert_eq!(still_serving.backend_metadata["playlist"]["index"], 2); + } + + #[tokio::test] + async fn a_file_that_is_no_image_at_all_is_refused_with_the_format_code() { + let directory = TempDir::new().expect("playlist directory"); + write_fixture(directory.path(), "a.jpg", b"GIF89a and not a JPEG"); + let mut camera = session(playlist_backend(directory.path(), json!({}))).await; + let error = camera + .capture(playlist_request("cap-1", "passthrough", None)) + .await + .expect_err("an extension is not evidence of a format"); + assert_eq!(error.code(), ErrorCode::UnsupportedPixelFormat); + assert!(error.to_string().contains("neither JPEG nor PNG")); + } + + /// A playlist may not name a file outside its directory, and a link is how that happens. + #[cfg(unix)] + #[tokio::test] + async fn a_symbolic_link_is_refused_at_load_and_at_capture() { + let outside = TempDir::new().expect("directory outside the playlist"); + let secret = write_fixture(outside.path(), "elsewhere.jpg", &jpeg_bytes(4, 4, 1)); + let directory = TempDir::new().expect("playlist directory"); + write_fixture(directory.path(), "a.jpg", &jpeg_bytes(4, 4, 2)); + std::os::unix::fs::symlink(&secret, directory.path().join("linked.jpg")) + .expect("the fixture link"); + + let refused = connect_failure(playlist_backend(directory.path(), json!({}))).await; + assert_eq!(refused.code(), ErrorCode::DeviceUnavailable); + assert!(refused.to_string().contains("symbolic link")); + + // The same check runs again at capture time, because the directory is a live filesystem and + // a member can be swapped for a link between connect and capture. + fs::remove_file(directory.path().join("linked.jpg")).expect("remove the fixture link"); + let mut camera = session(playlist_backend(directory.path(), json!({}))).await; + fs::remove_file(directory.path().join("a.jpg")).expect("remove the member"); + std::os::unix::fs::symlink(&secret, directory.path().join("a.jpg")) + .expect("swap the member for a link"); + let swapped = camera + .capture(playlist_request("cap-1", "passthrough", None)) + .await + .expect_err("a member replaced by a link is not replayed"); + assert_eq!(swapped.code(), ErrorCode::DeviceUnavailable); + assert!(swapped.to_string().contains("symbolic link")); + } } diff --git a/src/config.rs b/src/config.rs index 4c0ccb7..bd6410e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,7 +6,7 @@ //! relationships before runtime state changes. use std::collections::{BTreeMap, HashSet}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::str::FromStr; use chrono_tz::Tz; @@ -509,7 +509,11 @@ impl Default for SimFrameConfig { } /// Simulator frame patterns. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +/// +/// The four synthetic patterns are generated per capture from the seed and the capture ordinal. +/// [`SimPattern::Playlist`] instead replays a directory of real image files, so a downstream +/// consumer receives genuine imagery through the ordinary capture, encoding, and storage path. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SimPattern { /// SMPTE-like color bands. @@ -521,6 +525,69 @@ pub enum SimPattern { Checkerboard, /// Seed-derived solid color. Solid, + /// Replay of a directory of real image files. + Playlist(SimPlaylistConfig), +} + +impl SimPattern { + /// The playlist settings when this pattern replays files, and `None` for a synthetic pattern. + #[must_use] + pub const fn playlist(&self) -> Option<&SimPlaylistConfig> { + match self { + Self::Playlist(playlist) => Some(playlist), + _ => None, + } + } +} + +/// Settings for the simulator pattern that replays a directory of real image files. +/// +/// The directory is read once, when the camera connects, so the playlist is a fixed list for the +/// life of the session; `sb/reconnect` re-reads it. `frame.width`, `frame.height`, and +/// `frame.pixelFormat` describe the synthetic generator and carry no meaning for a playlist: each +/// capture reports the replayed file's own dimensions and format. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SimPlaylistConfig { + /// Absolute directory holding the image files. + pub directory: PathBuf, + /// Glob patterns, matched case-sensitively against each file's `/`-separated path relative to + /// `directory`. A file joins the playlist when it matches at least one pattern. `**` matches any + /// number of path segments, `*` matches within one segment, and `?` matches one character. + #[serde(default = "default_playlist_include")] + pub include: Vec, + /// Replay order. + #[serde(default)] + pub order: SimPlaylistOrder, + /// Whether replay restarts at the first file after the last one. + #[serde(default = "default_true", rename = "loop")] + pub loop_playlist: bool, + /// When the cursor moves to the next file. + #[serde(default)] + pub advance: SimPlaylistAdvance, +} + +/// Order in which a playlist replays its files. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SimPlaylistOrder { + /// Ascending by relative path. + #[default] + Sorted, + /// Deterministic shuffle derived from the simulator seed. + Seeded, +} + +/// When a playlist cursor moves to the next file. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SimPlaylistAdvance { + /// Every capture replays the next file. + #[default] + PerCapture, + /// Every trigger replays the next file. Captures that share a trigger -- one command request, + /// one capture-group request, or one schedule occurrence -- replay the same file. + PerTrigger, } /// Simulator PTZ capability switches. @@ -1575,6 +1642,18 @@ fn validate_sim(sim: &SimBackendConfig, global: &GlobalConfig, path: &str) -> Re ); } } + if let Some(playlist) = sim.frame.pattern.playlist() { + let field = format!("{path}.frame.pattern.playlist"); + if !playlist.directory.is_absolute() { + return config_error(format!("{field}.directory"), "must be an absolute path"); + } + if playlist.include.is_empty() { + return config_error(format!("{field}.include"), "must list at least one glob"); + } + if playlist.include.iter().any(|glob| glob.trim().is_empty()) { + return config_error(format!("{field}.include"), "globs must not be empty"); + } + } for (value, field) in [ ( sim.faults.disconnect_after_captures, @@ -2254,6 +2333,13 @@ fn issue_from_error(instance: Option, error: CameraError) -> ConfigIssue fn default_true() -> bool { true } +fn default_playlist_include() -> Vec { + vec![ + "**/*.jpg".to_string(), + "**/*.jpeg".to_string(), + "**/*.png".to_string(), + ] +} fn default_camera_directory_template() -> String { "{cameraId}/{yyyy}/{MM}/{dd}".to_string() } @@ -3196,6 +3282,87 @@ mod tests { } } + /// An absolute directory the current platform agrees is absolute. + fn playlist_directory() -> String { + if cfg!(windows) { + "C:/line-clearance".to_string() + } else { + "/srv/line-clearance".to_string() + } + } + + #[test] + fn a_simulator_playlist_is_defaulted_and_validated_before_startup() { + let mut value = valid_config(); + value["component"]["instances"][0]["backend"] = json!({ + "type": "sim", + "frame": { "pattern": { "playlist": { "directory": playlist_directory() } } } + }); + let config = + AdapterConfig::from_core_initial(&core(value)).expect("a valid playlist simulator"); + let BackendConfig::Sim(sim) = &config.config.instances[0].backend else { + panic!("the fixture configures the simulator"); + }; + let playlist = sim + .frame + .pattern + .playlist() + .expect("the playlist settings survive parsing"); + assert_eq!(playlist.include, ["**/*.jpg", "**/*.jpeg", "**/*.png"]); + assert_eq!(playlist.order, SimPlaylistOrder::Sorted); + assert_eq!(playlist.advance, SimPlaylistAdvance::PerCapture); + assert!( + playlist.loop_playlist, + "replay loops unless it is told not to" + ); + + let cases: Vec = vec![ + // A relative directory resolves against whatever the process happens to be running in. + Box::new(|value| { + value["component"]["instances"][0]["backend"] = json!({ + "type": "sim", + "frame": { "pattern": { "playlist": { "directory": "line-clearance" } } } + }); + }), + Box::new(|value| { + value["component"]["instances"][0]["backend"] = json!({ + "type": "sim", + "frame": { "pattern": { "playlist": { + "directory": playlist_directory(), "include": [] + } } } + }); + }), + Box::new(|value| { + value["component"]["instances"][0]["backend"] = json!({ + "type": "sim", + "frame": { "pattern": { "playlist": { + "directory": playlist_directory(), "include": [" "] + } } } + }); + }), + // The playlist object is closed like every other block in this schema. + Box::new(|value| { + value["component"]["instances"][0]["backend"] = json!({ + "type": "sim", + "frame": { "pattern": { "playlist": { + "directory": playlist_directory(), "shuffle": true + } } } + }); + }), + Box::new(|value| { + value["component"]["instances"][0]["backend"] = json!({ + "type": "sim", + "frame": { "pattern": { "playlist": {} } } + }); + }), + ]; + for mutate in cases { + let mut value = valid_config(); + mutate(&mut value); + assert!(AdapterConfig::from_core_initial(&core(value)).is_err()); + } + } + #[test] fn genicam_selector_transport_and_feature_allowlist_are_validated_before_startup() { let cases: Vec = vec![ diff --git a/src/jobs.rs b/src/jobs.rs index 9e3f1d6..f3d29a9 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -1062,6 +1062,7 @@ impl JobEngine { let capture = session.capture(CaptureRequest { capture_id: runtime.spec.capture_id.clone(), + trigger_key: Some(trigger_key(&runtime.spec.trigger)), profile: runtime.spec.profile.capture.clone(), maximum_frame_bytes: runtime.spec.profile.maximum_frame_bytes, timeout: remaining_duration(runtime.deadlines().capture_at_ms), @@ -2432,6 +2433,28 @@ fn cancelled_error(stage: &'static str) -> CameraError { ) } +/// The opaque trigger identity handed to a backend on every capture. +/// +/// Captures that belong to the same operator action or the same schedule occurrence produce the same +/// key: one command request, one capture-group request, or one occurrence of one schedule. It is +/// deliberately a flat string -- the backend seam compares it and nothing more, so no trigger type +/// leaks into a protocol backend. +fn trigger_key(trigger: &CaptureTrigger) -> String { + match trigger { + CaptureTrigger::Command { request_id } => format!("command:{request_id}"), + CaptureTrigger::GroupCommand { + capture_group_id, .. + } => format!("group:{capture_group_id}"), + CaptureTrigger::Schedule { + schedule_id, + intended_fire_time, + } => format!( + "schedule:{schedule_id}@{}", + intended_fire_time.timestamp_millis() + ), + } +} + fn is_retriable(code: ErrorCode) -> bool { matches!( code, diff --git a/src/runtime/tests/simulator_runtime.rs b/src/runtime/tests/simulator_runtime.rs index 2dfc8ff..b028142 100644 --- a/src/runtime/tests/simulator_runtime.rs +++ b/src/runtime/tests/simulator_runtime.rs @@ -4,6 +4,7 @@ //! production paths the suite had never reached, and keeping them separate keeps this file readable. mod coverage_command; +mod coverage_playlist; mod coverage_reload; mod coverage_supervision; diff --git a/src/runtime/tests/simulator_runtime/coverage_command.rs b/src/runtime/tests/simulator_runtime/coverage_command.rs index 54e17af..ce4095b 100644 --- a/src/runtime/tests/simulator_runtime/coverage_command.rs +++ b/src/runtime/tests/simulator_runtime/coverage_command.rs @@ -4387,6 +4387,7 @@ async fn the_image_that_is_delivered_is_the_image_the_camera_took() { let expected = session .capture(crate::backend::CaptureRequest { capture_id: "regenerated".to_owned(), + trigger_key: None, profile: terminal_profile(&terminal), maximum_frame_bytes: 8 * 1024 * 1024, timeout: Duration::from_secs(5), @@ -4547,6 +4548,7 @@ async fn the_thumbnail_that_is_announced_is_a_downscale_of_the_frame_the_camera_ .expect("the simulator must connect"); let regenerate = || crate::backend::CaptureRequest { capture_id: "regenerated".to_owned(), + trigger_key: None, profile: terminal_profile(&terminal), maximum_frame_bytes: 8 * 1024 * 1024, timeout: Duration::from_secs(5), diff --git a/src/runtime/tests/simulator_runtime/coverage_playlist.rs b/src/runtime/tests/simulator_runtime/coverage_playlist.rs new file mode 100644 index 0000000..5351cc9 --- /dev/null +++ b/src/runtime/tests/simulator_runtime/coverage_playlist.rs @@ -0,0 +1,157 @@ +//! The simulator `playlist` pattern driven through the real capture and storage path. +//! +//! The unit tests beside `SimBackend` prove what the backend hands upwards. These prove the part +//! that only the assembled component can: a replayed file reaches disk through the ordinary +//! finalization -- sidecar first, then the image made visible atomically -- and the digest the +//! announcement and the sidecar carry is the digest of the file that was replayed. + +use std::fs; + +use image::ExtendedColorType; +use image::codecs::jpeg::JpegEncoder; +use sha2::{Digest, Sha256}; + +use super::*; + +/// A deterministic JPEG fixture. +fn jpeg_fixture(width: u32, height: u32, tint: u8) -> Vec { + let pixels: Vec = (0..(width * height * 3)) + .map(|index| (index as u8).wrapping_mul(7).wrapping_add(tint)) + .collect(); + let mut bytes = Vec::new(); + JpegEncoder::new_with_quality(std::io::Cursor::new(&mut bytes), 92) + .encode(&pixels, width, height, ExtendedColorType::Rgb8) + .expect("the fixture encoder produces a JPEG"); + bytes +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// One camera replaying `playlist` into `root`, with the sidecar the design requires. +fn playlist_config(root: &Path, playlist: &Path) -> AdapterConfig { + let raw = json!({ + "component": { + "global": { + "output": { + "rootDirectory": root.display().to_string(), + "writeMetadataSidecar": true + } + }, + "instances": [{ + "id": "camera-a", + "backend": { + "type": "sim", + "captureDelayMs": 0, + "frame": { + "pattern": { + "playlist": { "directory": playlist.display().to_string() } + } + } + }, + "defaultCaptureProfile": "replay", + "captureProfiles": { "replay": { "output": { "encoding": "passthrough" } } } + }] + } + }); + AdapterConfig::from_core_reload(&Config::from_value(COMPONENT_NAME, "gw-01", raw).unwrap()) + .unwrap() +} + +/// Submits one capture and returns the committed terminal body. +async fn replay_once(runtime: &CameraRuntime, request_id: &str) -> serde_json::Value { + let accepted = runtime + .submit_capture( + "camera-a".to_string(), + request_id.to_string(), + None, + None, + serde_json::Map::new(), + format!("{request_id}-correlation"), + "sb/capture-submit", + crate::admission::CapturePriority::Submitted, + ) + .await + .unwrap(); + let capture_id = match accepted { + crate::catalog::AcceptJobOutcome::Inserted(record) => record.capture_id, + other => panic!("expected a newly accepted capture, got {other:?}"), + }; + let terminal = wait_for_terminal(runtime, &capture_id).await; + assert_eq!(terminal.state, crate::model::JobState::Succeeded); + terminal + .terminal_result + .clone() + .expect("a succeeded capture commits its terminal body") +} + +/// The whole point of the pattern: what lands on disk is the image that was replayed. +/// +/// A synthetic pattern can prove the plumbing runs. It cannot prove that the file a vision component +/// picks up is the file an operator put in the directory -- and that is exactly the claim a +/// line-clearance rehearsal rests on. So this asserts the digest three times over, on the three +/// artifacts a consumer can actually reach: the announced terminal body, the durable sidecar beside +/// the image, and the installed bytes themselves. +#[tokio::test] +async fn a_replayed_capture_installs_the_file_it_names_with_that_file_s_own_digest() { + let directory = TempDir::new().unwrap(); + let playlist = TempDir::new().unwrap(); + let first = jpeg_fixture(24, 16, 5); + let second = jpeg_fixture(12, 12, 9); + fs::write(playlist.path().join("a.jpg"), &first).unwrap(); + fs::write(playlist.path().join("b.jpg"), &second).unwrap(); + + let runtime = runtime( + playlist_config(directory.path(), playlist.path()), + &directory, + ) + .await; + runtime + .start_supervisor("camera-a".to_string(), runtime.engine("camera-a").unwrap()) + .unwrap(); + wait_for_online(&runtime, "camera-a").await; + + let body = replay_once(&runtime, "replay-1").await; + let digest = sha256_hex(&first); + + assert_eq!(body["backendMetadata"]["playlist"]["sourcePath"], "a.jpg"); + assert_eq!(body["backendMetadata"]["playlist"]["index"], 0); + assert_eq!(body["frame"]["width"], 24); + assert_eq!(body["frame"]["height"], 16); + assert_eq!(body["frame"]["pixelFormat"], "JPEG"); + assert_eq!(body["image"]["encoding"], "passthrough"); + assert_eq!(body["image"]["contentType"], "image/jpeg"); + assert_eq!(body["image"]["bytes"], first.len()); + assert_eq!( + body["image"]["sha256"], digest, + "the announced digest must be the digest of the replayed file, not of a re-encode of it" + ); + + let installed = fs::read(body["image"]["absolutePath"].as_str().unwrap()).unwrap(); + assert_eq!( + installed, first, + "a passthrough replay installs the playlist file byte for byte" + ); + + let sidecar_relative = body["image"]["metadataSidecarRelativePath"] + .as_str() + .expect("the sidecar is written before the image becomes visible"); + let sidecar: serde_json::Value = + serde_json::from_slice(&fs::read(directory.path().join(sidecar_relative)).unwrap()) + .expect("the sidecar is the committed terminal document"); + assert_eq!(sidecar["image"]["sha256"], digest); + assert_eq!( + sidecar["backendMetadata"]["playlist"]["sourcePath"], + "a.jpg" + ); + assert_eq!(sidecar["backendMetadata"]["playlist"]["index"], 0); + + let next = replay_once(&runtime, "replay-2").await; + assert_eq!(next["backendMetadata"]["playlist"]["sourcePath"], "b.jpg"); + assert_eq!(next["backendMetadata"]["playlist"]["index"], 1); + assert_eq!(next["image"]["sha256"], sha256_hex(&second)); + assert_eq!(next["frame"]["width"], 12); + + runtime.shutdown().await; +} From 2ac2595c974c462b640e050cc5c6da87b05167ad Mon Sep 17 00:00:00 2001 From: breis Date: Sat, 22 Aug 2026 16:42:02 -0400 Subject: [PATCH 2/2] refactor(sim): drop the playlist advance policy The playlist cursor now advances one file per capture, full stop. `advance` (`perCapture` / `perTrigger`) is removed from the configuration, the schema, the reference and how-to docs, and D-CAM-31; the register keeps the reasoning for dropping it. `perTrigger` was the only reason a capture had to know which operator action or schedule occurrence produced it, so `CaptureRequest.trigger_key` and the `CaptureTrigger`-derived key that fed it are removed as well. That restores the backend seam and the twelve `CaptureRequest` construction sites in the GenICam, ONVIF, and RTSP backends to what they were, and leaves the playlist feature touching only the simulator, its configuration, and its own tests. --- DESIGN.md | 12 +- config.schema.json | 8 +- docs/how-to-guides.md | 7 +- docs/reference/configuration.md | 5 +- src/backend/genicam_aravis.rs | 4 - src/backend/mod.rs | 8 -- src/backend/onvif.rs | 3 - src/backend/rtsp_backend.rs | 1 - src/backend/sim.rs | 111 ++++-------------- src/config.rs | 16 --- src/jobs.rs | 23 ---- .../simulator_runtime/coverage_command.rs | 2 - 12 files changed, 34 insertions(+), 166 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 2c49705..3fe1c9d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -148,7 +148,7 @@ The words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are no | D-CAM-29 | Core 0.4.0 adoption: scoped instance routing + conditional availability | Adopt core 0.4.0's immediate-reply scoped registration: the delivery topic's `{instance}` token authoritative for every immediate-reply verb, adapter-side conflict refusal, and `sb/discover`'s configuration-conditional availability published into `describe` via `set_command_availability` (`disabled` with a reason while `global.discovery.enabled` is false; reapplied on committed reloads through the configuration listener — this part of the decision stands unchanged). `receivedTs` is **N/A**: the adapter is a direct camera client with no upstream broker hop to stamp a receive time. PTZ capability is per-camera and runtime-discovered, so it is deliberately NOT mirrored into component-scope availability. | **The recorded gap is CLOSED by D-CAM-30 (core 0.5.0).** Core 0.4.0 exposed the addressed-instance token only to the immediate-reply registration, so `sb/capture`/`sb/capture-group` routed by the body and the topic token did not route them — surfaced up front here as a gap needing a core scoped-outcome registration. Core 0.5.0's breaking two-form surface delivers exactly that: the deferred verbs now receive the addressed instance like every other verb, with deferred settlement unchanged. The adapter-side routing/conflict layer this entry introduced (`register_scoped` + `scoped_request`) is deleted — addressing enforcement is library-owned. | | D-CAM-30 | Core 0.5.0 adoption: declared verb scope + scoped deferred captures + keepalive instance state | Pin `edgecommons` at rust-lib/v0.5.0 (a14a3285). Every verb registers through the two-form surface `register(verb, scope, handler)` / `register_outcome(verb, scope, handler)` with a declared `CommandScope` (SOUTHBOUND §2.2 / D-SC-2), derived from its closed request schema: **`Component`** for the selector-less verbs (`sb/list`, `sb/discover`, `sb/capture-group`, `sb/capture-group-submit`, `sb/capture-cancel` — fleet answers, `instances[]` targets, durable capture/group ids), **`Instance`** for per-camera actuation (`sb/capture`, `sb/capture-submit`, `sb/reconnect`, `sb/ptz`, `sb/ptz-presets`, `sb/pause`, `sb/resume`), **`Both`** for the dual-semantics verbs where no addressing means the whole component (`sb/status` every camera, `sb/queue-status` the fleet, `sb/capture-status` component-wide lookups, `sb/queue-clear` the `allCameras` drain). The library enforces addressing ahead of dispatch (conflict-first `BAD_ARGS`, `Component`-scope rejection, D-SC-4); the adapter's hand-rolled `scoped_request` layer is deleted, keeping only the D-SC-4 component-side policies: the optional-iff-one configured-camera default and `NO_SUCH_INSTANCE` for an unknown name (`addressed_request` seeds the library-resolved token into the body selector those policies read). **The deferred verbs are scoped too — closing the D-CAM-29 gap:** `sb/capture` (`Instance`) and `sb/capture-group` (`Component`) register through the scoped outcome form, so the topic token routes a deferred capture while deferred settlement, permit release, and `sb/capture-cancel` settling the held reply are unchanged. Companion (D-SC-7): the state keepalive's `instances[]` `state` comes from the single instance state model that answers `sb/status` — a paused camera reports `PAUSED` (shared `CONNECTING`/`ONLINE`/`BACKOFF`/`PAUSED` vocabulary) while `connected` keeps reporting reachability; the exact wire element is pinned via the now-public `InstanceConnectivity::to_json`. | The 0.4.0 model needed an adapter-side enforcement layer and still left the two most consequential verbs blind to the envelope. With the declared scope the library owns addressing for every registration form, the camera class of gap is structurally impossible (D-SC-1), and `describe` advertises each verb's scope for the console. Dual-meaning verbs gain first-class component-wide semantics instead of overloading "no instance named". `PAUSED` in the keepalive lets a console distinguish expected-quiet from silently-stale (D-SC-8) without a second bookkeeping path. | | D-CAM-22 | Bare-RTSP backend | A distinct `rtsp` backend addresses a camera by a raw `rtsp://`/`rtsps://` URL, with no ONVIF. It is still-image only, reuses the shared RTSP engine (`RtspCaptureController`) and the network/credential/TLS primitives, and advertises `capture_modes=[rtsp-frame]` with all PTZ/snapshot/discovery capabilities off. To make it buildable without ONVIF, the protocol-neutral net/auth primitives and the credential-resolution seam are lifted from the `onvif` module into a shared `backend::net` module, and the `rtsp` cargo feature is decoupled from `onvif`. | ONVIF gives identity, capability discovery, media profiles, snapshot, PTZ, and the governed stream URI; a raw RTSP URL gives none of these, so it is a genuinely different camera kind rather than a mode of `onvif-rtsp` — a separate backend keeps the ONVIF backend's required-field invariants (`deviceServiceUrl`/`mediaProfile`) intact. `connect()` performs the RTSP `DESCRIBE`/`SETUP` + auth + SDP/codec validation so a dead URL, bad auth, or unsupported codec fails at connect (the supervisor keeps such a camera OFFLINE rather than falsely ONLINE, since reachability is inferred from a successful connect). The URL carries no credentials (userinfo is rejected); credentials are `$secret` references resolved through the same bounded EdgeCommons path as ONVIF, and the same host-allowlist / DNS-pin / RTSPS-SNI / forbidden-address policy applies to the user-supplied URL. Decoupling the feature lets an operator ship an RTSP-only binary without the ONVIF surface. | -| D-CAM-31 | Simulator `playlist` pattern | `frame.pattern` accepts `{ "playlist": { "directory", "include", "order", "loop", "advance" } }` beside the four synthetic tokens. The directory is walked once at connect — deterministic, symbolic links refused, bounded at 10,000 files and 32 levels, and empty is a connect failure — ordered `sorted` (by relative path) or `seeded` (SplitMix64 Fisher-Yates over the sorted list, keyed by the simulator seed), and replayed one file per capture through the **unchanged** finalize path: temp write, fsync, sidecar first, atomic visibility, catalog row, `ImageCaptured`, thumbnail where configured. A JPEG member captured under `passthrough`/`raw` is delivered as the bytes on disk, so `image.sha256` is the source file's digest; every other combination decodes to `RGB8`/`Mono8` and re-encodes through the ordinary encoding stage. `backendMetadata.playlist` carries `sourcePath` and `index`; the session-status surface carries `playlist{count,index,directory}`. The backend seam gains an opaque `CaptureRequest.trigger_key`, derived adapter-side from the durable `CaptureTrigger`, which `advance: perTrigger` compares for equality. | The synthetic patterns prove plumbing, not vision: a downstream anomaly or classification model needs real imagery, and `image-processor`'s tier-4 end-to-end rehearsal (D-IP-18) needs it arriving through the real camera path — genuine sidecars, digests, and announcements — rather than from a fixture that writes files behind the adapter's back. Reading the directory at connect rather than per capture keeps an unbounded filesystem walk off every frame's acquisition deadline and gives the replay a fixed list to be deterministic about; `sb/reconnect` is the re-read. **Byte-preserving replay is JPEG-only**, because `OutputEncoding::Passthrough` already requires a declared complete JPEG source: extending it to PNG would mean a new `PixelFormat` variant and a changed encoding contract for every backend, so a PNG member is decoded and re-encoded instead and only a JPEG member yields a sidecar digest equal to the file's. `trigger_key` is a flat opaque string rather than the `CaptureTrigger` type so that no EdgeCommons message shape crosses the backend seam (§6.2). | +| D-CAM-31 | Simulator `playlist` pattern | `frame.pattern` accepts `{ "playlist": { "directory", "include", "order", "loop" } }` beside the four synthetic tokens. The directory is walked once at connect — deterministic, symbolic links refused, bounded at 10,000 files and 32 levels, and empty is a connect failure — ordered `sorted` (by relative path) or `seeded` (SplitMix64 Fisher-Yates over the sorted list, keyed by the simulator seed), and replayed one file per capture through the **unchanged** finalize path: temp write, fsync, sidecar first, atomic visibility, catalog row, `ImageCaptured`, thumbnail where configured. A JPEG member captured under `passthrough`/`raw` is delivered as the bytes on disk, so `image.sha256` is the source file's digest; every other combination decodes to `RGB8`/`Mono8` and re-encodes through the ordinary encoding stage. `backendMetadata.playlist` carries `sourcePath` and `index`; the session-status surface carries `playlist{count,index,directory}`. | The synthetic patterns prove plumbing, not vision: a downstream anomaly or classification model needs real imagery, and `image-processor`'s tier-4 end-to-end rehearsal (D-IP-18) needs it arriving through the real camera path — genuine sidecars, digests, and announcements — rather than from a fixture that writes files behind the adapter's back. Reading the directory at connect rather than per capture keeps an unbounded filesystem walk off every frame's acquisition deadline and gives the replay a fixed list to be deterministic about; `sb/reconnect` is the re-read. **Byte-preserving replay is JPEG-only**, because `OutputEncoding::Passthrough` already requires a declared complete JPEG source: extending it to PNG would mean a new `PixelFormat` variant and a changed encoding contract for every backend, so a PNG member is decoded and re-encoded instead and only a JPEG member yields a sidecar digest equal to the file's. The cursor advances once per capture and nothing else: a per-trigger hold was considered and dropped, because it would have to carry a trigger identity across the backend seam -- which knows protocols and not EdgeCommons message shapes -- to serve a case no configuration needs. | ## 5. System context @@ -289,9 +289,8 @@ is a required implementation, not only a test fixture hidden behind conditional `playlist` pattern replays a directory of real image files: the directory is walked once at connect, ordered `sorted` (ascending by relative path) or `seeded` (a SplitMix64 Fisher-Yates shuffle of that sorted list, keyed by the simulator seed), and each capture yields the next file's bytes with that file's own -dimensions and format. `loop` restarts replay after the last file; with `loop: false` a capture past the end -fails `DEVICE_UNAVAILABLE`. `advance` selects when the cursor moves: `perCapture` on every capture, or -`perTrigger` only when the capture's trigger differs from the previous capture's. +dimensions and format. Every capture takes the next file. `loop` restarts replay after the last file; with +`loop: false` a capture past the end fails `DEVICE_UNAVAILABLE`. A replayed frame takes the same path as any other frame. A JPEG member captured under a byte-preserving output (`passthrough` or `raw`) is handed on as the bytes on disk, so the announced `image.sha256` is the @@ -305,11 +304,6 @@ canonicalized root, and the same check runs again at capture time because the di filesystem. The walk is bounded at 10,000 matching files and 32 levels, and a directory that matches no file fails the connect rather than accepting captures it would refuse one at a time. -`CaptureRequest` carries a `trigger_key`: an opaque string identifying the operator action or schedule -occurrence a capture belongs to, derived adapter-side from the durable `CaptureTrigger`. A backend compares -it for equality and never parses it, which is what `advance: perTrigger` reads and what keeps the seam free -of EdgeCommons message shapes. - ### 6.3 Threading and blocking I/O - Tokio tasks MAY manage camera state, timers, queues, messaging, HTTP, and durable catalog work. diff --git a/config.schema.json b/config.schema.json index 8de2906..05875c5 100644 --- a/config.schema.json +++ b/config.schema.json @@ -561,8 +561,7 @@ "type": "boolean", "default": true, "description": "Whether replay restarts at the first file after the last one." - }, - "advance": { "$ref": "#/$defs/simPlaylistAdvance" } + } } }, "simPlaylistOrder": { @@ -570,11 +569,6 @@ "default": "sorted", "description": "Playlist replay order: ascending by relative path, or a deterministic shuffle derived from the simulator seed." }, - "simPlaylistAdvance": { - "enum": ["perCapture", "perTrigger"], - "default": "perCapture", - "description": "When the playlist cursor moves to the next file." - }, "genicamTransport": { "enum": ["auto", "gige-vision", "usb3-vision"], "default": "auto", diff --git a/docs/how-to-guides.md b/docs/how-to-guides.md index 61f02ba..371d064 100644 --- a/docs/how-to-guides.md +++ b/docs/how-to-guides.md @@ -133,8 +133,7 @@ To replay a directory of images: "directory": "/srv/line-clearance/reference-images", "include": ["**/*.jpg", "**/*.jpeg", "**/*.png"], "order": "sorted", - "loop": true, - "advance": "perCapture" + "loop": true } } } @@ -144,9 +143,7 @@ To replay a directory of images: The adapter reads the directory once, when the camera connects, so images added later take effect on `sb/reconnect`. `order: "seeded"` shuffles the list deterministically from the camera's `seed`, which gives a repeatable order that is not alphabetical. `loop: false` replays each file once and then fails further -captures with `DEVICE_UNAVAILABLE`, which is how you drive a fixed-length rehearsal. `advance: "perTrigger"` -holds one file for every capture that shares a trigger — one command request, one capture-group request, or -one schedule occurrence. +captures with `DEVICE_UNAVAILABLE`, which is how you drive a fixed-length rehearsal. Every replayed capture names its source. The terminal `ImageCaptured` body and the metadata sidecar beside the image both carry `backendMetadata.playlist.sourcePath`, the file's path relative to the playlist diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 978f368..0d8c50e 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -126,9 +126,8 @@ that replays a directory of real image files. A playlist requires an absolute `d `include` (case-sensitive globs matched against each file's `/`-separated path relative to `directory`, default `**/*.jpg`, `**/*.jpeg`, `**/*.png`; `**` spans path segments, `*` matches within one segment, `?` matches one character), `order` (`sorted` by relative path, or `seeded` for a deterministic shuffle keyed by -the camera's `seed`), `loop` (default true; with `false` a capture past the last file fails -`DEVICE_UNAVAILABLE`), and `advance` (`perCapture`, or `perTrigger` to hold one file for every capture that -shares a command request, capture-group request, or schedule occurrence). The directory is read once when +the camera's `seed`), and `loop` (default true; with `false` a capture past the last file fails +`DEVICE_UNAVAILABLE`). Every capture replays the next file. The directory is read once when the camera connects and holds at most 10,000 files nested at most 32 levels deep; symbolic links are refused. `frame.width`, `frame.height`, and `frame.pixelFormat` configure the synthetic generators and are not consulted for a playlist — each capture reports the replayed file's own dimensions and format. A JPEG diff --git a/src/backend/genicam_aravis.rs b/src/backend/genicam_aravis.rs index ad88587..c5a7479 100644 --- a/src/backend/genicam_aravis.rs +++ b/src/backend/genicam_aravis.rs @@ -2228,7 +2228,6 @@ mod tests { fn fake_gige_request(capture_id: &str) -> CaptureRequest { CaptureRequest { capture_id: capture_id.to_owned(), - trigger_key: None, profile: fake_gige_profile(), maximum_frame_bytes: 76_800, timeout: Duration::from_secs(5), @@ -2255,7 +2254,6 @@ mod tests { let frame = session .capture(CaptureRequest { capture_id: "cap-1".to_owned(), - trigger_key: None, profile: profile(), maximum_frame_bytes: 1, timeout: Duration::from_secs(1), @@ -3313,7 +3311,6 @@ mod tests { let frame = session .capture(CaptureRequest { capture_id: "cap-frame-fidelity".to_owned(), - trigger_key: None, profile: requested, maximum_frame_bytes, timeout: Duration::from_secs(5), @@ -3390,7 +3387,6 @@ mod tests { let error = session .capture(CaptureRequest { capture_id: "cap-over-bound".to_owned(), - trigger_key: None, profile: profile(), maximum_frame_bytes: 1, timeout: Duration::from_secs(5), diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 55e9e40..1d80f93 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -85,14 +85,6 @@ pub struct ConnectRequest { pub struct CaptureRequest { /// Adapter-generated durable capture id. pub capture_id: String, - /// Opaque identity of the operator action or schedule occurrence this capture belongs to. - /// - /// Captures produced by one command request, one capture-group request, or one schedule - /// occurrence carry the same key. A backend compares it for equality and never parses it, which - /// is what keeps the seam protocol-neutral: nothing about EdgeCommons triggers crosses it beyond - /// "same action" or "different action". `None` means the caller has no trigger to report, and a - /// backend that does not model triggers ignores the field. - pub trigger_key: Option, /// Immutable effective profile. pub profile: CaptureProfile, /// Hard accepted source-frame ceiling. diff --git a/src/backend/onvif.rs b/src/backend/onvif.rs index 3b80110..925dbaf 100644 --- a/src/backend/onvif.rs +++ b/src/backend/onvif.rs @@ -4877,7 +4877,6 @@ wkWsh7u3nnr9fXRpWsamYEAKGzNo0istMB6rD6cMzNfRZCMk4rXuokYWOw== session .capture(CaptureRequest { capture_id: "closed-session".to_owned(), - trigger_key: None, profile, maximum_frame_bytes: 1_024, timeout: Duration::from_secs(1), @@ -6492,7 +6491,6 @@ wkWsh7u3nnr9fXRpWsamYEAKGzNo0istMB6rD6cMzNfRZCMk4rXuokYWOw== let frame = session .capture(CaptureRequest { capture_id: "capture-1".to_owned(), - trigger_key: None, profile, maximum_frame_bytes: 1_048_576, timeout: Duration::from_secs(2), @@ -6781,7 +6779,6 @@ wkWsh7u3nnr9fXRpWsamYEAKGzNo0istMB6rD6cMzNfRZCMk4rXuokYWOw== .expect("capture profile"); CaptureRequest { capture_id: "byte-fidelity".to_owned(), - trigger_key: None, profile, maximum_frame_bytes: 1_048_576, timeout: Duration::from_secs(5), diff --git a/src/backend/rtsp_backend.rs b/src/backend/rtsp_backend.rs index d6b1117..ca964fd 100644 --- a/src/backend/rtsp_backend.rs +++ b/src/backend/rtsp_backend.rs @@ -433,7 +433,6 @@ mod tests { let frame = session .capture(CaptureRequest { capture_id: "cap-live-1".to_string(), - trigger_key: None, profile, maximum_frame_bytes: 1_048_576, timeout: Duration::from_secs(15), diff --git a/src/backend/sim.rs b/src/backend/sim.rs index 2226dbf..771777a 100644 --- a/src/backend/sim.rs +++ b/src/backend/sim.rs @@ -28,8 +28,7 @@ use super::{ DiscoveryCandidate, DiscoveryRequest, }; use crate::config::{ - BackendConfig, SimBackendConfig, SimPattern, SimPlaylistAdvance, SimPlaylistConfig, - SimPlaylistOrder, + BackendConfig, SimBackendConfig, SimPattern, SimPlaylistConfig, SimPlaylistOrder, }; use crate::error::{CameraError, ErrorCode, Result}; use crate::model::{ @@ -320,7 +319,7 @@ impl CameraSession for SimSession { let limit = request.maximum_frame_bytes; let mut acquired = match self.playlist.as_mut() { Some(playlist) => { - let index = playlist.take(request.trigger_key.as_deref())?; + let index = playlist.take()?; let entry = playlist.entry(index).clone(); let root = playlist.root.clone(); let encoding = request.profile.output.encoding; @@ -612,11 +611,6 @@ struct Playlist { /// Position the next capture replays. cursor: usize, loop_playlist: bool, - advance: SimPlaylistAdvance, - /// Trigger the current position was taken for, under `perTrigger`. - last_trigger: Option, - /// Position the last capture replayed. - current: Option, } impl Playlist { @@ -647,9 +641,6 @@ impl Playlist { entries, cursor: 0, loop_playlist: config.loop_playlist, - advance: config.advance, - last_trigger: None, - current: None, }) } @@ -658,17 +649,11 @@ impl Playlist { &self.entries[index] } - /// The position this capture replays, moving the cursor when the advance policy says to. + /// The position this capture replays, moving the cursor to the next file. /// /// # Errors /// `DEVICE_UNAVAILABLE` once an unlooped playlist has replayed its last file. - fn take(&mut self, trigger: Option<&str>) -> Result { - let repeat = self.advance == SimPlaylistAdvance::PerTrigger - && trigger.is_some() - && self.last_trigger.as_deref() == trigger; - if let Some(current) = self.current.filter(|_| repeat) { - return Ok(current); - } + fn take(&mut self) -> Result { if self.cursor >= self.entries.len() { if !self.loop_playlist { return Err(playlist_unavailable( @@ -679,8 +664,6 @@ impl Playlist { } let index = self.cursor; self.cursor += 1; - self.current = Some(index); - self.last_trigger = trigger.map(str::to_owned); Ok(index) } @@ -1097,7 +1080,6 @@ mod tests { let mut second = session(config).await; let request = || CaptureRequest { capture_id: "cap-1".to_string(), - trigger_key: None, profile: profile(), maximum_frame_bytes: 1_000_000, timeout: Duration::from_secs(1), @@ -1114,7 +1096,6 @@ mod tests { let mut camera = session(json!({"type":"sim","faults":{"failEveryNthCapture":2}})).await; let request = || CaptureRequest { capture_id: "cap".to_string(), - trigger_key: None, profile: profile(), maximum_frame_bytes: 1_000_000, timeout: Duration::from_secs(1), @@ -1168,7 +1149,6 @@ mod tests { let error = camera .capture(CaptureRequest { capture_id: "cap".to_string(), - trigger_key: None, profile: profile(), maximum_frame_bytes: 1_000_000, timeout: Duration::from_secs(1), @@ -1185,7 +1165,6 @@ mod tests { session(json!({"type":"sim","faults":{"incompleteEveryNthCapture":1}})).await; let request = || CaptureRequest { capture_id: "cap-fault".to_string(), - trigger_key: None, profile: profile(), maximum_frame_bytes: 1_000_000, timeout: Duration::from_secs(1), @@ -1264,7 +1243,6 @@ mod tests { async fn simulator_emits_declared_raw_and_jpeg_formats_with_frame_bounds() { let request = || CaptureRequest { capture_id: "format-check".to_owned(), - trigger_key: None, profile: profile(), maximum_frame_bytes: 1_000_000, timeout: Duration::from_secs(1), @@ -1591,10 +1569,9 @@ mod tests { .expect("valid capture profile") } - fn playlist_request(capture_id: &str, encoding: &str, trigger: Option<&str>) -> CaptureRequest { + fn playlist_request(capture_id: &str, encoding: &str) -> CaptureRequest { CaptureRequest { capture_id: capture_id.to_owned(), - trigger_key: trigger.map(str::to_owned), profile: encoding_profile(encoding), maximum_frame_bytes: 1_000_000, timeout: Duration::from_secs(1), @@ -1711,9 +1688,7 @@ mod tests { .expect("looping playlist"); assert_eq!( (0..5) - .map(|_| looping - .take(None) - .expect("a looping playlist never runs out")) + .map(|_| looping.take().expect("a looping playlist never runs out")) .collect::>(), vec![0, 1, 2, 0, 1] ); @@ -1725,11 +1700,11 @@ mod tests { .expect("single-pass playlist"); assert_eq!( (0..3) - .map(|_| once.take(None).expect("every file is replayed once")) + .map(|_| once.take().expect("every file is replayed once")) .collect::>(), vec![0, 1, 2] ); - let spent = once.take(None).expect_err("a spent playlist has no frame"); + let spent = once.take().expect_err("a spent playlist has no frame"); assert_eq!(spent.code(), ErrorCode::DeviceUnavailable); assert!(spent.to_string().contains("spent")); assert_eq!( @@ -1741,7 +1716,7 @@ mod tests { let mut wrapped = Playlist::load(&playlist_settings(directory.path(), json!({})), 1) .expect("looping playlist"); for _ in 0..3 { - wrapped.take(None).expect("every file replayed once"); + wrapped.take().expect("every file replayed once"); } assert_eq!( wrapped.diagnostics()["index"], @@ -1750,37 +1725,6 @@ mod tests { ); } - #[test] - fn per_trigger_holds_one_file_for_captures_that_share_a_trigger() { - let directory = three_jpeg_directory(); - let mut per_trigger = Playlist::load( - &playlist_settings(directory.path(), json!({ "advance": "perTrigger" })), - 1, - ) - .expect("per-trigger playlist"); - assert_eq!(per_trigger.take(Some("schedule:minute@0")).unwrap(), 0); - assert_eq!( - per_trigger.take(Some("schedule:minute@0")).unwrap(), - 0, - "a second capture under the same trigger replays the same file" - ); - assert_eq!(per_trigger.take(Some("schedule:minute@60000")).unwrap(), 1); - assert_eq!( - per_trigger.take(None).unwrap(), - 2, - "a capture with no trigger to compare always takes the next file" - ); - - let mut per_capture = Playlist::load(&playlist_settings(directory.path(), json!({})), 1) - .expect("per-capture playlist"); - assert_eq!(per_capture.take(Some("command:one")).unwrap(), 0); - assert_eq!( - per_capture.take(Some("command:one")).unwrap(), - 1, - "the default advances on every capture whatever the trigger says" - ); - } - #[test] fn a_relative_token_refuses_a_path_that_is_not_plainly_nested() { let root = Path::new("/playlist"); @@ -1835,21 +1779,18 @@ mod tests { let directory = TempDir::new().expect("playlist directory"); write_fixture(directory.path(), "a.jpg", &jpeg_bytes(4, 4, 1)); write_fixture(directory.path(), "b.jpg", &jpeg_bytes(4, 4, 2)); - let mut camera = session(playlist_backend( - directory.path(), - json!({ "advance": "perCapture" }), - )) - .await; + let mut camera = + session(playlist_backend(directory.path(), json!({ "loop": false }))).await; fs::remove_file(directory.path().join("a.jpg")).expect("remove the first member"); let gone = camera - .capture(playlist_request("cap-1", "passthrough", None)) + .capture(playlist_request("cap-1", "passthrough")) .await .expect_err("a member that is no longer there is not a frame"); assert_eq!(gone.code(), ErrorCode::DeviceUnavailable); assert!(gone.to_string().contains("cannot be read")); camera - .capture(playlist_request("cap-2", "passthrough", None)) + .capture(playlist_request("cap-2", "passthrough")) .await .expect("the rest of the playlist still replays"); } @@ -1865,7 +1806,7 @@ mod tests { let mut camera = session(playlist_backend(directory.path(), json!({}))).await; let undecodable = camera - .capture(playlist_request("cap-1", "png", None)) + .capture(playlist_request("cap-1", "png")) .await .expect_err("a truncated PNG is not a frame"); assert_eq!(undecodable.code(), ErrorCode::UnsupportedPixelFormat); @@ -1876,7 +1817,7 @@ mod tests { let over_ceiling = camera .capture(CaptureRequest { maximum_frame_bytes: 4_096, - ..playlist_request("cap-2", "png", None) + ..playlist_request("cap-2", "png") }) .await .expect_err("the decoded frame is bounded too"); @@ -1894,7 +1835,7 @@ mod tests { let mut camera = session(playlist_backend(directory.path(), json!({}))).await; let frame = camera - .capture(playlist_request("cap-1", "passthrough", None)) + .capture(playlist_request("cap-1", "passthrough")) .await .expect("the first playlist file"); assert_eq!( @@ -1910,7 +1851,7 @@ mod tests { assert_eq!(frame.backend_metadata["playlist"]["index"], 0); let next = camera - .capture(playlist_request("cap-2", "passthrough", None)) + .capture(playlist_request("cap-2", "passthrough")) .await .expect("the second playlist file"); assert_eq!(next.bytes.as_ref(), second.as_slice()); @@ -1928,7 +1869,7 @@ mod tests { let mut camera = session(playlist_backend(directory.path(), json!({}))).await; let colour = camera - .capture(playlist_request("cap-1", "png", None)) + .capture(playlist_request("cap-1", "png")) .await .expect("a colour PNG decodes to RGB8"); assert_eq!(colour.pixel_format, PixelFormat::Rgb8); @@ -1936,14 +1877,14 @@ mod tests { assert_eq!(colour.bytes.len(), 6 * 5 * 3); let grey = camera - .capture(playlist_request("cap-2", "png", None)) + .capture(playlist_request("cap-2", "png")) .await .expect("a grayscale PNG decodes to Mono8"); assert_eq!(grey.pixel_format, PixelFormat::Mono8); assert_eq!(grey.bytes.len(), 6 * 5); let photo = camera - .capture(playlist_request("cap-3", "png", None)) + .capture(playlist_request("cap-3", "png")) .await .expect("a JPEG asked for as PNG is decoded rather than refused"); assert_eq!(photo.pixel_format, PixelFormat::Rgb8); @@ -1974,7 +1915,7 @@ mod tests { ); camera - .capture(playlist_request("cap-1", "passthrough", None)) + .capture(playlist_request("cap-1", "passthrough")) .await .expect("one replayed capture"); let after = camera.status().await.expect("session status"); @@ -2013,20 +1954,20 @@ mod tests { let oversized = camera .capture(CaptureRequest { maximum_frame_bytes: 16, - ..playlist_request("cap-1", "passthrough", None) + ..playlist_request("cap-1", "passthrough") }) .await .expect_err("the frame ceiling is checked before the file is read"); assert_eq!(oversized.code(), ErrorCode::ResourceLimit); let undecodable = camera - .capture(playlist_request("cap-2", "passthrough", None)) + .capture(playlist_request("cap-2", "passthrough")) .await .expect_err("a truncated JPEG is not a frame"); assert_eq!(undecodable.code(), ErrorCode::UnsupportedPixelFormat); let still_serving = camera - .capture(playlist_request("cap-3", "png", None)) + .capture(playlist_request("cap-3", "png")) .await .expect("a refused file does not close the session"); assert_eq!(still_serving.backend_metadata["playlist"]["index"], 2); @@ -2038,7 +1979,7 @@ mod tests { write_fixture(directory.path(), "a.jpg", b"GIF89a and not a JPEG"); let mut camera = session(playlist_backend(directory.path(), json!({}))).await; let error = camera - .capture(playlist_request("cap-1", "passthrough", None)) + .capture(playlist_request("cap-1", "passthrough")) .await .expect_err("an extension is not evidence of a format"); assert_eq!(error.code(), ErrorCode::UnsupportedPixelFormat); @@ -2068,7 +2009,7 @@ mod tests { std::os::unix::fs::symlink(&secret, directory.path().join("a.jpg")) .expect("swap the member for a link"); let swapped = camera - .capture(playlist_request("cap-1", "passthrough", None)) + .capture(playlist_request("cap-1", "passthrough")) .await .expect_err("a member replaced by a link is not replayed"); assert_eq!(swapped.code(), ErrorCode::DeviceUnavailable); diff --git a/src/config.rs b/src/config.rs index bd6410e..2cfb300 100644 --- a/src/config.rs +++ b/src/config.rs @@ -562,9 +562,6 @@ pub struct SimPlaylistConfig { /// Whether replay restarts at the first file after the last one. #[serde(default = "default_true", rename = "loop")] pub loop_playlist: bool, - /// When the cursor moves to the next file. - #[serde(default)] - pub advance: SimPlaylistAdvance, } /// Order in which a playlist replays its files. @@ -578,18 +575,6 @@ pub enum SimPlaylistOrder { Seeded, } -/// When a playlist cursor moves to the next file. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum SimPlaylistAdvance { - /// Every capture replays the next file. - #[default] - PerCapture, - /// Every trigger replays the next file. Captures that share a trigger -- one command request, - /// one capture-group request, or one schedule occurrence -- replay the same file. - PerTrigger, -} - /// Simulator PTZ capability switches. #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields, default)] @@ -3310,7 +3295,6 @@ mod tests { .expect("the playlist settings survive parsing"); assert_eq!(playlist.include, ["**/*.jpg", "**/*.jpeg", "**/*.png"]); assert_eq!(playlist.order, SimPlaylistOrder::Sorted); - assert_eq!(playlist.advance, SimPlaylistAdvance::PerCapture); assert!( playlist.loop_playlist, "replay loops unless it is told not to" diff --git a/src/jobs.rs b/src/jobs.rs index f3d29a9..9e3f1d6 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -1062,7 +1062,6 @@ impl JobEngine { let capture = session.capture(CaptureRequest { capture_id: runtime.spec.capture_id.clone(), - trigger_key: Some(trigger_key(&runtime.spec.trigger)), profile: runtime.spec.profile.capture.clone(), maximum_frame_bytes: runtime.spec.profile.maximum_frame_bytes, timeout: remaining_duration(runtime.deadlines().capture_at_ms), @@ -2433,28 +2432,6 @@ fn cancelled_error(stage: &'static str) -> CameraError { ) } -/// The opaque trigger identity handed to a backend on every capture. -/// -/// Captures that belong to the same operator action or the same schedule occurrence produce the same -/// key: one command request, one capture-group request, or one occurrence of one schedule. It is -/// deliberately a flat string -- the backend seam compares it and nothing more, so no trigger type -/// leaks into a protocol backend. -fn trigger_key(trigger: &CaptureTrigger) -> String { - match trigger { - CaptureTrigger::Command { request_id } => format!("command:{request_id}"), - CaptureTrigger::GroupCommand { - capture_group_id, .. - } => format!("group:{capture_group_id}"), - CaptureTrigger::Schedule { - schedule_id, - intended_fire_time, - } => format!( - "schedule:{schedule_id}@{}", - intended_fire_time.timestamp_millis() - ), - } -} - fn is_retriable(code: ErrorCode) -> bool { matches!( code, diff --git a/src/runtime/tests/simulator_runtime/coverage_command.rs b/src/runtime/tests/simulator_runtime/coverage_command.rs index ce4095b..54e17af 100644 --- a/src/runtime/tests/simulator_runtime/coverage_command.rs +++ b/src/runtime/tests/simulator_runtime/coverage_command.rs @@ -4387,7 +4387,6 @@ async fn the_image_that_is_delivered_is_the_image_the_camera_took() { let expected = session .capture(crate::backend::CaptureRequest { capture_id: "regenerated".to_owned(), - trigger_key: None, profile: terminal_profile(&terminal), maximum_frame_bytes: 8 * 1024 * 1024, timeout: Duration::from_secs(5), @@ -4548,7 +4547,6 @@ async fn the_thumbnail_that_is_announced_is_a_downscale_of_the_frame_the_camera_ .expect("the simulator must connect"); let regenerate = || crate::backend::CaptureRequest { capture_id: "regenerated".to_owned(), - trigger_key: None, profile: terminal_profile(&terminal), maximum_frame_bytes: 8 * 1024 * 1024, timeout: Duration::from_secs(5),