diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index d3cca41..62381bc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -70,6 +70,9 @@ pub struct VideoExportState { /// Zero-copy VAAPI video production: renders each frame to RGBA and hardware-encodes it /// into a VAAPI surface, all on the encoder's own wgpu device (no readback / swscale). +/// VAAPI is Linux-only, so this and its machinery are `cfg`-gated; other platforms always +/// use the software encoder path. +#[cfg(target_os = "linux")] struct ZeroCopyVideo { encoder: gpu_video_encoder::encoder::ZeroCopyEncoder, renderer: vello::Renderer, @@ -833,11 +836,53 @@ impl ExportOrchestrator { } } + /// Spawn the software video-encoder thread and build its UI-driven export state. Used by every + /// non-zero-copy path (non-H.264, VAAPI unavailable, or non-Linux platforms). The caller drives + /// frames into it via `render_next_video_frame()`. + #[allow(clippy::too_many_arguments)] + fn spawn_software_video( + settings: VideoExportSettings, + output_path: PathBuf, + frame_rx: Receiver, + frame_tx: Sender, + progress_tx: Sender, + cancel_flag: Arc, + total_frames: usize, + start_time: f64, + end_time: f64, + framerate: f64, + width: u32, + height: u32, + ) -> (std::thread::JoinHandle<()>, VideoExportState) { + let handle = std::thread::spawn(move || { + Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames); + }); + // GPU resources + readback pipeline init lazily on the first frame (needs device). + let state = VideoExportState { + current_frame: 0, + total_frames, + start_time, + end_time, + framerate, + width, + height, + frame_tx: Some(frame_tx), + gpu_resources: None, + readback_pipeline: None, + cpu_yuv_converter: None, + frames_in_flight: 0, + next_frame_to_encode: 0, + perf_metrics: Some(perf_metrics::ExportMetrics::new()), + }; + (handle, state) + } + /// Build a zero-copy VAAPI H.264 encoder targeting `output_path` (with its own vello /// renderer + GPU resources + RGBA texture on the encoder's device), or `None` when the /// codec isn't H.264 or VAAPI / the GPU device is unavailable — in which case the caller /// falls back to the software encoder path. Used by both the video-only and video+audio - /// export entry points. + /// export entry points. VAAPI is Linux-only, so this whole path is `cfg`-gated. + #[cfg(target_os = "linux")] fn try_build_zero_copy( settings: &VideoExportSettings, width: u32, @@ -938,65 +983,39 @@ impl ExportOrchestrator { self.cancel_flag.store(false, Ordering::Relaxed); let cancel_flag = Arc::clone(&self.cancel_flag); - // Zero-copy VAAPI H.264 writes the final output directly (no audio → no mux), reporting - // through the single-export progress channel. Otherwise use the software encoder thread. - let zero_copy = Self::try_build_zero_copy(&settings, width, height, framerate, &output_path); - - let (handle, video_state) = match zero_copy { - Some(zc) => { + // Zero-copy VAAPI H.264 (Linux only) writes the final output directly on a background + // thread (no audio → no mux), reporting through the single-export progress channel. On + // success it returns here; otherwise we fall through to the software encoder thread. + #[cfg(target_os = "linux")] + { + if let Some(zc) = Self::try_build_zero_copy(&settings, width, height, framerate, &output_path) { drop(frame_rx); let document_snapshot = document.clone(); let mut image_cache = ImageCache::new(); image_cache.set_container_path(container_path); let handle = std::thread::spawn(move || { Self::run_zerocopy_video_export( - zc, - document_snapshot, - image_cache, - video_manager, - raster_store, - total_frames, - start_time, - framerate, - width, - height, - output_path, - progress_tx, - cancel_flag, + zc, document_snapshot, image_cache, video_manager, raster_store, + total_frames, start_time, framerate, width, height, + output_path, progress_tx, cancel_flag, ); }); - (handle, None) - } - None => { - let handle = std::thread::spawn(move || { - Self::run_video_encoder( - settings, - output_path, - frame_rx, - progress_tx, - cancel_flag, - total_frames, - ); - }); - // GPU resources + readback pipeline init lazily on the first frame (needs device). - let state = VideoExportState { - current_frame: 0, - total_frames, - start_time, - end_time, - framerate, - width, - height, - frame_tx: Some(frame_tx), - gpu_resources: None, - readback_pipeline: None, - cpu_yuv_converter: None, - frames_in_flight: 0, - next_frame_to_encode: 0, - perf_metrics: Some(perf_metrics::ExportMetrics::new()), - }; - (handle, Some(state)) + self.thread_handle = Some(handle); + self.video_state = None; + println!("🎬 [VIDEO EXPORT] zero-copy thread spawned"); + return Ok(()); } + } + // Zero-copy isn't used here; the scene-snapshot inputs are Linux-zero-copy-only. + #[cfg(not(target_os = "linux"))] + let _ = (document, &video_manager, &raster_store, &container_path); + + let (handle, video_state) = { + let (h, s) = Self::spawn_software_video( + settings, output_path, frame_rx, frame_tx, progress_tx, cancel_flag, + total_frames, start_time, end_time, framerate, width, height, + ); + (h, Some(s)) }; self.thread_handle = Some(handle); @@ -1093,21 +1112,14 @@ impl ExportOrchestrator { let video_cancel_flag = Arc::clone(&self.cancel_flag); let audio_cancel_flag = Arc::clone(&self.cancel_flag); - // Try the zero-copy VAAPI path for H.264 (renders + hardware-encodes on its own device, - // writing the temp .mp4 directly); falls back to the software encoder thread when the - // codec isn't H.264 or VAAPI is unavailable. - let zero_copy = Self::try_build_zero_copy( - &video_settings, - video_width, - video_height, - video_framerate, - &temp_video_path, - ); - - // Spawn the video thread: either the background zero-copy renderer/encoder (which owns a - // document snapshot + its own device, decoupled from the UI's vsync loop) or the software - // encoder thread fed by `render_next_video_frame` on the UI thread. - let (video_thread, video_state) = match zero_copy { + // Spawn the video thread: on Linux, try the background zero-copy renderer/encoder (its own + // device + a document snapshot, decoupled from the UI vsync loop, writing the temp .mp4 + // directly); otherwise (non-H.264, no VAAPI, or non-Linux) the software encoder thread fed + // by `render_next_video_frame` on the UI thread. + #[cfg(target_os = "linux")] + let (video_thread, video_state) = match Self::try_build_zero_copy( + &video_settings, video_width, video_height, video_framerate, &temp_video_path, + ) { Some(zc) => { drop(frame_rx); // the zero-copy path renders internally, no frame channel let document_snapshot = document.clone(); @@ -1118,56 +1130,34 @@ impl ExportOrchestrator { let temp_video_path = temp_video_path.clone(); let handle = std::thread::spawn(move || { Self::run_zerocopy_video_export( - zc, - document_snapshot, - image_cache, - video_manager, - raster_store, - total_frames, - video_start_time, - video_framerate, - video_width, - video_height, - temp_video_path, - video_progress_tx, - video_cancel_flag, + zc, document_snapshot, image_cache, video_manager, raster_store, + total_frames, video_start_time, video_framerate, video_width, video_height, + temp_video_path, video_progress_tx, video_cancel_flag, ); }); // No UI-thread video state: rendering happens entirely on the background thread. (Some(handle), None) } None => { - let video_settings_clone = video_settings.clone(); - let temp_video_path_clone = temp_video_path.clone(); - let handle = std::thread::spawn(move || { - Self::run_video_encoder( - video_settings_clone, - temp_video_path_clone, - frame_rx, - video_progress_tx, - video_cancel_flag, - total_frames, - ); - }); - let state = VideoExportState { - current_frame: 0, - total_frames, - start_time: video_start_time, - end_time: video_end_time, - framerate: video_framerate, - width: video_width, - height: video_height, - frame_tx: Some(frame_tx), - gpu_resources: None, - readback_pipeline: None, - cpu_yuv_converter: None, - frames_in_flight: 0, - next_frame_to_encode: 0, - perf_metrics: Some(perf_metrics::ExportMetrics::new()), - }; - (Some(handle), Some(state)) + let (h, s) = Self::spawn_software_video( + video_settings.clone(), temp_video_path.clone(), frame_rx, frame_tx, + video_progress_tx, video_cancel_flag, total_frames, + video_start_time, video_end_time, video_framerate, video_width, video_height, + ); + (Some(h), Some(s)) } }; + #[cfg(not(target_os = "linux"))] + let (video_thread, video_state) = { + // VAAPI/zero-copy is Linux-only; these scene-snapshot inputs are unused elsewhere. + let _ = (document, &video_manager, &raster_store, &container_path); + let (h, s) = Self::spawn_software_video( + video_settings.clone(), temp_video_path.clone(), frame_rx, frame_tx, + video_progress_tx, video_cancel_flag, total_frames, + video_start_time, video_end_time, video_framerate, video_width, video_height, + ); + (Some(h), Some(s)) + }; // Spawn audio export thread let temp_audio_path_clone = temp_audio_path.clone(); @@ -1410,7 +1400,8 @@ impl ExportOrchestrator { /// `.mp4`. Runs entirely off the UI thread (its own device + a `Document` snapshot), so it's /// not throttled by egui's vsync'd repaint loop. Reports progress through `progress_tx` /// (the same channel the software encoder thread uses); `poll_parallel_progress` muxes with - /// the audio track once both stream's `Complete` arrive. + /// the audio track once both stream's `Complete` arrive. VAAPI is Linux-only. + #[cfg(target_os = "linux")] #[allow(clippy::too_many_arguments)] fn run_zerocopy_video_export( mut zc: ZeroCopyVideo,