瀏覽代碼

app: migrate to a proper video loader (slow)

darkfi 7 月之前
父節點
當前提交
f897e8f36c

+ 1 - 5
bin/app/src/app/node.rs

@@ -175,11 +175,7 @@ pub fn create_video(name: &str) -> SceneNode {
     node.add_property(prop).unwrap();
 
     let mut prop = Property::new("path", PropertyType::Str, PropertySubType::Null);
-    prop.set_ui_text("Path", "Path format string using {frame} in the name");
-    node.add_property(prop).unwrap();
-
-    let mut prop = Property::new("length", PropertyType::Uint32, PropertySubType::Null);
-    prop.set_ui_text("Frame Length", "Total frames to load (last frame + 1)");
+    prop.set_ui_text("Path", "Path to .ivf video file (AV1 format)");
     node.add_property(prop).unwrap();
 
     node

+ 2 - 2
bin/app/src/app/schema/mod.rs

@@ -127,7 +127,8 @@ mod ui_consts {
 mod desktop_paths {
     use std::path::PathBuf;
 
-    pub const VID_PATH: &str = "assets/forest_1920x1080/{frame}.qoi";
+    //pub const VID_PATH: &str = "assets/forest_1920x1080.ivf";
+    pub const VID_PATH: &str = "assets/forest2/forest_1920x1080.ivf.{frame}";
     pub const VID_ASPECT_RATIO: f32 = 16. / 9.;
 
     pub fn get_chatdb_path() -> PathBuf {
@@ -341,7 +342,6 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
         node.set_property_u32(atom, Role::App, "z_index", 0).unwrap();
         //let node = node.setup(|me| Image::new(me, app.render_api.clone())).await;
         //layer_node.link(node);
-        node.set_property_u32(atom, Role::App, "length", 150).unwrap();
         let node = node.setup(|me| Video::new(me, app.render_api.clone(), app.ex.clone())).await;
         layer_node.link(node);
     } else if COLOR_SCHEME == ColorScheme::PaperLight {

+ 1 - 2
bin/app/src/app/schema/test.rs

@@ -44,7 +44,7 @@ mod ui_consts {
 mod ui_consts {
     //pub const CHATDB_PATH: &str = "chatdb";
     //pub const KING_PATH: &str = "assets/king.png";
-    pub const VID_PATH: &str = "assets/forest/forest_{frame}.png";
+    pub const VID_PATH: &str = "assets/forest2/forest_1920x1080.ivf.{frame}";
 }
 
 use ui_consts::*;
@@ -234,7 +234,6 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
     prop.set_f32(atom, Role::App, 3, 600.).unwrap();
     node.set_property_str(atom, Role::App, "path", VID_PATH).unwrap();
     node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
-    node.set_property_u32(atom, Role::App, "length", 357).unwrap();
     let node = node.setup(|me| Video::new(me, app.render_api.clone(), app.ex.clone())).await;
     layer_node.link(node);
 

+ 2 - 2
bin/app/src/ui/mod.rs

@@ -42,8 +42,8 @@ pub use gesture::GesturePtr;
 mod image;
 #[allow(unused_imports)]
 pub use image::{Image, ImagePtr};
-mod video;
-pub use video::{Video, VideoPtr};
+mod vid;
+pub use vid::{Video, VideoPtr};
 mod vector_art;
 pub use vector_art::{
     shape::{ShapeVertex, VectorShape},

+ 0 - 396
bin/app/src/ui/video.rs

@@ -1,396 +0,0 @@
-/* This file is part of DarkFi (https://dark.fi)
- *
- * Copyright (C) 2020-2025 Dyne.org foundation
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-use async_trait::async_trait;
-use parking_lot::Mutex as SyncMutex;
-use rand::{rngs::OsRng, Rng};
-use std::sync::{
-    atomic::{AtomicBool, Ordering},
-    Arc,
-};
-use tracing::instrument;
-
-use crate::{
-    gfx::{
-        anim::Frame, gfxtag, DrawCall, DrawInstruction, DrawMesh, ManagedSeqAnimPtr,
-        ManagedTexturePtr, Rectangle, RenderApi,
-    },
-    mesh::{MeshBuilder, MeshInfo, COLOR_WHITE},
-    prop::{BatchGuardPtr, PropertyAtomicGuard, PropertyRect, PropertyStr, PropertyUint32, Role},
-    scene::{Pimpl, SceneNodeWeak},
-    util::spawn_thread,
-    ExecutorPtr,
-};
-
-use super::{DrawUpdate, OnModify, UIObject};
-
-pub const N_LOADERS: usize = 6;
-
-macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui:video", $($arg)*); } }
-macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui:video", $($arg)*); } }
-
-pub type VideoPtr = Arc<Video>;
-
-#[derive(Clone)]
-struct StreamedVideoData {
-    textures: Vec<Option<ManagedTexturePtr>>,
-    anim: ManagedSeqAnimPtr,
-
-    textures_pub: async_broadcast::Sender<(usize, ManagedTexturePtr)>,
-    textures_sub: async_broadcast::Receiver<(usize, ManagedTexturePtr)>,
-}
-
-impl StreamedVideoData {
-    fn new(len: usize, render_api: &RenderApi) -> Self {
-        let (textures_pub, textures_sub) = async_broadcast::broadcast(len);
-
-        let anim = render_api.new_anim(len, false, gfxtag!("video"));
-        Self { textures: vec![None; len], anim, textures_pub, textures_sub }
-    }
-}
-
-pub struct Video {
-    node: SceneNodeWeak,
-    render_api: RenderApi,
-    tasks: SyncMutex<Vec<smol::Task<()>>>,
-    load_tasks: SyncMutex<Vec<smol::Task<()>>>,
-    ex: ExecutorPtr,
-    stop_load: Arc<AtomicBool>,
-    dc_key: u64,
-
-    vid_data: Arc<SyncMutex<Option<StreamedVideoData>>>,
-    // Do we need this?
-    _load_handles: SyncMutex<[Option<std::thread::JoinHandle<()>>; N_LOADERS]>,
-
-    rect: PropertyRect,
-    uv: PropertyRect,
-    z_index: PropertyUint32,
-    priority: PropertyUint32,
-    path: PropertyStr,
-    vid_len: PropertyUint32,
-
-    parent_rect: SyncMutex<Option<Rectangle>>,
-}
-
-impl Video {
-    pub async fn new(node: SceneNodeWeak, render_api: RenderApi, ex: ExecutorPtr) -> Pimpl {
-        t!("Video::new()");
-
-        let node_ref = &node.upgrade().unwrap();
-        let rect = PropertyRect::wrap(node_ref, Role::Internal, "rect").unwrap();
-        let uv = PropertyRect::wrap(node_ref, Role::Internal, "uv").unwrap();
-        let z_index = PropertyUint32::wrap(node_ref, Role::Internal, "z_index", 0).unwrap();
-        let priority = PropertyUint32::wrap(node_ref, Role::Internal, "priority", 0).unwrap();
-        let path = PropertyStr::wrap(node_ref, Role::Internal, "path", 0).unwrap();
-        let vid_len = PropertyUint32::wrap(node_ref, Role::Internal, "length", 0).unwrap();
-
-        let self_ = Arc::new(Self {
-            node,
-            render_api,
-            tasks: SyncMutex::new(vec![]),
-            load_tasks: SyncMutex::new(vec![]),
-            ex,
-            stop_load: Arc::new(AtomicBool::new(false)),
-            dc_key: OsRng.gen(),
-
-            vid_data: Arc::new(SyncMutex::new(None)),
-            _load_handles: SyncMutex::new([const { None }; N_LOADERS]),
-
-            rect,
-            uv,
-            z_index,
-            priority,
-            path,
-            vid_len,
-
-            parent_rect: SyncMutex::new(None),
-        });
-
-        Pimpl::Video(self_)
-    }
-
-    async fn reload(self: Arc<Self>, batch: BatchGuardPtr) {
-        self.load_textures();
-        self.clone().redraw(batch).await;
-    }
-
-    fn load_textures(&self) {
-        let vid_len = self.vid_len.get() as usize;
-        let path_fmt = self.path.get();
-
-        // Starts N threads
-        // batch idxs across threads
-        //    0    1    2    3
-        //    4    5    6    7
-        //           ...
-        // load_texture:
-        //    read image
-        //    create texture
-        //    set texture slot
-        //    broadcast (idx, texture)
-        //
-        // draw_call:
-        //    create broadcast sub
-        //    load as many slots from mutex, then release
-        //    start task:
-        //        loop:
-        //            listen to broadcast
-        //            create draw_call
-        //            send to gfx
-
-        let textures_pub = {
-            let mut vid_data = self.vid_data.lock();
-            let svidat = StreamedVideoData::new(vid_len, &self.render_api);
-            let textures_pub = svidat.textures_pub.clone();
-            *vid_data = Some(svidat);
-            textures_pub
-        };
-
-        let mut handles = [const { None }; N_LOADERS];
-        for thread_idx in 0..N_LOADERS {
-            let path_fmt = path_fmt.clone();
-            let render_api = self.render_api.clone();
-            let stop_load = self.stop_load.clone();
-            let vid_data = self.vid_data.clone();
-            let textures_pub = textures_pub.clone();
-
-            let name = format!("video-load-{}", thread_idx);
-            let handle = spawn_thread(name, move || {
-                let now = std::time::Instant::now();
-                let mut frame_idx = thread_idx;
-                while frame_idx < vid_len {
-                    // Stop loading instantly
-                    if stop_load.load(Ordering::Relaxed) {
-                        return
-                    }
-                    let path = path_fmt.replace("{frame}", &format!("{frame_idx:#03}"));
-                    let texture = Self::load_texture(path, &render_api);
-                    // Make editing textures array and broadcasting an atomic op
-                    {
-                        let mut vid_data = vid_data.lock();
-                        // vid_data becomes None if the stop() is called. In which case
-                        // we just stop loading and return from this thread.
-                        let Some(vid_data) = vid_data.as_mut() else { return };
-                        vid_data.textures[frame_idx] = Some(texture.clone());
-                        // broadcast
-                        textures_pub.try_broadcast((frame_idx, texture)).unwrap();
-                    }
-
-                    frame_idx += N_LOADERS;
-                }
-                d!("thread {thread_idx} finished took {:?}", now.elapsed());
-            });
-            handles[thread_idx] = Some(handle);
-        }
-        *self._load_handles.lock() = handles;
-    }
-
-    fn load_texture(path: String, render_api: &RenderApi) -> ManagedTexturePtr {
-        // TODO we should NOT use panic here
-        let data = Arc::new(SyncMutex::new(vec![]));
-        let data2 = data.clone();
-        miniquad::fs::load_file(&path.clone(), move |res| match res {
-            Ok(res) => *data2.lock() = res,
-            Err(e) => {
-                error!(target: "ui::video", "Unable to open video: {path}: {e}");
-                panic!("Resource not found! {e}");
-            }
-        });
-        let (header, bmp) = qoi::decode_to_vec(&*data.lock()).unwrap();
-        let width = header.width as u16;
-        let height = header.height as u16;
-
-        render_api.new_texture(width, height, bmp, gfxtag!("img"))
-    }
-
-    #[instrument(target = "ui::video")]
-    async fn redraw(self: Arc<Self>, batch: BatchGuardPtr) {
-        let Some(parent_rect) = self.parent_rect.lock().clone() else { return };
-
-        let atom = &mut batch.spawn();
-        let Some(draw_update) = self.get_draw_calls(atom, parent_rect).await else {
-            error!(target: "ui:video", "Video failed to draw");
-            return
-        };
-        self.render_api.replace_draw_calls(batch.id, draw_update.draw_calls);
-    }
-
-    /// Called whenever any property changes.
-    fn regen_mesh(&self) -> MeshInfo {
-        let rect = self.rect.get();
-        let uv = self.uv.get();
-        let mesh_rect = Rectangle::from([0., 0., rect.w, rect.h]);
-        let mut mesh = MeshBuilder::new(gfxtag!("img"));
-        mesh.draw_box(&mesh_rect, COLOR_WHITE, &uv);
-        mesh.alloc(&self.render_api)
-    }
-
-    async fn get_draw_calls(
-        &self,
-        atom: &mut PropertyAtomicGuard,
-        parent_rect: Rectangle,
-    ) -> Option<DrawUpdate> {
-        self.rect.eval(atom, &parent_rect).ok()?;
-        let rect = self.rect.get();
-        self.uv.eval(atom, &rect).ok()?;
-
-        let mesh = self.regen_mesh();
-        // Begin subscribing before we clone Mutex, but actually
-        // we need the length of the textures stored, so lets just hold it,
-        // do the clones THEN release.
-        let (vid_data, tsubs) = {
-            let vid_data = self.vid_data.lock();
-            let vid_data = vid_data.clone().unwrap();
-            // Possibly triggered by race condition.
-            // Check it anyway since generally should work.
-            assert_eq!(vid_data.textures.len(), self.vid_len.get() as usize);
-            let tsubs = vec![vid_data.textures_sub.clone(); vid_data.textures.len()];
-            (vid_data, tsubs)
-        };
-        assert_eq!(vid_data.textures.len(), tsubs.len());
-
-        // Only used in this function so fine to hold the entire time
-        let mut load_tasks = self.load_tasks.lock();
-        load_tasks.clear();
-
-        let mut loaded_n_frames = 0;
-        let total_frames = vid_data.textures.len();
-
-        for (texture_idx, (mut texture, mut tsub)) in
-            vid_data.textures.into_iter().zip(tsubs.into_iter()).enumerate()
-        {
-            let vertex_buffer = mesh.vertex_buffer.clone();
-            let index_buffer = mesh.index_buffer.clone();
-
-            let Some(texture) = texture.take() else {
-                let anim = vid_data.anim.clone();
-                let task = self.ex.spawn(async move {
-                    while let Ok((frame_idx, texture)) = tsub.recv().await {
-                        if frame_idx != texture_idx {
-                            continue
-                        }
-
-                        let mesh = DrawMesh {
-                            vertex_buffer,
-                            index_buffer,
-                            texture: Some(texture),
-                            num_elements: mesh.num_elements,
-                        };
-                        let dc = DrawCall {
-                            instrs: vec![DrawInstruction::Draw(mesh)],
-                            dcs: vec![],
-                            z_index: 0,
-                            debug_str: "video",
-                        };
-
-                        //t!("sending {frame_idx}");
-                        anim.update(frame_idx, Frame::new(40, dc));
-                        break
-                    }
-                });
-                load_tasks.push(task);
-
-                continue
-            };
-            let mesh = DrawMesh {
-                vertex_buffer,
-                index_buffer,
-                texture: Some(texture),
-                num_elements: mesh.num_elements,
-            };
-            let dc = DrawCall {
-                instrs: vec![DrawInstruction::Draw(mesh)],
-                dcs: vec![],
-                z_index: 0,
-                debug_str: "video",
-            };
-            vid_data.anim.update(texture_idx, Frame::new(40, dc));
-            loaded_n_frames += 1;
-        }
-
-        debug!(target: "ui::video", "Loaded {loaded_n_frames} / {total_frames} frames");
-
-        Some(DrawUpdate {
-            key: self.dc_key,
-            draw_calls: vec![(
-                self.dc_key,
-                DrawCall::new(
-                    vec![
-                        DrawInstruction::Move(rect.pos()),
-                        DrawInstruction::Animation(vid_data.anim.id),
-                    ],
-                    vec![],
-                    self.z_index.get(),
-                    "vid",
-                ),
-            )],
-        })
-    }
-}
-
-#[async_trait]
-impl UIObject for Video {
-    fn priority(&self) -> u32 {
-        self.priority.get()
-    }
-
-    fn init(&self) {
-        self.load_textures();
-    }
-
-    async fn start(self: Arc<Self>, ex: ExecutorPtr) {
-        let me = Arc::downgrade(&self);
-
-        let mut on_modify = OnModify::new(ex, self.node.clone(), me.clone());
-        on_modify.when_change(self.rect.prop(), Self::redraw);
-        on_modify.when_change(self.uv.prop(), Self::redraw);
-        on_modify.when_change(self.z_index.prop(), Self::redraw);
-        on_modify.when_change(self.path.prop(), Self::reload);
-
-        *self.tasks.lock() = on_modify.tasks;
-    }
-
-    fn stop(&self) {
-        self.tasks.lock().clear();
-        *self.parent_rect.lock() = None;
-        *self.vid_data.lock() = None;
-    }
-
-    #[instrument(target = "ui::video")]
-    async fn draw(
-        &self,
-        parent_rect: Rectangle,
-        atom: &mut PropertyAtomicGuard,
-    ) -> Option<DrawUpdate> {
-        *self.parent_rect.lock() = Some(parent_rect);
-        self.get_draw_calls(atom, parent_rect).await
-    }
-}
-
-impl Drop for Video {
-    fn drop(&mut self) {
-        let atom = self.render_api.make_guard(gfxtag!("Video::drop"));
-        self.render_api.replace_draw_calls(atom.batch_id, vec![(self.dc_key, Default::default())]);
-    }
-}
-
-impl std::fmt::Debug for Video {
-    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
-        write!(f, "{:?}", self.node.upgrade().unwrap())
-    }
-}

+ 69 - 23
bin/app/src/video/decoder.rs

@@ -22,7 +22,9 @@
 
 use rav1d::{Decoder as Rav1dDecoderInner, Picture, PlanarImageComponent, Rav1dError};
 
-use super::yuv_conv::yuv420p_to_rgba;
+pub type DecoderResult<T> = Result<T, Rav1dError>;
+
+macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui:video", $($arg)*); } }
 
 /// A decoded frame containing RGBA data
 #[derive(Debug, Clone)]
@@ -37,7 +39,7 @@ pub struct DecodedFrame {
 
 /// rav1d AV1 video decoder wrapper
 ///
-/// This wraps the rav1d decoder and provides automatic YUV to RGBA conversion.
+/// This wraps the rav1d decoder and provides automatic planar GBR to RGBA conversion.
 pub struct Rav1dDecoder {
     /// Inner decoder from rav1d
     decoder: Rav1dDecoderInner,
@@ -48,52 +50,96 @@ impl Rav1dDecoder {
         Self { decoder: Rav1dDecoderInner::new().unwrap() }
     }
 
-    /// Decode AV1 bitstream data
-    pub fn decode(&mut self, data: &[u8]) -> Result<DecodedFrame, Rav1dError> {
-        // Send data to decoder
-        // Need to copy data because send_data requires 'static ownership
+    /// Send AV1 bitstream data to the decoder without getting a frame
+    pub fn send_data(&mut self, data: &[u8]) -> DecoderResult<()> {
         let data = data.to_vec();
         match self.decoder.send_data(data, None, None, None) {
             Ok(_) => {}
             Err(Rav1dError::TryAgain) => {
-                // Pending data - try to send it again
                 while let Err(Rav1dError::TryAgain) = self.decoder.send_pending_data() {
                     // Continue sending pending data
                 }
             }
             Err(err) => return Err(err),
         }
+        Ok(())
+    }
 
-        self.get_pic()
+    /// Get the next decoded frame from the decoder
+    pub fn get_pic(&mut self) -> DecoderResult<DecodedFrame> {
+        let now = std::time::Instant::now();
+        let pix = self.decoder.get_picture();
+        t!("decoder get pix: {:?}", now.elapsed());
+
+        let now = std::time::Instant::now();
+        let res = pix.map(|pic| Self::conv(pic));
+        t!("decoder conv: {:?}", now.elapsed());
+        res
     }
 
-    fn get_pic(&mut self) -> Result<DecodedFrame, Rav1dError> {
-        self.decoder.get_picture().map(|pic| Self::conv(pic))
+    /// Decode AV1 bitstream data and get all available frames
+    /// Returns a vector of frames (may be empty if decoder needs more data)
+    pub fn decode(&mut self, data: &[u8]) -> DecoderResult<Vec<DecodedFrame>> {
+        self.send_data(data)?;
+
+        let mut frames = Vec::new();
+        loop {
+            match self.get_pic() {
+                Ok(frame) => frames.push(frame),
+                Err(Rav1dError::TryAgain) => break,
+                Err(e) => return Err(e),
+            }
+        }
+        Ok(frames)
     }
 
-    /// Convert a rav1d Picture to RGBA
+    /// Convert a rav1d Picture from planar GBR to RGBA
     fn conv(pic: Picture) -> DecodedFrame {
-        let y_plane = pic.plane(PlanarImageComponent::Y);
-        let u_plane = pic.plane(PlanarImageComponent::U);
-        let v_plane = pic.plane(PlanarImageComponent::V);
+        let g_plane = pic.plane(PlanarImageComponent::Y);
+        let b_plane = pic.plane(PlanarImageComponent::U);
+        let r_plane = pic.plane(PlanarImageComponent::V);
 
-        let y_stride = pic.stride(PlanarImageComponent::Y) as usize;
-        let u_stride = pic.stride(PlanarImageComponent::U) as usize;
-        let v_stride = pic.stride(PlanarImageComponent::V) as usize;
+        let g_stride = pic.stride(PlanarImageComponent::Y) as usize;
+        let b_stride = pic.stride(PlanarImageComponent::U) as usize;
+        let r_stride = pic.stride(PlanarImageComponent::V) as usize;
 
         let width = pic.width() as usize;
         let height = pic.height() as usize;
 
-        let data = yuv420p_to_rgba(
-            &y_plane, &u_plane, &v_plane, width, height, y_stride, u_stride, v_stride,
-        );
+        let mut rgba = vec![0u8; width * height * 4];
+
+        for y in 0..height {
+            for x in 0..width {
+                let g_idx = y * g_stride + x;
+                let b_idx = y * b_stride + x;
+                let r_idx = y * r_stride + x;
 
-        DecodedFrame { width: width as u32, height: height as u32, data }
+                let r = r_plane[r_idx];
+                let g = g_plane[g_idx];
+                let b = b_plane[b_idx];
+
+                let out_idx = (y * width + x) * 4;
+                rgba[out_idx] = r;
+                rgba[out_idx + 1] = g;
+                rgba[out_idx + 2] = b;
+                rgba[out_idx + 3] = 255;
+            }
+        }
+
+        DecodedFrame { width: width as u32, height: height as u32, data: rgba }
     }
 
     /// Flush the decoder to get any remaining frames
-    pub fn flush(&mut self) -> Result<DecodedFrame, Rav1dError> {
+    pub fn flush(&mut self) -> DecoderResult<Vec<DecodedFrame>> {
         self.decoder.flush();
-        self.get_pic()
+        let mut frames = Vec::new();
+        loop {
+            match self.get_pic() {
+                Ok(frame) => frames.push(frame),
+                Err(Rav1dError::TryAgain) => break,
+                Err(e) => return Err(e),
+            }
+        }
+        Ok(frames)
     }
 }

+ 71 - 49
bin/app/src/video/ivf.rs

@@ -25,9 +25,6 @@ use darkfi_serial::Decodable;
 use std::io::{Cursor, Read};
 use thiserror::Error;
 
-macro_rules! t { ($($arg:tt)*) => { trace!(target: "video::ivf", $($arg)*); } }
-macro_rules! d { ($($arg:tt)*) => { debug!(target: "video::ivf", $($arg)*); } }
-
 /// Errors that can occur during IVF demuxing
 #[derive(Debug, Error)]
 pub enum IvfError {
@@ -64,7 +61,7 @@ pub type IvfResult<T> = Result<T, IvfError>;
 /// unused (u32)      - unused (4 bytes)
 /// ```
 #[derive(Debug, Clone)]
-struct IvfHeader {
+pub struct IvfHeader {
     signature: [u8; 4],
     version: u16,
     header_len: u16,
@@ -73,20 +70,30 @@ struct IvfHeader {
     pub height: u16,
     timebase_den: u32,
     timebase_num: u32,
-    num_frames: u32,
+    pub num_frames: u32,
     unused: u32,
 }
 
-/// IVF demuxer for AV1 video files
-pub struct IvfDemuxer {
+/// Streaming IVF demuxer for chunked video files
+///
+/// This demuxer is designed for videos split into multiple chunks
+/// (e.g., forest_1920x1080.ivf.000, forest_1920x1080.ivf.001, ...).
+/// It handles frames that may span across chunk boundaries.
+pub struct IvfStreamingDemuxer {
+    /// Cursor wrapping the data buffer
     cur: Cursor<Vec<u8>>,
+    /// Parsed IVF header
     pub header: IvfHeader,
+    /// Current frame counter
     current_frame: u32,
 }
 
-impl IvfDemuxer {
-    /// Create a new IVF demuxer from raw bytes
-    pub fn from_bytes(data: Vec<u8>) -> IvfResult<Self> {
+impl IvfStreamingDemuxer {
+    /// Create a new streaming IVF demuxer from the first chunk
+    ///
+    /// The first chunk must contain the 32-byte IVF header followed by
+    /// frame data. Remaining chunks should be fed via `feed_data`.
+    pub fn from_first_chunk(data: Vec<u8>) -> IvfResult<Self> {
         let mut self_ = Self {
             cur: Cursor::new(data),
             header: unsafe { std::mem::zeroed() },
@@ -105,32 +112,10 @@ impl IvfDemuxer {
             return Err(IvfError::UnsupportedCodec(self_.header.codec_fourcc));
         }
 
-        d!(
-            "IVF header: {}x{} frames={}",
-            self_.header.width,
-            self_.header.height,
-            self_.header.num_frames
-        );
-
         Ok(self_)
     }
 
-    /// Parse IVF header from bytes
-    ///
-    /// # IVF Header Structure (32 bytes, little-endian)
-    ///
-    /// | Offset | Size | Field           | Value                      |
-    /// |--------|------|-----------------|----------------------------|
-    /// | 0      | 4    | signature       | "DKIF"                     |
-    /// | 4      | 2    | version         | 0                          |
-    /// | 6      | 2    | header_len      | 32                         |
-    /// | 8      | 4    | codec_fourcc    | "AV01" for AV1             |
-    /// | 12     | 2    | width           | Frame width in pixels      |
-    /// | 14     | 2    | height          | Frame height in pixels     |
-    /// | 16     | 4    | timebase_den    | FPS denominator            |
-    /// | 20     | 4    | timebase_num    | FPS numerator              |
-    /// | 24     | 4    | num_frames      | Total frames               |
-    /// | 28     | 4    | unused          | Reserved                   |
+    /// Parse IVF header from bytes (shared with IvfDemuxer)
     fn parse_header(&mut self) -> Result<(), std::io::Error> {
         // Offset 0-3: Signature "DKIF" (raw bytes)
         let mut signature = [0u8; 4];
@@ -176,27 +161,64 @@ impl IvfDemuxer {
         Ok(())
     }
 
-    /// Get the next frame's AV1 bitstream data
+    /// Feed additional chunk data to the internal buffer
     ///
-    /// # IVF Frame Header Structure (12 bytes, little-endian)
-    ///
-    /// | Offset | Size | Field       | Description                           |
-    /// |--------|------|-------------|---------------------------------------|
-    /// | 0      | 4    | frame_size  | Size of frame data in bytes           |
-    /// | 4      | 8    | timestamp   | Presentation timestamp                |
+    /// After feeding data, call `try_read_frame()` to extract complete frames.
+    pub fn feed_data(&mut self, mut data: Vec<u8>) {
+        let pos = self.cur.position() as usize;
+        let buffer = self.cur.get_mut();
+
+        // Append new data to buffer
+        buffer.append(&mut data);
+
+        // Reset cursor to continue reading
+        self.cur.set_position(pos as u64);
+    }
+
+    /// Try to read the next complete frame
     ///
-    /// The frame data immediately follows the 12-byte header.
-    pub fn next_frame(&mut self) -> Result<Vec<u8>, std::io::Error> {
-        // Offset 0-3: Frame size in bytes
-        let frame_size = u32::decode(&mut self.cur)?;
-        // Offset 4-11: Timestamp (8 bytes) - not used for linear playback
-        let _timestamp = u64::decode(&mut self.cur)?;
+    /// Returns `Ok(Some(frame))` if a complete frame is available,
+    /// `Ok(None)` if more data is needed, or `Err` on invalid data.
+    pub fn try_read_frame(&mut self) -> Option<Vec<u8>> {
+        let current_pos = self.cur.position() as usize;
+
+        // Check if we have enough bytes for frame header (12 bytes)
+        if self.buffer_len() < current_pos + 12 {
+            return None;
+        }
+
+        // Save cursor position in case we need to roll back
+        let saved_pos = self.cur.position();
+
+        // Read frame header
+        let frame_size = u32::decode(&mut self.cur).unwrap();
+        let _timestamp = u64::decode(&mut self.cur).unwrap();
+
+        let frame_end = self.cur.position() as usize + frame_size as usize;
+
+        // Check if we have the complete frame
+        if self.buffer_len() < frame_end {
+            // Incomplete frame, reset cursor
+            self.cur.set_position(saved_pos);
+            return None;
+        }
 
+        // Read the frame data
         let mut frame_data = vec![0u8; frame_size as usize];
-        self.cur.read_exact(&mut frame_data)?;
-        // Read the frame
+        self.cur.read_exact(&mut frame_data).unwrap();
+
         self.current_frame += 1;
-        Ok(frame_data)
+        Some(frame_data)
+    }
+
+    /// Have we read all frames?
+    pub fn is_finished(&self) -> bool {
+        assert!(self.current_frame < self.header.num_frames);
+        self.current_frame == self.header.num_frames - 1
+    }
+
+    fn buffer_len(&mut self) -> usize {
+        self.cur.get_mut().len()
     }
 }
 

+ 2 - 1
bin/app/src/video/mod.rs

@@ -20,5 +20,6 @@ mod decoder;
 mod ivf;
 mod yuv_conv;
 
-pub use ivf::{IvfDemuxer, IvfError, IvfResult};
+pub use decoder::{DecodedFrame, Rav1dDecoder};
+pub use ivf::{IvfHeader, IvfStreamingDemuxer};
 pub use yuv_conv::yuv420p_to_rgba;

+ 0 - 4
bin/app/src/video/yuv_conv.rs

@@ -21,8 +21,6 @@
 //! This module provides functions to convert YUV420P planar format
 //! to RGBA format for GPU rendering.
 
-macro_rules! t { ($($arg:tt)*) => { trace!(target: "video::yuv_conv", $($arg)*); } }
-
 /// Convert YUV420P planar format to RGBA
 ///
 /// # Arguments
@@ -55,8 +53,6 @@ pub fn yuv420p_to_rgba(
     u_stride: usize,
     v_stride: usize,
 ) -> Vec<u8> {
-    //t!("yuv420p_to_rgba() {}x{} strides: y={} u={} v={}", width, height, y_stride, u_stride, v_stride);
-
     let mut rgba = vec![0u8; width * height * 4];
 
     for y in 0..height {