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}