Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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;