Bind the reel for the browser: one .wrel envelope, every cargo class verified in-tab. ReelSink re-derives the reel_hash + per-frame Merkle root (reel-equivalence) AND, when the reel carries a soundtrack, the embedded score's mixdown_hash (mixdown-equivalence); exposes the per-frame camera pose, the embedded world bytes (driven by SkeletalSink / WorldSink), and the decoded i16 PCM so the page plays picture and sound in sync. The render + Web Audio playback are the sink's presentation; the verified quantities are the hashes. Co-Authored-By: Claude Opus 4.8 <[email protected]>
104 lines
3.8 KiB
Rust
104 lines
3.8 KiB
Rust
//! Browser sink for `wai.film.reel` — the synthesis surface. One `.wrel`
|
|
//! envelope carries several cargo classes at once: the cut picture (a sealed
|
|
//! world + a sequence of shots) and, optionally, the soundtrack (an embedded
|
|
//! `wai.audio.score`). This sink re-derives EVERY part in the tab and checks
|
|
//! it against the corpus — the reel's `reel_hash` + per-frame Merkle root
|
|
//! (reel-equivalence) and the score's `mixdown_hash` (mixdown-equivalence) —
|
|
//! and hands the page what it needs to play them in sync: the per-frame camera
|
|
//! pose, the embedded world bytes (driven by a WorldSink / SkeletalSink), and
|
|
//! the decoded PCM. Every class verified, one envelope, on the visitor's own
|
|
//! machine.
|
|
|
|
use wasm_bindgen::prelude::*;
|
|
|
|
use wai::film::Reel;
|
|
use wai::world::receipt::merkle_root;
|
|
|
|
#[wasm_bindgen]
|
|
pub struct ReelSink {
|
|
reel: Reel,
|
|
}
|
|
|
|
fn hex(b: &[u8]) -> String {
|
|
b.iter().map(|x| format!("{x:02x}")).collect()
|
|
}
|
|
|
|
#[wasm_bindgen]
|
|
impl ReelSink {
|
|
#[wasm_bindgen(constructor)]
|
|
pub fn new(wrel: &[u8]) -> Result<ReelSink, JsError> {
|
|
Ok(ReelSink { reel: Reel::from_bytes(wrel).map_err(|e| JsError::new(&e.to_string()))? })
|
|
}
|
|
|
|
pub fn total_frames(&self) -> u64 {
|
|
self.reel.total_frames()
|
|
}
|
|
pub fn shots(&self) -> u32 {
|
|
self.reel.shots.len() as u32
|
|
}
|
|
pub fn frame_tick(&self, f: u64) -> Result<u64, JsError> {
|
|
self.reel.frame_tick(f).ok_or_else(|| JsError::new("frame out of range"))
|
|
}
|
|
|
|
// ---- picture: reel-equivalence ----
|
|
pub fn reel_hash_hex(&self) -> String {
|
|
hex(&self.reel.reel_hash())
|
|
}
|
|
/// Merkle root over the per-frame `BLAKE3(world_state_hash || camera_pose)`
|
|
/// blocks — the reel-equivalence quantity (validates the world as a side
|
|
/// effect).
|
|
pub fn frames_root_hex(&self) -> Result<String, JsError> {
|
|
let fh = self.reel.frame_hashes().map_err(|e| JsError::new(&e.to_string()))?;
|
|
let leaves: Vec<[u8; 32]> = fh.iter().map(|x| x.hash).collect();
|
|
Ok(hex(&merkle_root(&leaves)))
|
|
}
|
|
|
|
/// Interpolated camera pose at global frame `f`: 8 f32 `[px,py,pz,
|
|
/// qx,qy,qz,qw, fov]`.
|
|
pub fn camera(&self, f: u64) -> Result<Box<[f32]>, JsError> {
|
|
let p = self.reel.camera_at_frame(f).ok_or_else(|| JsError::new("frame out of range"))?;
|
|
let mut out = Vec::with_capacity(8);
|
|
for v in p.pos {
|
|
out.push(v.approx_f64() as f32);
|
|
}
|
|
for v in p.rot {
|
|
out.push(v.approx_f64() as f32);
|
|
}
|
|
out.push(p.fov.approx_f64() as f32);
|
|
Ok(out.into_boxed_slice())
|
|
}
|
|
|
|
pub fn world_sim(&self) -> Result<String, JsError> {
|
|
Ok(self.reel.world().map_err(|e| JsError::new(&e.to_string()))?.contract.sim)
|
|
}
|
|
pub fn world_bytes(&self) -> Box<[u8]> {
|
|
self.reel.world_bytes.clone().into_boxed_slice()
|
|
}
|
|
|
|
// ---- sound: mixdown-equivalence (if the reel carries a soundtrack) ----
|
|
pub fn has_audio(&self) -> bool {
|
|
self.reel.audio_bytes.is_some()
|
|
}
|
|
pub fn audio_sample_rate(&self) -> Result<u32, JsError> {
|
|
let s = self.score()?;
|
|
Ok(s.contract.sample_rate)
|
|
}
|
|
pub fn samples_per_frame(&self) -> u64 {
|
|
self.reel.samples_per_frame().unwrap_or(0)
|
|
}
|
|
/// The soundtrack's canonical `i16` PCM mixdown — what a Web Audio buffer
|
|
/// plays, and the mixdown-equivalence quantity.
|
|
pub fn audio_pcm(&self) -> Result<Box<[i16]>, JsError> {
|
|
Ok(self.score()?.mixdown().into_boxed_slice())
|
|
}
|
|
pub fn audio_mixdown_hash_hex(&self) -> Result<String, JsError> {
|
|
Ok(hex(&self.score()?.mixdown_hash()))
|
|
}
|
|
|
|
fn score(&self) -> Result<wai::score::Score, JsError> {
|
|
self.reel
|
|
.audio_score()
|
|
.ok_or_else(|| JsError::new("reel has no soundtrack"))?
|
|
.map_err(|e| JsError::new(&e.to_string()))
|
|
}
|
|
}
|