Skip to main content

azul_layout/widgets/
video.rs

1//! Video-playback widget - a "dumb widget" identical in architecture to the
2//! [`CameraWidget`](super::camera) / [`ScreenCaptureWidget`](super::screencap),
3//! only the source differs (a video URL/file decoded via vk-video).
4//! SUPER_PLAN_2 §4 P6, widget pivot.
5//!
6//! `VideoWidget::create(config).dom()` → an `<img>` a background decode thread
7//! keeps fed; each frame goes through [`super::capture_common::present_frame`]
8//! (GL-texture install-once / re-upload + recomposite). Shared core in
9//! `capture_common`; this widget is its config + worker. Test-pattern worker
10//! (scrolling SMPTE colour bars) stands in for the real vk-video decode worker.
11
12use alloc::vec::Vec;
13
14use azul_core::callbacks::{Update, VirtualViewCallbackInfo, VirtualViewReturn};
15use azul_core::dom::{ComponentEventFilter, DatasetMergeCallbackType, Dom, EventFilter, OptionDom};
16use azul_core::geom::LogicalPosition;
17use azul_core::refany::{OptionRefAny, RefAny};
18use azul_core::resources::{ImageRef, RawImage, RawImageData, RawImageFormat};
19use azul_core::task::{ThreadId, ThreadReceiver, ThreadSendMsg};
20use azul_core::video::{VideoConfig, VideoFrame};
21
22use super::capture_common::{
23    invoke_on_frame, OnVideoFrame, OnVideoFrameCallback, OptionOnVideoFrame,
24};
25use crate::callbacks::{Callback, CallbackInfo, CallbackType};
26use crate::thread::{
27    Thread, ThreadCallback, ThreadReceiveMsg, ThreadSender, ThreadWriteBackMsg, WriteBackCallback,
28};
29
30/// Default decode size for the test pattern (the real decoder reports the
31/// stream's actual size).
32const DEFAULT_W: u32 = 1280;
33const DEFAULT_H: u32 = 720;
34
35/// Live state for one video widget, carried across relayout by
36/// [`merge_video_state`].
37#[derive(Debug)]
38pub struct VideoWidgetState {
39    /// The requested playback configuration (source + autoplay/loop).
40    pub config: VideoConfig,
41    /// `true` once the decode thread has been started.
42    pub started: bool,
43    /// The stable external GL texture id once installed.
44    pub gl_texture_id: Option<u32>,
45    /// Optional user hook invoked with each decoded frame (effects / save /
46    /// send). Re-set on every fresh build (see [`merge_video_state`]).
47    pub on_frame: OptionOnVideoFrame,
48    /// Optional pre-decoded frames to replay (a `RefAny` holding a
49    /// `Vec<VideoFrame>`); when set, the replay worker cycles these instead of
50    /// the built-in test pattern. Carried forward by [`merge_video_state`].
51    pub frames: OptionRefAny,
52    /// The off-main-thread streaming decode worker (mirrors the map widget's
53    /// `fetch_callback`). Set via [`VideoWidget::dom_with_decoder`]. When present,
54    /// `AfterMount` spawns it on a background `Thread` instead of the replay /
55    /// test-pattern workers, so the VK decode runs off the main thread.
56    pub decode_callback: Option<ThreadCallback>,
57    /// The latest decoded frame to display, as a CPU `ImageRef` (RGBA8). The
58    /// `VirtualView` render callback ([`video_widget_render`]) reads this on each
59    /// re-render; [`video_writeback`] stores it and triggers an in-place
60    /// `VirtualView` re-render - so the frame renders on cpurender AND webrender,
61    /// exactly like the map widget's tile cache. (Replaces the GL `present_frame`
62    /// path for video; camera/screencap still use `present_frame`.)
63    pub current_frame: Option<ImageRef>,
64    /// The decode worker's `ThreadId` (set by `AfterMount`). Lets the resize callback
65    /// message the running worker (`info.get_thread(id).sender.send(..)`) so it can
66    /// re-target the decoder to the new physical-pixel size - a cheap image swap, no
67    /// relayout. Carried across relayout by [`merge_video_state`].
68    pub thread_id: Option<ThreadId>,
69    /// Clone of the worker's main→worker `Sender` (set by `AfterMount`, carried by
70    /// merge). Lets [`merge_video_state`] - which has no `CallbackInfo` - push a
71    /// seek to the running worker when `config.timestamp` changes (scrubbing).
72    pub seek_sender: Option<std::sync::mpsc::Sender<ThreadSendMsg>>,
73}
74
75/// A video-playback widget. `create(config).dom()` yields an `<img>` the
76/// decode thread keeps fed.
77#[repr(C)]
78#[derive(Debug)]
79pub struct VideoWidget {
80    /// Source URL + autoplay/loop + format.
81    pub config: VideoConfig,
82    /// Optional per-frame user hook (effects / save / send - azul-meet).
83    pub on_frame: OptionOnVideoFrame,
84    /// Optional pre-decoded frames to replay (a `RefAny` holding a
85    /// `Vec<VideoFrame>`); set via [`with_frames`](Self::with_frames). When
86    /// present the widget cycles these instead of the test pattern.
87    pub frames: OptionRefAny,
88}
89
90impl VideoWidget {
91    /// Create a video widget for the given config.
92    #[must_use] pub const fn create(config: VideoConfig) -> Self {
93        Self {
94            config,
95            on_frame: OptionOnVideoFrame::None,
96            frames: OptionRefAny::None,
97        }
98    }
99
100    /// Set a hook invoked with every decoded frame - for live effects, saving
101    /// frames into your data model, or sending them over the network
102    /// (azul-meet). The backreference DI pattern (see `architecture.md`).
103    pub fn set_on_frame<C: Into<OnVideoFrameCallback>>(&mut self, data: RefAny, on_frame: C) {
104        self.on_frame = Some(OnVideoFrame {
105            refany: data,
106            callback: on_frame.into(),
107        })
108        .into();
109    }
110
111    /// Builder form of [`set_on_frame`](Self::set_on_frame).
112    #[must_use]
113    pub fn with_on_frame<C: Into<OnVideoFrameCallback>>(
114        mut self,
115        data: RefAny,
116        on_frame: C,
117    ) -> Self {
118        self.set_on_frame(data, on_frame);
119        self
120    }
121
122    /// Replay a list of already-decoded frames instead of the built-in test
123    /// pattern: `frames` is a [`RefAny`] holding a `Vec<VideoFrame>`. The
124    /// background worker cycles them through the shared GL presenter (the same
125    /// `present_frame` path the camera/screencap widgets use), so callers that
126    /// decode a clip up front (e.g. `decode_mp4_h264_bytes`) get real pixels on
127    /// screen. The `RefAny` must carry a `Vec<VideoFrame>`, else playback is
128    /// skipped and the test pattern shows instead.
129    #[must_use] pub fn with_frames(mut self, frames: RefAny) -> Self {
130        self.frames = Some(frames).into();
131        self
132    }
133
134    fn build_dom(self, decode_cb: Option<ThreadCallback>) -> Dom {
135        let state = VideoWidgetState {
136            config: self.config,
137            started: false,
138            gl_texture_id: None,
139            on_frame: self.on_frame,
140            frames: self.frames,
141            decode_callback: decode_cb,
142            current_frame: None,
143            thread_id: None,
144            seek_sender: None,
145        };
146        let dataset = RefAny::new(state);
147        let vv_data = dataset.clone();
148
149        // The body is a VirtualView (exactly like the map widget): its render
150        // callback re-reads `current_frame` from the dataset each re-render and
151        // builds the `<img>`, so streamed frames render on BOTH cpurender and
152        // webrender. The background decode worker is started on AfterMount and
153        // `WriteBack`s frames into `current_frame` + triggers a VirtualView
154        // re-render in place (no DOM rebuild) — see `video_writeback`. The caller
155        // sizes the outer node via `.with_css(...)` on the returned Dom.
156        Dom::create_div()
157            .with_dataset(OptionRefAny::Some(dataset.clone()))
158            .with_merge_callback(azul_core::dom::DatasetMergeCallback::from_ptr(merge_video_state))
159            .with_callback(
160                EventFilter::Component(ComponentEventFilter::AfterMount),
161                dataset.clone(),
162                Callback::from_ptr(video_on_after_mount),
163            )
164            // Window/layout resize → re-target the decoder to the new physical size
165            // (a cheap image swap, no relayout). See `video_on_resize`.
166            .with_callback(
167                EventFilter::Component(ComponentEventFilter::NodeResized),
168                dataset,
169                Callback::from_ptr(video_on_resize),
170            )
171            .with_child(
172                Dom::create_virtual_view(
173                    vv_data,
174                    azul_core::callbacks::VirtualViewCallback::create(video_widget_render),
175                )
176                .with_css("width: 100%; height: 100%; overflow: hidden;"),
177            )
178    }
179
180    /// Build the widget's DOM: a single `<img>` node a background thread keeps
181    /// fed. Replays pre-decoded [`with_frames`](Self::with_frames) if given, else
182    /// shows the built-in test pattern.
183    #[must_use] pub fn dom(self) -> Dom {
184        self.build_dom(None)
185    }
186
187    /// Build the widget's DOM and wire a background **streaming** decode worker -
188    /// mirrors `MapWidget::dom_with_fetch`. `cb` runs on a framework `Thread` OFF
189    /// the main thread: it reads the `VideoConfig` (its typed `VideoSource` -
190    /// URL / file / bytes), runs the VK decode incrementally (no up-front decode),
191    /// and `WriteBack`s frames to the `<img>` paced by wall-clock (dropping late
192    /// frames). The standard worker is
193    /// `azul_dll::desktop::extra::video_codec::stream::video_decode_worker`; wrap
194    /// it in a `ThreadCallback` to pass it here.
195    #[must_use] pub fn dom_with_decoder(self, cb: ThreadCallback) -> Dom {
196        self.build_dom(Some(cb))
197    }
198}
199
200/// `VirtualView` render callback (mirrors `map_widget_render`): build the `<img>`
201/// for the latest decoded frame, re-read from the widget's dataset on every
202/// re-render. The decode worker stores frames into `current_frame` and triggers
203/// the re-render in place (see [`video_writeback`]), so this renders on both the
204/// CPU and GPU renderers with no DOM rebuild.
205extern "C" fn video_widget_render(
206    mut data: RefAny,
207    info: VirtualViewCallbackInfo,
208) -> VirtualViewReturn {
209    let bounds = info.get_bounds().get_logical_size();
210    if std::env::var("AZ_VIDEO_FRAMELOG").is_ok() {
211        eprintln!("[vrender] bounds {}x{}", bounds.width, bounds.height);
212    }
213    // Defensive (like map_widget_render): a non-finite / non-positive box (layout
214    // not yet settled, e.g. flex-grow before the parent height resolves) would
215    // produce a garbage `<img>` size — render nothing until it settles.
216    let dom = if !bounds.width.is_finite()
217        || !bounds.height.is_finite()
218        || bounds.width <= 0.0
219        || bounds.height <= 0.0
220    {
221        OptionDom::None
222    } else {
223        data.downcast_ref::<VideoWidgetState>().map_or(OptionDom::None, |s| {
224            s.current_frame.as_ref().map_or(OptionDom::None, |img| {
225                OptionDom::Some(
226                    Dom::create_image(img.clone()).with_css("width: 100%; height: 100%;"),
227                )
228            })
229        })
230    };
231    VirtualViewReturn {
232        dom,
233        scroll_size: bounds,
234        scroll_offset: LogicalPosition::zero(),
235        virtual_scroll_size: bounds,
236        virtual_scroll_offset: LogicalPosition::zero(),
237    }
238}
239
240/// `AfterMount`: start the background decode thread exactly once.
241extern "C" fn video_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
242    // Mark started exactly once; pull out the streaming decode worker (if any),
243    // its source, and any pre-decoded replay frames.
244    let (decode_cb, config, frames) = {
245        let Some(mut s) = data.downcast_mut::<VideoWidgetState>() else {
246            return Update::DoNothing;
247        };
248        if s.started {
249            return Update::DoNothing;
250        }
251        s.started = true;
252        let frames = match &s.frames {
253            OptionRefAny::Some(f) => Some(f.clone()),
254            OptionRefAny::None => None,
255        };
256        (s.decode_callback.clone(), s.config.clone(), frames)
257    };
258    // Priority: off-main streaming decode worker > replay pre-decoded frames >
259    // built-in test pattern. All feed the same WriteBack -> video_writeback path.
260    if let Some(cb) = decode_cb {
261        // The worker's thread-init is the `VideoConfig` itself: it matches on
262        // `config.source` (typed — no RefAny downcast) and reads `config.timestamp`.
263        let init = RefAny::new(config);
264        let tid = ThreadId::unique();
265        let thread = Thread::create(init, data.clone(), cb);
266        // Grab the main→worker sender BEFORE add_thread moves the Thread, so the
267        // merge callback can push seeks to the worker (scrubbing).
268        let seek_sender = thread.clone_sender();
269        info.add_thread(tid, thread);
270        // Remember the worker's id (resize messaging) + sender (seek messaging).
271        if let Some(mut s) = data.downcast_mut::<VideoWidgetState>() {
272            s.thread_id = Some(tid);
273            s.seek_sender = seek_sender;
274        }
275    } else if let Some(frames) = frames {
276        info.add_thread(
277            ThreadId::unique(),
278            Thread::create(frames, data.clone(), ThreadCallback::new(video_replay_worker)),
279        );
280    } else {
281        info.add_thread(
282            ThreadId::unique(),
283            Thread::create(
284                RefAny::new(()),
285                data.clone(),
286                ThreadCallback::new(video_test_worker),
287            ),
288        );
289    }
290    Update::DoNothing
291}
292
293/// `NodeResized`: the video box changed physical size (window resize / relayout). Tell
294/// the running decode worker the new target size via its `ThreadSender` so it scales
295/// frames to fit OFF the main thread - the UI then does a cheap image swap with no
296/// interpolation. This is a message, NOT a relayout: returns `DoNothing`.
297#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
298extern "C" fn video_on_resize(mut data: RefAny, mut info: CallbackInfo) -> Update {
299    let tid = match data.downcast_ref::<VideoWidgetState>() {
300        Some(s) => s.thread_id,
301        None => return Update::DoNothing,
302    };
303    let Some(tid) = tid else {
304        return Update::DoNothing;
305    };
306    let node = info.get_hit_node();
307    let Some(size) = info.get_node_size(node) else {
308        return Update::DoNothing;
309    };
310    let target = (size.width.max(1.0) as u32, size.height.max(1.0) as u32);
311    if let Some(thread) = info.get_thread(&tid) {
312        // Best-effort resize notification: if the decode worker has already
313        // exited, the send fails and there is nothing to do here.
314        let _ = thread.send_message(ThreadSendMsg::Custom(RefAny::new(target)));
315    }
316    Update::DoNothing
317}
318
319/// Background worker (test pattern): SMPTE-style colour bars scrolling
320/// horizontally ~30x/s. Replaced by the real vk-video decode worker later.
321#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
322extern "C" fn video_test_worker(_init: RefAny, mut sender: ThreadSender, _recv: ThreadReceiver) {
323    const BARS: [[u8; 3]; 7] = [
324        [235, 235, 235],
325        [235, 235, 16],
326        [16, 235, 235],
327        [16, 235, 16],
328        [235, 16, 235],
329        [235, 16, 16],
330        [16, 16, 235],
331    ];
332    let (w, h) = (DEFAULT_W as usize, DEFAULT_H as usize);
333    let mut tick: u32 = 0;
334    loop {
335        let shift = (tick as usize / 4) % 7;
336        let mut bytes = Vec::with_capacity(w * h * 4);
337        for _y in 0..h {
338            for x in 0..w {
339                let c = BARS[((x * 7 / w) + shift) % 7];
340                bytes.extend_from_slice(&[c[0], c[1], c[2], 255]);
341            }
342        }
343        let frame = VideoFrame {
344            width: w as u32,
345            height: h as u32,
346            bytes: bytes.into(),
347        };
348        let sent = sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
349            WriteBackCallback::new(video_writeback),
350            RefAny::new(frame),
351        )));
352        if !sent {
353            break;
354        }
355        std::thread::sleep(std::time::Duration::from_millis(33));
356        tick = tick.wrapping_add(2);
357    }
358}
359
360/// Background worker (replay): cycle a caller-supplied `Vec<VideoFrame>` (e.g. a
361/// clip decoded up front via `decode_mp4_h264_bytes`) ~30x/s through the same
362/// `WriteBack` -> [`video_writeback`] -> [`super::capture_common::present_frame`]
363/// path as the test pattern, so real decoded pixels land in the shared GL
364/// texture. `init` is the `RefAny` handed to
365/// [`VideoWidget::with_frames`](VideoWidget::with_frames); if it doesn't hold a
366/// non-empty `Vec<VideoFrame>` the worker just returns.
367extern "C" fn video_replay_worker(mut init: RefAny, mut sender: ThreadSender, _recv: ThreadReceiver) {
368    let frames: Vec<VideoFrame> = match init.downcast_ref::<Vec<VideoFrame>>() {
369        Some(f) => f.clone(),
370        None => return,
371    };
372    if frames.is_empty() {
373        return;
374    }
375    let mut idx: usize = 0;
376    loop {
377        let frame = frames[idx % frames.len()].clone();
378        let sent = sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
379            WriteBackCallback::new(video_writeback),
380            RefAny::new(frame),
381        )));
382        if !sent {
383            break;
384        }
385        std::thread::sleep(std::time::Duration::from_millis(33));
386        idx = idx.wrapping_add(1);
387    }
388}
389
390/// Writeback (main thread): store the decoded frame as the widget's
391/// `current_frame` (a CPU `ImageRef`) and re-render the `VirtualView` in place so it
392/// re-reads it - exactly like `map_tile_writeback`.
393///
394/// Renders on cpurender AND
395/// webrender (no GL `present_frame`, no DOM rebuild).
396#[must_use] pub extern "C" fn video_writeback(
397    mut writeback_data: RefAny,
398    mut frame_data: RefAny,
399    mut info: CallbackInfo,
400) -> Update {
401    let hook = writeback_data.downcast_ref::<VideoWidgetState>().map_or_else(|| OptionOnVideoFrame::None, |s| s.on_frame.clone());
402    let mut user_update = Update::DoNothing;
403    match frame_data.downcast_ref::<VideoFrame>() {
404        Some(frame) => {
405            if let Some(img) = ImageRef::new_rawimage(RawImage {
406                pixels: RawImageData::U8(frame.bytes.clone()),
407                width: frame.width as usize,
408                height: frame.height as usize,
409                premultiplied_alpha: false,
410                data_format: RawImageFormat::RGBA8,
411                tag: b"azul-video-frame".to_vec().into(),
412            }) {
413                if let Some(mut s) = writeback_data.downcast_mut::<VideoWidgetState>() {
414                    s.current_frame = Some(img);
415                }
416            }
417            user_update = invoke_on_frame(&hook, &mut info, &frame);
418        }
419        None => return Update::DoNothing,
420    }
421    // Re-render the VirtualView(s) in place so the content callback re-reads the
422    // freshly-stored `current_frame` (NOT RefreshDom — that would rebuild the DOM
423    // and orphan the worker's dataset clone). Same trick as `map_tile_writeback`.
424    info.trigger_all_virtual_view_rerender();
425    user_update
426}
427
428/// Carry live state forward across relayout.
429#[allow(clippy::float_cmp)] // intentional exact compare: change-detection / identity fast-path / cache-key match
430extern "C" fn merge_video_state(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
431    {
432        let new_guard = new_data.downcast_mut::<VideoWidgetState>();
433        let old_guard = old_data.downcast_ref::<VideoWidgetState>();
434        if let (Some(mut new_g), Some(old_g)) = (new_guard, old_guard) {
435            new_g.started = old_g.started;
436            new_g.gl_texture_id = old_g.gl_texture_id;
437            new_g.frames = old_g.frames.clone();
438            new_g.decode_callback.clone_from(&old_g.decode_callback);
439            new_g.current_frame.clone_from(&old_g.current_frame);
440            new_g.thread_id = old_g.thread_id;
441            new_g.seek_sender.clone_from(&old_g.seek_sender);
442            // Scrubbing: a changed `config.timestamp` across this relayout → tell the
443            // worker to seek. Cheap wall-clock reposition (the worker already has the
444            // decoded frames), result comes back as an image swap — no re-decode here.
445            if old_g.config.timestamp != new_g.config.timestamp {
446                if let Some(snd) = new_g.seek_sender.as_ref() {
447                    drop(snd.send(ThreadSendMsg::Custom(RefAny::new(new_g.config.timestamp))));
448                }
449            }
450            // Input-source change → tell the worker to re-init the decode (it
451            // re-resolves/demuxes/decodes the new source); the frame swaps in when ready.
452            if old_g.config.source != new_g.config.source {
453                if let Some(snd) = new_g.seek_sender.as_ref() {
454                    drop(snd.send(ThreadSendMsg::Custom(RefAny::new(new_g.config.source.clone()))));
455                }
456            }
457        }
458    }
459    new_data
460}