flybrain/services/flysim/crates/fly-session/src/media.rs
acamilo 079842f818 media: no best-effort defaults in the audio path
Review round two.

A restored timeline refuses a declared stream with no recorded sample
position instead of resuming it at zero, which would have restarted the
episode's audio clock silently.

A declared stream that produces no chunk for a transition now fails the
step. The contract does not say a chunk must exist; this slice requires
one, because a silently missing chunk cannot be told from a lost one.
Boundary 0 is exempt: no interval precedes it.

The fresh-epoch discontinuity refusal is dropped rather than written into
the amendment. Only the restore direction is stated, and sections 6 and 7
both have a fresh timeline publishing a discontinuity after a recovery or
a reset, so refusing the flag at an origin contradicted them. The dated
amendment now says the requirement is one-directional.

A snapshot with an unreadable chunk, or one whose handle is not attached,
is now unreadable as a whole rather than quietly carrying less audio than
was published. ViewPipeline::is_bootstrap_repeat is removed.

New tests: a missing chunk fails the step, restored timelines need every
declared stream's position, and a 7 Hz world's seven chunks sum to exactly
one second of 48 kHz samples. The bus-conformance reconnect row also cites
the test that actually replaces an incarnation.
2026-09-22 15:15:06 +00:00

837 lines
31 KiB
Rust

//! Native observations and the presentation handoff: the MEDIA-01 slice.
//!
//! Everything here sits on top of the bus `ArtifactRef` and its ownership rules. There is no
//! second buffer system: an environment allocates, writes and seals one immutable object per
//! boundary, the coordinator forwards that one owned handle to every agent and to publication,
//! and a spectator reads it through an ordinary latest subscription.
//!
//! The module owns four things:
//!
//! 1. Production. [`ViewPipeline`] renders one native frame per boundary and hands out the
//! frame a declared `observationDelaySteps` requires, so a pipeline delay is a real queue
//! rather than a number in a descriptor. [`AudioSource`] produces one chunk per boundary
//! with an exact rational sample budget.
//! 2. Acceptance. [`check_required_views`] and [`AudioTimelines`] are the coordinator's Phase C
//! media checks: a required sensory view must exist at exactly the producing boundary its
//! declared delay implies, and audio chunks cannot overlap or go backwards inside an epoch.
//! 3. Consumption. [`Spectator`] is a presentation-side consumer on a latest subscription with
//! finite credits, and [`detach_frame`] is the renderer that keeps its handle after the
//! message is gone.
//! 4. Identity. [`AssetRegistry`] holds installed persistent content named by `AssetRef`.
//! Importing an asset produces a *new* transient artifact; the two identities never convert.
//!
//! Resizing, overlays, compositing, mixing, encoding and streaming are not here and are not
//! anywhere else in this crate: they belong to the application's presentation layer.
use std::collections::{BTreeMap, VecDeque};
use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use fly_session_types::media::AudioTimeline;
// `crate::types` is this crate's facade over the shared `fly-session-types` crate; the glob
// keeps the contract's own names in sight instead of restating them.
use crate::types::*;
/// The content type of a native RGBA8 frame. Top-left origin, no padded rows.
pub const FRAME_CONTENT_TYPE: &str = "image/x-rgba8";
/// The content type of a native audio chunk: interleaved little-endian f32.
pub const AUDIO_CONTENT_TYPE: &str = "audio/x-f32le";
/// The attachment name one view's pixels travel under.
pub fn view_attachment(view_id: &str) -> String {
format!("view.{view_id}")
}
/// The attachment name one audio stream's samples travel under.
pub fn audio_attachment(stream_id: &str) -> String {
format!("audio.{stream_id}")
}
fn store_error(what: &str, message: &str) -> DomainError {
DomainError::new(
ErrorCode::BackendFailure,
format!("{what}: {message}"),
MutationCertainty::Applied,
)
}
fn media_error(message: impl std::fmt::Display) -> DomainError {
// A world that advanced without usable media leaves the transition's certainty unknown:
// the mutation happened, the observation of it did not.
DomainError::new(ErrorCode::BufferInvalid, message, MutationCertainty::Unknown)
}
// ----------------------------------------------------------------------------------------------
// Production
/// How many frames a producer has actually rendered.
///
/// A shared counter, so a test can prove that forwarding one image to several recipients
/// renders it once.
#[derive(Clone, Debug, Default)]
pub struct RenderCounter(Arc<AtomicU64>);
impl RenderCounter {
pub fn new() -> RenderCounter {
RenderCounter::default()
}
pub fn bump(&self) {
self.0.fetch_add(1, Ordering::SeqCst);
}
pub fn count(&self) -> u64 {
self.0.load(Ordering::SeqCst)
}
}
/// One native frame of the counter arena: a real synthetic pattern, not a constant fill.
///
/// Top-left RGBA8 with `rowStride` exactly `4 x width` and no padded rows, which is the only
/// pixel layout v1 has. The red channel carries the world counter, so a reader that samples
/// one pixel still reads the world; green is a horizontal ramp and blue a vertical ramp with
/// a one-column bar that walks with the boundary, so consecutive frames differ.
pub fn arena_frame(descriptor: &ViewDescriptor, counter: i64, boundary: u64) -> Vec<u8> {
let width = descriptor.width;
let height = descriptor.height;
let stride = descriptor.row_stride as usize;
let mut out = vec![0u8; stride * height as usize];
let counter_byte = (counter & 0xff) as u8;
let bar = boundary % width;
for y in 0..height {
let row = y as usize * stride;
for x in 0..width {
let p = row + x as usize * 4;
let ramp_x = if width > 1 {
(x * 255 / (width - 1)) as u8
} else {
0
};
let ramp_y = if height > 1 {
(y * 255 / (height - 1)) as u8
} else {
0
};
out[p] = counter_byte;
out[p + 1] = ramp_x;
out[p + 2] = if x == bar { 255 } else { ramp_y };
out[p + 3] = 255;
}
}
out
}
/// One view's production pipeline: render at every boundary, deliver with the declared delay.
///
/// `observationDelaySteps` is a real queue here. At boundary `b` the required frame is the one
/// produced at `max(0, b - delay)`, so a declared delay of two repeats `O[0]` at boundaries 0,
/// 1 and 2 -- the bootstrap repetition the contract allows -- and then advances one frame per
/// boundary. Nothing beyond that is retained: an older frame is dropped, so a later boundary
/// cannot be served an arbitrary stale image.
pub struct ViewPipeline {
descriptor: ViewDescriptor,
frames: VecDeque<(u64, flybus::Artifact)>,
renders: RenderCounter,
}
impl ViewPipeline {
pub fn new(descriptor: ViewDescriptor, renders: RenderCounter) -> ViewPipeline {
ViewPipeline {
descriptor,
frames: VecDeque::new(),
renders,
}
}
pub fn descriptor(&self) -> &ViewDescriptor {
&self.descriptor
}
/// Seals one immutable frame for `boundary` and files it under its producing boundary.
pub async fn render(
&mut self,
client: &flybus::Client,
boundary: u64,
counter: i64,
) -> DomainResult<()> {
let bytes = arena_frame(&self.descriptor, counter, boundary);
let artifact = seal(client, FRAME_CONTENT_TYPE, &bytes).await?;
self.renders.bump();
self.frames.push_back((boundary, artifact));
// Keep exactly the frames a declared delay can still require.
while self.frames.len() > self.descriptor.observation_delay_steps as usize + 1 {
self.frames.pop_front();
}
Ok(())
}
/// Seals a frame of the wrong length, which is what a broken backend produces. The
/// reference it returns describes the artifact honestly, so the shape check is the thing
/// under test rather than a lie in the payload.
pub async fn render_truncated(
&mut self,
client: &flybus::Client,
boundary: u64,
counter: i64,
) -> DomainResult<()> {
let mut bytes = arena_frame(&self.descriptor, counter, boundary);
bytes.truncate(bytes.len() - self.descriptor.row_stride as usize);
let artifact = seal(client, FRAME_CONTENT_TYPE, &bytes).await?;
self.renders.bump();
self.frames.push_back((boundary, artifact));
while self.frames.len() > self.descriptor.observation_delay_steps as usize + 2 {
self.frames.pop_front();
}
Ok(())
}
/// The view reference and the owned handle a required sensory view has at `boundary`.
pub fn at(&self, boundary: u64) -> Option<(ViewRef, flybus::Artifact)> {
let produced = self.descriptor.required_produced_step(boundary);
self.frame_produced_at(produced)
}
/// The frame produced at exactly `produced`, if it is still retained.
pub fn frame_produced_at(&self, produced: u64) -> Option<(ViewRef, flybus::Artifact)> {
self.frames
.iter()
.find(|(step, _)| *step == produced)
.map(|(step, artifact)| {
(
ViewRef {
view_id: self.descriptor.view_id.clone(),
produced_step: *step,
pixels: artifact.reference().clone(),
},
artifact.clone(),
)
})
}
/// How many boundaries this pipeline has rendered.
pub fn renders(&self) -> u64 {
self.renders.count()
}
}
/// One audio stream's production: an exact sample budget and a deterministic waveform.
///
/// The number of frames in a step is `sampleRate x stepDuration`, accumulated as a rational so
/// a cadence that does not divide the sample rate never drifts: 8 kHz at 60 Hz produces
/// 133, 133, 134, ... and the sum is exact at every boundary. The waveform is integer-phase
/// arithmetic only, so a `fixed-build` environment produces the same bytes on every run.
pub struct AudioSource {
descriptor: AudioDescriptor,
/// The unconsumed fraction of a frame, over `denominator`.
accumulator: u128,
denominator: u128,
next_sample: u64,
phase: u64,
chunks: u64,
discontinuous: bool,
}
impl AudioSource {
/// A fresh episode, whose first chunk starts at the configured audio origin.
pub fn new(descriptor: AudioDescriptor, origin: u64) -> AudioSource {
AudioSource {
descriptor,
accumulator: 0,
denominator: 1,
next_sample: origin,
phase: 0,
chunks: 0,
discontinuous: false,
}
}
/// A new epoch after a restore: the sample position is preserved and the first chunk of
/// this epoch marks a discontinuity.
pub fn restored_at(descriptor: AudioDescriptor, sample: u64) -> AudioSource {
let mut source = AudioSource::new(descriptor, sample);
source.discontinuous = true;
source
}
pub fn descriptor(&self) -> &AudioDescriptor {
&self.descriptor
}
pub fn next_sample(&self) -> u64 {
self.next_sample
}
pub fn chunks(&self) -> u64 {
self.chunks
}
/// The exact number of sample frames one step of `step` nanoseconds contains.
///
/// The remainder is kept, never rounded: the accumulator is integer arithmetic over the
/// common denominator `stepDenominator x 1e9`.
pub fn frames_for_step(&mut self, step: &RationalNs) -> DomainResult<u64> {
let denominator = u128::from(step.denominator)
.checked_mul(1_000_000_000)
.ok_or_else(|| DomainError::invalid("audio: the step denominator overflows"))?;
if self.denominator != denominator {
// A cadence change would need a new epoch; carrying a remainder across one would
// be a silent resample.
if self.chunks > 0 {
return Err(DomainError::invalid(
"audio: the cadence changed inside an epoch",
));
}
self.denominator = denominator;
}
let per_step = u128::from(self.descriptor.sample_rate)
.checked_mul(u128::from(step.numerator))
.ok_or_else(|| DomainError::invalid("audio: the sample budget overflows"))?;
self.accumulator = self
.accumulator
.checked_add(per_step)
.ok_or_else(|| DomainError::invalid("audio: the sample accumulator overflows"))?;
let frames = self.accumulator / self.denominator;
self.accumulator %= self.denominator;
u64::try_from(frames).map_err(|_| DomainError::invalid("audio: too many frames in a step"))
}
/// Produces one chunk covering exactly one step of world time.
pub async fn produce(
&mut self,
client: &flybus::Client,
step: &RationalNs,
counter: i64,
) -> DomainResult<(AudioRef, flybus::Artifact)> {
let frames = self.frames_for_step(step)?;
let bytes = self.samples(frames, counter);
let artifact = seal(client, AUDIO_CONTENT_TYPE, &bytes).await?;
let chunk = AudioRef {
stream_id: self.descriptor.stream_id.clone(),
first_sample: self.next_sample,
sample_frames: frames,
samples: artifact.reference().clone(),
discontinuity: self.discontinuous && self.chunks == 0,
};
self.next_sample = self
.next_sample
.checked_add(frames)
.ok_or_else(|| DomainError::invalid("audio: the sample position overflows"))?;
self.chunks += 1;
Ok((chunk, artifact))
}
/// A deterministic triangle wave whose pitch follows the world counter, interleaved across
/// the declared channels. Every sample is finite by construction.
fn samples(&mut self, frames: u64, counter: i64) -> Vec<u8> {
let rate = self.descriptor.sample_rate;
let channels = self.descriptor.channels;
let step = 220 + (counter.rem_euclid(8) as u64) * 55;
let mut out = Vec::with_capacity((frames * channels * 4) as usize);
for _ in 0..frames {
self.phase = (self.phase + step) % rate;
let position = self.phase as f32 / rate as f32;
// 1 - 2|2p - 1| is a triangle in [-1, 1] built from exact IEEE operations.
let value = 1.0 - 2.0 * (2.0 * position - 1.0).abs();
for channel in 0..channels {
let scaled = value * 0.25 / (channel + 1) as f32;
out.extend_from_slice(&scaled.to_le_bytes());
}
}
out
}
}
/// Allocates, writes and seals one immutable artifact.
async fn seal(
client: &flybus::Client,
content_type: &str,
bytes: &[u8],
) -> DomainResult<flybus::Artifact> {
let mut writer = client
.artifacts()
.allocate(bytes.len() as u64, content_type)
.await
.map_err(|e| store_error("allocate", &e.message))?;
writer
.write_all(bytes)
.map_err(|e| store_error("write", &e.to_string()))?;
writer
.seal()
.await
.map_err(|e| store_error("seal", &e.message))
}
/// Splits one reply's attachments into the view handles and the audio handles.
///
/// Views are sensory data forwarded to agents; audio is presentation data that is published
/// and never attached to a sensory input.
pub fn split_attachments(
artifacts: BTreeMap<String, flybus::Artifact>,
) -> (
BTreeMap<String, flybus::Artifact>,
BTreeMap<String, flybus::Artifact>,
) {
let mut views = BTreeMap::new();
let mut audio = BTreeMap::new();
for (name, artifact) in artifacts {
if name.starts_with("audio.") {
audio.insert(name, artifact);
} else {
views.insert(name, artifact);
}
}
(views, audio)
}
/// The attachment names one environment's declared media travel under.
pub fn attachment_names(descriptor: &EnvironmentDescriptor) -> Vec<String> {
descriptor
.views
.iter()
.map(|view| view_attachment(&view.view_id))
.chain(
descriptor
.audio
.iter()
.map(|stream| audio_attachment(&stream.stream_id)),
)
.collect()
}
// ----------------------------------------------------------------------------------------------
// Acceptance
/// Every declared view must be present at exactly the producing boundary its delay implies.
///
/// A missing spectator frame is tolerable; a missing required sensory input is not. Neither is
/// one that arrived from an older boundary than the declared delay allows: the transition
/// fails instead of the session substituting whatever frame it happens to hold.
pub fn check_required_views(
descriptor: &EnvironmentDescriptor,
observation: &WorldObservation,
) -> DomainResult<()> {
for view in &descriptor.views {
let want = required_produced_step(view, observation.boundary);
match observation
.sensory_views
.iter()
.find(|given| given.view_id == view.view_id)
{
Some(given) if given.produced_step == want => {}
Some(given) => {
return Err(media_error(format!(
"view {} came from boundary {}, and its declared delay of {} requires {want}",
view.view_id, given.produced_step, view.observation_delay_steps
)));
}
None => {
return Err(media_error(format!(
"required sensory view {} is missing",
view.view_id
)));
}
}
}
Ok(())
}
/// Every declared audio stream produces exactly one chunk per transition.
///
/// The contract states the shape and the ordering of chunks, not whether one has to exist, so
/// this is MEDIA-01's choice and it is deliberate: a session that tolerates a silently missing
/// chunk cannot tell "this world produced no audio for this interval" from "the chunk was
/// lost", and the second is the case the retention rules care about. Boundary 0 has no
/// preceding interval and so carries no chunk.
pub fn check_required_audio(
descriptor: &EnvironmentDescriptor,
observation: &WorldObservation,
) -> DomainResult<()> {
if observation.boundary == 0 {
return Ok(());
}
for stream in &descriptor.audio {
if !observation
.audio
.iter()
.any(|chunk| chunk.stream_id == stream.stream_id)
{
return Err(media_error(format!(
"declared audio stream {} produced no chunk for this transition",
stream.stream_id
)));
}
}
Ok(())
}
/// Every declared audio stream's chunk sequence, one timeline per stream and epoch.
#[derive(Clone, Debug, Default)]
pub struct AudioTimelines(BTreeMap<String, AudioTimeline>);
impl AudioTimelines {
/// Fresh timelines for a new episode: every declared stream starts at origin zero.
pub fn fresh(descriptor: &EnvironmentDescriptor) -> AudioTimelines {
AudioTimelines(
descriptor
.audio
.iter()
.map(|stream| (stream.stream_id.clone(), AudioTimeline::fresh(stream, 0)))
.collect(),
)
}
/// Timelines for a new epoch after a restore: each stream resumes at its preserved sample
/// position, and each one's first chunk must mark a discontinuity.
///
/// A declared stream with no recorded position is an error. Resuming it at sample zero
/// would restart the episode's audio clock silently, which is exactly the best-effort
/// policy the restore rules refuse: crash restore *preserves* the sample position.
pub fn restored(
descriptor: &EnvironmentDescriptor,
positions: &BTreeMap<String, u64>,
) -> DomainResult<AudioTimelines> {
let mut timelines = BTreeMap::new();
for stream in &descriptor.audio {
let at = positions.get(&stream.stream_id).copied().ok_or_else(|| {
DomainError::before(
ErrorCode::IncompatibleState,
format!(
"audio stream {} has no restored sample position",
stream.stream_id
),
)
})?;
timelines.insert(stream.stream_id.clone(), AudioTimeline::restored_at(stream, at));
}
Ok(AudioTimelines(timelines))
}
/// Accepts one observation's chunks. Unknown streams and out-of-sequence chunks fail.
pub fn accept(
&mut self,
descriptor: &EnvironmentDescriptor,
observation: &WorldObservation,
) -> DomainResult<()> {
for chunk in &observation.audio {
let declared = descriptor
.audio_stream(&chunk.stream_id)
.ok_or_else(|| media_error(format!("audio stream {} is not declared", chunk.stream_id)))?;
let timeline = self
.0
.get_mut(&chunk.stream_id)
.ok_or_else(|| media_error(format!("audio stream {} has no timeline", chunk.stream_id)))?;
timeline.accept(chunk, declared).map_err(media_error)?;
}
Ok(())
}
/// Where each stream's next chunk may start.
pub fn positions(&self) -> BTreeMap<String, u64> {
self.0
.iter()
.map(|(id, timeline)| (id.clone(), timeline.next_sample()))
.collect()
}
pub fn accepted(&self, stream_id: &str) -> u64 {
self.0.get(stream_id).map_or(0, AudioTimeline::accepted)
}
}
// ----------------------------------------------------------------------------------------------
// Consumption
/// What one agent actually sensed, recorded where a test can read it.
///
/// The fake agent is the only thing that reads the pixels, so this is how "one shared image
/// reached both agents" is proved from the agents' side rather than from the producer's.
#[derive(Clone, Debug, Default)]
pub struct SensorLog(Arc<std::sync::Mutex<Vec<SensedView>>>);
/// One view an agent read: which boundary it was consumed at, which artifact it was, and the
/// digest of the bytes the agent actually read.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SensedView {
pub boundary: u64,
pub view_id: Id,
pub artifact_id: String,
pub produced_step: u64,
pub digest: Digest,
}
impl SensorLog {
pub fn new() -> SensorLog {
SensorLog::default()
}
pub fn record(&self, view: SensedView) {
self.0.lock().expect("the sensor log is never poisoned").push(view);
}
pub fn entries(&self) -> Vec<SensedView> {
self.0.lock().expect("the sensor log is never poisoned").clone()
}
/// The artifacts this agent read, in order.
pub fn artifact_ids(&self) -> Vec<String> {
self.entries().into_iter().map(|v| v.artifact_id).collect()
}
}
/// One frame a spectator took off its subscription.
pub struct SpectatorFrame {
pub boundary: u64,
/// Every agent the committed snapshot carries, in publication order. A presentation
/// consumer is a multi-agent consumer: one snapshot holds the whole session.
pub agents: Vec<Id>,
pub sequence: u64,
/// How many undelivered snapshots were coalesced into this one.
pub replaced: u64,
pub view: ViewRef,
pub artifact: flybus::Artifact,
/// Each published chunk with the handle it travelled on.
pub audio: Vec<(AudioRef, flybus::Artifact)>,
}
/// A presentation-side consumer of committed snapshots.
///
/// It subscribes `latest` with finite credits, which is the spectator row of the domain
/// retention table: new snapshots replace its queued value, it never blocks the session, and
/// the only thing a slow one exhausts is its own credits.
pub struct Spectator {
subscription: flybus::Subscription,
held: Vec<flybus::Message>,
seen: u64,
coalesced: u64,
}
impl Spectator {
/// Subscribes to `topic` in latest mode with `credits` in flight.
///
/// A latest subscription always has exactly one queued value; `credits` is its in-flight
/// bound, which the router limits (two by default). Finite credits are the spectator row
/// of the domain retention table: they are the only thing a slow viewer exhausts.
pub async fn attach(
client: &flybus::Client,
topic: &str,
credits: u32,
) -> Result<Spectator, flybus::BusError> {
let subscription = client
.subscribe(
topic,
flybus::SubscriptionConfig::latest().in_flight(credits).replay(true),
)
.await?;
Ok(Spectator {
subscription,
held: Vec::new(),
seen: 0,
coalesced: 0,
})
}
/// Takes the next snapshot, reads its frame and releases the delivery.
pub async fn take_frame(&mut self) -> Option<SpectatorFrame> {
let message = self.subscription.next().await?;
self.seen += 1;
self.coalesced += message.replaced();
let frame = snapshot_frame(&message);
drop(message);
frame
}
/// Takes the next snapshot message itself, for a renderer that keeps its own handle
/// after the message is gone.
pub async fn next_message(&mut self) -> Option<flybus::Message> {
let message = self.subscription.next().await?;
self.seen += 1;
self.coalesced += message.replaced();
Some(message)
}
/// Takes a snapshot without consuming it, which is what a viewer that stops rendering
/// does. Its credits run out and nothing else in the session notices.
pub async fn hold_one(&mut self) -> bool {
match self.subscription.next().await {
Some(message) => {
self.seen += 1;
self.coalesced += message.replaced();
self.held.push(message);
true
}
None => false,
}
}
/// Takes a snapshot without consuming it if one is queued right now.
pub fn try_hold_one(&mut self) -> bool {
match self.subscription.try_next() {
Some(message) => {
self.seen += 1;
self.coalesced += message.replaced();
self.held.push(message);
true
}
None => false,
}
}
/// Releases everything this spectator was holding, returning its credits.
pub fn release(&mut self) {
self.held.clear();
}
pub fn held(&self) -> usize {
self.held.len()
}
pub fn seen(&self) -> u64 {
self.seen
}
/// How many snapshots were replaced in this spectator's queue while it was busy.
pub fn coalesced(&self) -> u64 {
self.coalesced
}
}
/// Reads one committed snapshot's first view and its handle.
pub fn snapshot_frame(message: &flybus::Message) -> Option<SpectatorFrame> {
let payload = message.payload();
let boundary = payload
.get("scope")
.and_then(|s| s.get("step"))
.and_then(serde_json::Value::as_str)
.and_then(|s| s.parse::<u64>().ok())?;
let media = payload.get("media")?;
let views = media.get("views")?.as_array()?;
let view = ViewRef::from_json(views.first()?).ok()?;
// An unreadable chunk, or one whose handle is not attached, makes the whole snapshot
// unreadable rather than a snapshot that quietly has less audio in it than was published.
let mut audio = Vec::new();
for value in media.get("audio")?.as_array()? {
let chunk = AudioRef::from_json(value).ok()?;
let artifact = message.artifact(&audio_attachment(&chunk.stream_id)).ok()?;
audio.push((chunk, artifact));
}
let artifact = message.artifact(&view_attachment(&view.view_id)).ok()?;
let agents = payload
.get("agents")
.and_then(serde_json::Value::as_array)
.map(|agents| {
agents
.iter()
.filter_map(|a| a.get("agentId").and_then(serde_json::Value::as_str))
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
Some(SpectatorFrame {
boundary,
agents,
sequence: message.topic_sequence(),
replaced: message.replaced(),
view,
artifact,
audio,
})
}
/// Takes the frame out of a message and drops the message, as a renderer that finishes later
/// does. The delivery stays alive because the extracted handle still owns it.
pub fn detach_frame(message: flybus::Message) -> Option<(ViewRef, flybus::Artifact)> {
let frame = snapshot_frame(&message)?;
drop(message);
Some((frame.view, frame.artifact))
}
// ----------------------------------------------------------------------------------------------
// Persistent assets
/// The preprovisioned local registry an `AssetRef` names.
///
/// An asset is installed and verified before a run; it is not a path, a URL or something a
/// worker fetches. Nothing in this registry can be addressed by an `ArtifactRef`, and
/// [`AssetRegistry::import`] hands back a fresh transient artifact rather than turning the
/// asset into one.
#[derive(Clone, Debug, Default)]
pub struct AssetRegistry {
installed: BTreeMap<Id, (AssetRef, Vec<u8>)>,
}
impl AssetRegistry {
pub fn new() -> AssetRegistry {
AssetRegistry::default()
}
/// Installs content, verifying that it is the content the reference claims.
pub fn install(&mut self, asset: AssetRef, bytes: Vec<u8>) -> DomainResult<()> {
asset.validate().map_err(DomainError::invalid)?;
if asset.byte_length != bytes.len() as u64 {
return Err(DomainError::before(
ErrorCode::IdentityMismatch,
format!("asset {}: byteLength is not the installed length", asset.id),
));
}
if asset.digest != digest_of_bytes(&bytes) {
return Err(DomainError::before(
ErrorCode::IdentityMismatch,
format!("asset {}: digest is not the installed content", asset.id),
));
}
self.installed.insert(asset.id.clone(), (asset, bytes));
Ok(())
}
/// Resolves installed content. Identity and digest must both match: the same id with
/// another digest is a different asset, not an upgrade.
pub fn resolve(&self, asset: &AssetRef) -> DomainResult<&[u8]> {
match self.installed.get(&asset.id) {
Some((installed, bytes)) if installed == asset => Ok(bytes),
Some(_) => Err(DomainError::before(
ErrorCode::IdentityMismatch,
format!("asset {} is installed with another identity", asset.id),
)),
None => Err(DomainError::before(
ErrorCode::IdentityMismatch,
format!("asset {} is not installed", asset.id),
)),
}
}
pub fn contains(&self, asset: &AssetRef) -> bool {
self.resolve(asset).is_ok()
}
/// Imports installed content into the bus as a fresh immutable artifact.
///
/// The artifact is sealed against the asset's digest, which is mandatory for a persistent
/// asset import, and its identity belongs to the current store incarnation. The asset
/// reference is unchanged and outlives it.
pub async fn import(
&self,
client: &flybus::Client,
asset: &AssetRef,
) -> DomainResult<flybus::Artifact> {
let bytes = self.resolve(asset)?;
let mut writer = client
.artifacts()
.allocate(asset.byte_length, "application/octet-stream")
.await
.map_err(|e| store_error("allocate", &e.message))?;
writer
.write_all(bytes)
.map_err(|e| store_error("write", &e.to_string()))?;
let artifact = writer
.seal_with_digest(Some(asset.digest.clone()))
.await
.map_err(|e| store_error("seal", &e.message))?;
fly_session_types::media::check_imported_asset(asset, artifact.reference())
.map_err(|e| DomainError::before(ErrorCode::IdentityMismatch, e))?;
Ok(artifact)
}
}