facett-video/src/lib.rs
//! **facett-video** (§22) — a video player whose chrome (transport, play/pause,
//! scrubber, volume, time, fullscreen) is **themed** and driven by the shared
//! [`KeyMap`](facett_core::look::KeyMap); decode is a **pluggable backend** trait
//! ([`FrameSource`]). M2 ships the [`FrameSequence`] frame-source backend (a
//! consumer pushes RGBA frames → texture each frame → `ui.image`) for image
//! sequences / sim / camera (VID-2). The optional `egui-video` FFmpeg backend is
//! a host-side feature (VID-1).
//!
//! Overlay-ready (annotations/HUD via §5); **ROI selection** like the grid's
//! range select (VID-3). **Copy a frame** yields a typed
//! [`ClipPayload::Image`](facett_core::clip::ClipPayload) (the M2 image-copy
//! Exit criterion).
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! - **[`VideoState`]** — the complete observable, serializable, round-trippable
//! *transport* state: playing/paused, the current frame + its sub-frame clock
//! accumulator (the playhead), playback speed + loop, volume, the normalised ROI
//! selection, and the render scale. [`VideoPlayer::state`] hands back a
//! `&VideoState`. The decoded frames / GPU textures are the *input* — a boxed
//! [`FrameSource`] held on [`VideoPlayer`], **not** in the Model (like facett-grid's
//! Arrow data) — and the in-flight ROI drag anchor is an ephemeral gesture cache
//! (interior-mutable, FC-7), also outside the Model.
//! - **[`Msg`] + [`VideoPlayer::update`]** — the single mutation path (FC-2): play /
//! pause / toggle, seek + step, set speed / loop / volume, set ROI, set scale, and
//! the injected-clock [`Msg::Tick`] that advances the playhead. A headless driver
//! ([`facett_core::harness`]) reaches the identical transitions the chrome drives.
//! - **[`VideoPlayer::view`]** — a **pure** paint (FC-9): it reads `&self`, draws the
//! current frame + transport chrome and *returns* the [`Msg`]s the interactions
//! produced; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge
//! applies them via [`update`](VideoPlayer::update). The playhead advances via the
//! injected clock — a [`Msg::Tick`] emitted from the view while playing (from this
//! frame's `stable_dt`, FC-7), applied by `update` — never a mutation in the view.
//! - **FC-5 stable-id selection** — the ROI is addressed in normalised `[0,1]` frame
//! coordinates (a stable key, invariant to the on-screen letterbox size), so a
//! headless driver and the canvas agree on the same region.
//! - **Rich `state_json`** — the `frames` / `time` / `duration` a driver reads are
//! *derived* from the [`FrameSource`] (which is outside the Model), so the macro is
//! invoked in **form 3** (`custom_state_json`) to publish those keys.
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::look::{Action, keymap};
use facett_core::{FacetCaps, theme};
use serde::{Deserialize, Serialize};
use std::cell::Cell;
/// A pluggable decode backend (VID-1): hands the player the current frame as an
/// egui [`egui::ColorImage`] and reports duration/position so the chrome can draw
/// a scrubber. Deterministic backends (image sequences) make the player testable.
pub trait FrameSource {
/// Total frames (for the scrubber).
fn frame_count(&self) -> usize;
/// The frame at index `i` as RGBA, or `None` if out of range.
fn frame(&self, i: usize) -> Option<egui::ColorImage>;
/// Frames per second (for time display + playback advance).
fn fps(&self) -> f32 {
30.0
}
}
/// The frame-source backend (VID-2): an in-memory RGBA image sequence a consumer
/// pushes into. Deterministic → the player snapshots + tests headlessly.
#[derive(Clone, Default)]
pub struct FrameSequence {
frames: Vec<egui::ColorImage>,
fps: f32,
}
impl FrameSequence {
pub fn new(fps: f32) -> Self {
Self { frames: Vec::new(), fps: fps.max(1.0) }
}
pub fn push(&mut self, frame: egui::ColorImage) {
self.frames.push(frame);
}
/// A test pattern: `n` solid-colour `w×h` frames cycling hues.
pub fn test_pattern(n: usize, w: usize, h: usize) -> Self {
let mut s = Self::new(24.0);
for i in 0..n {
let c = Color32::from_rgb(((i * 37) % 256) as u8, ((i * 53) % 256) as u8, ((i * 97) % 256) as u8);
s.push(egui::ColorImage::new([w, h], vec![c; w * h]));
}
s
}
}
impl FrameSource for FrameSequence {
fn frame_count(&self) -> usize {
self.frames.len()
}
fn frame(&self, i: usize) -> Option<egui::ColorImage> {
self.frames.get(i).cloned()
}
fn fps(&self) -> f32 {
self.fps
}
}
/// A robot-/CLI-addressable transport message (FC-2) — the named boundary a headless
/// driver (or a host) drives the player through, the same effect a chrome gesture
/// produces. Applied by [`VideoPlayer::update`]; the **only** legal way to mutate the
/// [`VideoState`].
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
/// Start playback.
Play,
/// Pause playback (the playhead holds its frame).
Pause,
/// Flip play ↔ pause (the Confirm key / play button).
TogglePlay,
/// Seek to an absolute frame index (clamped to the decoded range).
Seek(usize),
/// Step the playhead by `±n` frames (clamped; the Left/Right keys step ∓1/±1).
StepFrame(i64),
/// Set the playback speed multiplier (clamped to a sane range; `1.0` = real time).
SetSpeed(f32),
/// Toggle whether the playhead wraps at the end (vs. stopping on the last frame).
SetLoop(bool),
/// Set the audio volume in `[0,1]`.
SetVolume(f32),
/// Set (or clear) the normalised `[0,1]` ROI selection (VID-3).
SetRoi(Option<Rect>),
/// Set the render scale (the deck's scale gesture).
SetScale(f32),
/// **Injected clock (FC-7)** — advance the playhead by `dt` seconds. Emitted from
/// the pure [`view`](VideoPlayer::view) each frame while playing (from this frame's
/// `stable_dt`), and the host/test hook [`advance`](VideoPlayer::advance) applies
/// the same `Msg` — so live == headless and playback is deterministic.
Tick(f32),
}
/// Side work as data (FC-8). The player's decode backend is in-memory + synchronous
/// (M2 [`FrameSequence`]), so every [`Msg`] mutates only the in-memory [`VideoState`]
/// and this is uninhabited on purpose: the type-checked statement that
/// [`VideoPlayer::update`] never asks the host to do async I/O.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
/// **The complete observable transport state (FC-1 / FC-3)** of a [`VideoPlayer`], in
/// one serializable, round-trippable struct: whether it is playing, the current frame
/// with its sub-frame clock accumulator (the playhead), the playback speed and loop
/// flag, the volume, the normalised ROI (FC-5 stable key), and the render scale.
/// [`VideoPlayer::state`] hands back a `&VideoState`; a headless driver
/// ([`facett_core::harness`]) snapshots it after feeding a `Vec<Msg>`. The decoded
/// frames (the [`FrameSource`]) are the *input*, held on [`VideoPlayer`] and
/// deliberately kept OUT of this Model.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VideoState {
/// Whether playback is running.
pub playing: bool,
/// The current frame index (the playhead).
pub frame: usize,
/// Sub-frame accumulator for the injected clock (fractional frames pending).
pub accum: f32,
/// Playback speed multiplier (`1.0` = real time).
pub speed: f32,
/// Whether the playhead wraps at the end (vs. stopping on the last frame).
pub looping: bool,
/// Audio volume in `[0,1]`.
pub volume: f32,
/// The region-of-interest selection in normalised `[0,1]` frame coords, as
/// `[left, top, right, bottom]` (VID-3, FC-5 stable key). `None` = no selection.
pub roi: Option<[f32; 4]>,
/// The render scale.
pub scale: f32,
}
impl Default for VideoState {
fn default() -> Self {
Self {
playing: false,
frame: 0,
accum: 0.0,
speed: 1.0,
looping: true,
volume: 1.0,
roi: None,
scale: 1.0,
}
}
}
/// The video player. Owns the transport [`VideoState`] + a boxed decode
/// [`FrameSource`] (the input, outside the Model). Playback advances on an
/// **injected clock** ([`Msg::Tick`] / [`advance`](Self::advance)), so it is
/// deterministic for snapshots.
pub struct VideoPlayer {
title: String,
/// The decode backend — the *input* (like facett-grid's Arrow data), NOT part of
/// the observable [`VideoState`].
source: Box<dyn FrameSource>,
/// All observable transport state (FC-3).
state: VideoState,
/// The in-flight ROI drag anchor (normalised). **Ephemeral gesture cache, not
/// observable state** — interior-mutable so the FC-9 pure [`view`](Self::view) can
/// record the drag start through a `&self` borrow; deliberately outside the Model.
roi_anchor: Cell<Option<egui::Pos2>>,
}
impl VideoPlayer {
pub fn new(title: impl Into<String>, source: impl FrameSource + 'static) -> Self {
Self {
title: title.into(),
source: Box::new(source),
state: VideoState::default(),
roi_anchor: Cell::new(None),
}
}
/// **FC-3** — read the complete observable transport state at any frame boundary.
pub fn state(&self) -> &VideoState {
&self.state
}
pub fn play(&mut self) {
let _ = self.update(Msg::Play);
}
pub fn pause(&mut self) {
let _ = self.update(Msg::Pause);
}
pub fn toggle(&mut self) {
let _ = self.update(Msg::TogglePlay);
}
pub fn is_playing(&self) -> bool {
self.state.playing
}
pub fn frame_index(&self) -> usize {
self.state.frame
}
pub fn roi(&self) -> Option<Rect> {
self.state.roi.map(|r| Rect::from_min_max(pos2(r[0], r[1]), pos2(r[2], r[3])))
}
/// Seek to a frame (scrubber / programmatic).
pub fn seek(&mut self, f: usize) {
let _ = self.update(Msg::Seek(f));
}
/// Set a normalised ROI (VID-3).
pub fn set_roi(&mut self, roi: Option<Rect>) {
let _ = self.update(Msg::SetRoi(roi));
}
/// Advance playback by `dt` seconds on the injected clock. The host-driven
/// analogue of the [`Msg::Tick`] the view emits — routed through the same
/// [`update`](Self::update) path, so live == headless.
pub fn advance(&mut self, dt: f32) {
let _ = self.update(Msg::Tick(dt));
}
/// The current frame image (for copy / display). Reads the [`FrameSource`] — the
/// decoded input, outside the Model.
pub fn current_frame(&self) -> Option<egui::ColorImage> {
self.source.frame(self.state.frame)
}
/// Current playback time + duration in seconds (derived from the source fps).
fn time_and_duration(&self) -> (f32, f32) {
let fps = self.source.fps();
(self.state.frame as f32 / fps, self.source.frame_count() as f32 / fps)
}
/// The last decodable frame index (`0` when empty).
fn last_frame(&self) -> usize {
self.source.frame_count().saturating_sub(1)
}
/// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
/// (always empty) [`Effect`]s the host should run. Every chrome gesture and key
/// routes its [`Msg`] here too, so live == headless.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::Play => self.state.playing = true,
Msg::Pause => self.state.playing = false,
Msg::TogglePlay => self.state.playing = !self.state.playing,
Msg::Seek(f) => {
self.state.frame = f.min(self.last_frame());
self.state.accum = 0.0;
}
Msg::StepFrame(delta) => {
let last = self.last_frame() as i64;
let next = (self.state.frame as i64 + delta).clamp(0, last.max(0));
self.state.frame = next.max(0) as usize;
self.state.accum = 0.0;
}
Msg::SetSpeed(s) => self.state.speed = s.clamp(0.0, 16.0),
Msg::SetLoop(l) => self.state.looping = l,
Msg::SetVolume(v) => self.state.volume = v.clamp(0.0, 1.0),
Msg::SetRoi(roi) => {
self.state.roi = roi.map(|r| [r.left(), r.top(), r.right(), r.bottom()]);
}
Msg::SetScale(s) => self.state.scale = s.clamp(0.25, 4.0),
Msg::Tick(dt) => self.tick(dt),
}
Vec::new()
}
/// Advance the playhead by `dt` seconds on the injected clock (the body of
/// [`Msg::Tick`]). Loops at the end when [`VideoState::looping`], otherwise clamps
/// to the last frame and stops. A no-op when paused or the source is empty.
fn tick(&mut self, dt: f32) {
let count = self.source.frame_count();
if !self.state.playing || count == 0 {
return;
}
self.state.accum += dt * self.source.fps() * self.state.speed;
while self.state.accum >= 1.0 {
self.state.accum -= 1.0;
if self.state.looping {
self.state.frame = (self.state.frame + 1) % count;
} else if self.state.frame + 1 < count {
self.state.frame += 1;
} else {
// Reached the end with looping off: stop on the last frame.
self.state.playing = false;
self.state.accum = 0.0;
break;
}
}
}
}
impl VideoPlayer {
/// **FC-9 render** — a **pure** function of `&self`: it draws the current frame +
/// the transport chrome and *returns* the [`Msg`]s the interactions produced. It
/// MUST NOT mutate the [`VideoState`]; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm)
/// bridge applies the returned messages via [`update`](Self::update). The playhead
/// advances via the injected-clock [`Msg::Tick`] emitted here while playing (FC-7);
/// the only `&self`-interior mutation is the ephemeral ROI drag anchor, outside the
/// observable Model.
pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
let mut msgs: Vec<Msg> = Vec::new();
// Transport via the shared KeyMap (COH-2): Confirm toggles play/pause,
// Left/Right step frames. FC-9: emit the Msg instead of mutating `self`.
let km = keymap(ui);
if km.consume(Action::Confirm, ui) {
msgs.push(Msg::TogglePlay);
}
if km.consume(Action::Right, ui) {
msgs.push(Msg::StepFrame(1));
}
if km.consume(Action::Left, ui) {
msgs.push(Msg::StepFrame(-1));
}
let th = theme(ui);
// The video surface.
let avail = ui.available_size();
let (rect, resp) = ui.allocate_exact_size(vec2(avail.x, (avail.y - 36.0).max(60.0)), Sense::click_and_drag());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, th.bg);
// Draw the current frame as a texture (themed letterbox around it). Loading a
// texture is an egui-context cache op, not a mutation of the observable Model.
// The uniform scale knob (`Facet::scale`, the deck's +/−/Reset toolbar) is a
// RENDER SCALE: it zooms the picture about the centre of the surface, letting a
// user magnify a region without touching the ROI. `painter_at(rect)` clips, so
// scaling past 1.0 crops rather than bleeding over the transport controls.
//
// It used to be stored, clamped to 0.25..=4.0 and published in `state_json` and
// then read by NOTHING — the tab advertised `.scalable()`, the toolbar drew
// +/−/Reset and a live percentage, and not one pixel moved. Guarded by
// `scale_is_applied_to_the_drawn_surface_not_just_stored`.
let s = self.state.scale;
let surface = Rect::from_center_size(rect.center(), rect.size() * s);
if let Some(img) = self.current_frame() {
// One id re-`set` per frame (see `facett_core::render::cpu::frame_texture`).
let tex = facett_core::render::cpu::frame_texture(ui, "facett_video_frame", img, egui::TextureOptions::LINEAR);
painter.image(tex, surface, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
} else {
painter.text(rect.center(), egui::Align2::CENTER_CENTER, "no signal", egui::FontId::proportional(13.0 * s), th.text_dim);
}
// ROI rubber-band (VID-3) — drag selects a normalised region. The drag anchor
// is an ephemeral interior-mutable gesture cache (FC-7); the resulting ROI is
// emitted as a Msg (FC-9) rather than written to `self`.
if let Some(p) = resp.interact_pointer_pos() {
let norm = |q: egui::Pos2| pos2((q.x - rect.left()) / rect.width(), (q.y - rect.top()) / rect.height());
if resp.drag_started() {
self.roi_anchor.set(Some(norm(p)));
} else if resp.dragged()
&& let Some(a) = self.roi_anchor.get()
{
msgs.push(Msg::SetRoi(Some(Rect::from_two_pos(a, norm(p)))));
}
}
if let Some(roi) = self.roi() {
let screen = Rect::from_min_max(
pos2(rect.left() + roi.left() * rect.width(), rect.top() + roi.top() * rect.height()),
pos2(rect.left() + roi.right() * rect.width(), rect.top() + roi.bottom() * rect.height()),
);
painter.rect_stroke(screen, 0.0, Stroke::new(2.0_f32, th.accent), StrokeKind::Inside);
painter.rect_filled(screen, 0.0, th.accent.linear_multiply(0.10));
}
// Transport chrome.
ui.horizontal(|ui| {
if ui.button(if self.state.playing { "⏸" } else { "▶" }).clicked() {
msgs.push(Msg::TogglePlay);
}
let (t, d) = self.time_and_duration();
// Scrubber.
let mut f = self.state.frame as f32;
let max = self.source.frame_count().saturating_sub(1).max(1) as f32;
if ui.add(egui::Slider::new(&mut f, 0.0..=max).show_value(false)).changed() {
msgs.push(Msg::Seek(f as usize));
}
ui.label(format!("{t:.1}s / {d:.1}s"));
ui.label("🔊");
let mut vol = self.state.volume;
if ui.add(egui::Slider::new(&mut vol, 0.0..=1.0).show_value(false)).changed() {
msgs.push(Msg::SetVolume(vol));
}
});
// Injected clock (FC-7): while playing, emit a Tick from this frame's
// `stable_dt` (the update loop applies it) and keep the frame pump alive.
if self.state.playing {
let dt = ui.input(|i| i.stable_dt);
msgs.push(Msg::Tick(dt));
ui.ctx().request_repaint();
}
// ── render-lane emit: this Facet::view path RAN ───────────────────────
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-video::VideoPlayer::view",
"ui_render",
// OK = the painted frame index is a real frame (rendered ≤ available):
// an empty source draws the "no signal" placeholder (frame 0, count 0),
// otherwise the current frame is strictly within the decoded count.
{
let n = self.source.frame_count();
n == 0 || self.state.frame < n
},
&format!(
"frame={} frames={} playing={}",
self.state.frame,
self.source.frame_count(),
self.state.playing,
),
);
msgs
}
}
// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for VideoPlayer {
type Model = VideoState;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &VideoState {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
VideoPlayer::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
VideoPlayer::view(self, ui)
}
}
// The bridge macro writes `impl Facet for VideoPlayer` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra overrides
// below. **Form 3** (`custom_state_json`) because the player publishes a RICHER
// `state_json` than a plain `serde(state())`: `frames` / `time` / `duration` are
// *derived* from the `FrameSource` (which is outside the Model), and `roi` is emitted
// as a `[l,t,r,b]` array — the exact keys the demo/matrix read, preserved verbatim.
facett_core::impl_facet_via_elm!(VideoPlayer, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
let (t, d) = self.time_and_duration();
serde_json::json!({
"title": self.title,
"playing": self.state.playing,
"frame": self.state.frame,
"frames": self.source.frame_count(),
"time": t,
"duration": d,
"volume": self.state.volume,
"roi": self.state.roi.map(|r| serde_json::json!([r[0], r[1], r[2], r[3]])),
"scale": self.state.scale,
"speed": self.state.speed,
"loop": self.state.looping,
})
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable().scalable().selectable().copyable()
}
fn scale(&self) -> f32 {
self.state.scale
}
fn set_scale(&mut self, scale: f32) {
let _ = self.update(Msg::SetScale(scale));
}
/// Copy the current frame's text descriptor (the typed Image is via
/// [`CopySource`]). Falls back to a frame tag for the OS text clipboard.
fn copy(&mut self) -> Option<String> {
self.current_frame().map(|i| format!("[video frame {} {}x{}]", self.state.frame, i.size[0], i.size[1]))
}
});
/// Copy the **current frame as a typed Image** (the M2 image-copy Exit criterion):
/// paste it into a viewer that accepts [`ClipKind::Image`].
impl CopySource for VideoPlayer {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Image, ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
self.current_frame().map(ClipPayload::Image)
}
}
#[cfg(test)]
mod tests;