export: run the zero-copy H.264 encoder on the shared device (Stage 3c-export pt 2)

Makes the common Linux H.264 export fully GPU-resident: decode (HW NV12) →
composite → VAAPI encode all on one device, no CPU round-trips.

- gpu-video-encoder: ZeroCopyEncoder now holds wgpu (device,queue,adapter) handles
  instead of owning a DrmDevice. New `new_on_device(device,queue,adapter,...)` runs
  the RGBA→NV12 render + DMA-BUF import on a passed device; `new` keeps building its
  own. The encoder only *imports* VAAPI surfaces (not export), so the shared
  import-capable device works directly.
- editor: stash the shared device handles on EditorApp (set in the creation closure
  from the eframe render_state when the shared device is active) and thread them
  through start_video_export / start_video_with_audio_export → try_build_zero_copy,
  which uses new_on_device when available. ZeroCopyVideo carries on_shared_device →
  the export composite uses hardware_ok=true (consuming HW-decoded GPU frames from
  pt 1) only then.

Falls back to the own-device encoder (decode downloads to CPU) when no shared
device. Tradeoff: the export now shares the GPU with the UI thread (was a separate
device); acceptable for the GPU-resident-decode win. Compiles; crate tests build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-06-26 05:18:23 -04:00
parent bb3369b709
commit ca9a70e10a
3 changed files with 86 additions and 26 deletions

View File

@ -6,7 +6,7 @@
use crate::dmabuf::{self, ImportedNv12, Nv12DmaBuf}; use crate::dmabuf::{self, ImportedNv12, Nv12DmaBuf};
use crate::render_nv12::Rgba2Nv12; use crate::render_nv12::Rgba2Nv12;
use crate::vk_device::{self, DrmDevice}; use crate::vk_device;
use ffmpeg_sys_next as ff; use ffmpeg_sys_next as ff;
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::CString; use std::ffi::CString;
@ -19,7 +19,11 @@ fn averror(e: i32) -> i32 {
} }
pub struct ZeroCopyEncoder { pub struct ZeroCopyEncoder {
drm: DrmDevice, /// wgpu handles the NV12 render runs on — either an own `DrmDevice`'s (via `new`) or the
/// editor's shared device (via `new_on_device`). Cloned (Arc-backed) so the source can drop.
device: wgpu::Device,
queue: wgpu::Queue,
adapter: wgpu::Adapter,
renderer: Rgba2Nv12, renderer: Rgba2Nv12,
hw_device: *mut ff::AVBufferRef, hw_device: *mut ff::AVBufferRef,
frames_ref: *mut ff::AVBufferRef, frames_ref: *mut ff::AVBufferRef,
@ -51,8 +55,30 @@ impl ZeroCopyEncoder {
output_path: &Path, output_path: &Path,
full_range: bool, full_range: bool,
) -> Result<Self, String> { ) -> Result<Self, String> {
// Build a dedicated DMA-BUF-import device and run the encoder on it.
let drm = vk_device::create()?; let drm = vk_device::create()?;
let renderer = Rgba2Nv12::new(&drm.device, full_range); Self::new_on_device(
drm.device, drm.queue, drm.adapter,
width, height, framerate, bitrate_kbps, output_path, full_range,
)
}
/// Build the encoder running its NV12 render + DMA-BUF import on an existing wgpu device (the
/// editor's shared device), so decode→composite→encode stay GPU-resident on one device. The
/// device must have the DMA-BUF import extensions (a `DrmDevice` / `vk_device::create_windowed`).
#[allow(clippy::too_many_arguments)]
pub fn new_on_device(
device: wgpu::Device,
queue: wgpu::Queue,
adapter: wgpu::Adapter,
width: u32,
height: u32,
framerate: i32,
bitrate_kbps: u32,
output_path: &Path,
full_range: bool,
) -> Result<Self, String> {
let renderer = Rgba2Nv12::new(&device, full_range);
unsafe { unsafe {
let mut hw_device = crate::vaapi::create_device()?; let mut hw_device = crate::vaapi::create_device()?;
let name = CString::new("h264_vaapi").unwrap(); let name = CString::new("h264_vaapi").unwrap();
@ -153,7 +179,9 @@ impl ZeroCopyEncoder {
let stream_tb = (*stream).time_base; let stream_tb = (*stream).time_base;
Ok(Self { Ok(Self {
drm, device,
queue,
adapter,
renderer, renderer,
hw_device, hw_device,
frames_ref, frames_ref,
@ -172,10 +200,10 @@ impl ZeroCopyEncoder {
/// The wgpu device frames must be rendered on (so the RGBA texture is importable). /// The wgpu device frames must be rendered on (so the RGBA texture is importable).
pub fn device(&self) -> &wgpu::Device { pub fn device(&self) -> &wgpu::Device {
&self.drm.device &self.device
} }
pub fn queue(&self) -> &wgpu::Queue { pub fn queue(&self) -> &wgpu::Queue {
&self.drm.queue &self.queue
} }
/// Render `rgba` (an `Rgba8Unorm` texture on [`Self::device`], `TEXTURE_BINDING`) /// Render `rgba` (an `Rgba8Unorm` texture on [`Self::device`], `TEXTURE_BINDING`)
@ -216,7 +244,7 @@ impl ZeroCopyEncoder {
uv_pitch: uv.pitch as u64, uv_pitch: uv.pitch as u64,
ten_bit: false, ten_bit: false,
}; };
let imported = match dmabuf::import_raw(&self.drm.device, &self.drm.adapter, &buf) { let imported = match dmabuf::import_raw(&self.device, &self.adapter, &buf) {
Ok(i) => i, Ok(i) => i,
Err(e) => { Err(e) => {
ff::av_frame_free(&mut (drm_f as *mut _)); ff::av_frame_free(&mut (drm_f as *mut _));
@ -233,10 +261,10 @@ impl ZeroCopyEncoder {
let rgba_view = rgba.create_view(&Default::default()); let rgba_view = rgba.create_view(&Default::default());
let y_view = imp.y().create_view(&Default::default()); let y_view = imp.y().create_view(&Default::default());
let uv_view = imp.uv().create_view(&Default::default()); let uv_view = imp.uv().create_view(&Default::default());
let mut cmd = self.drm.device.create_command_encoder(&Default::default()); let mut cmd = self.device.create_command_encoder(&Default::default());
self.renderer.convert(&self.drm.device, &mut cmd, &rgba_view, &y_view, &uv_view); self.renderer.convert(&self.device, &mut cmd, &rgba_view, &y_view, &uv_view);
self.drm.queue.submit(Some(cmd.finish())); self.queue.submit(Some(cmd.finish()));
let _ = self.drm.device.poll(wgpu::PollType::wait_indefinitely()); let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
// Encode the surface. // Encode the surface.
(*surf).pts = self.pts; (*surf).pts = self.pts;

View File

@ -82,6 +82,8 @@ struct ZeroCopyVideo {
gpu_resources: video_exporter::ExportGpuResources, gpu_resources: video_exporter::ExportGpuResources,
/// Reused RGBA target (RENDER_ATTACHMENT | TEXTURE_BINDING) on the encoder's device. /// Reused RGBA target (RENDER_ATTACHMENT | TEXTURE_BINDING) on the encoder's device.
rgba: wgpu::Texture, rgba: wgpu::Texture,
/// True when running on the shared device → compositing can consume hardware-decoded GPU frames.
on_shared_device: bool,
} }
/// State for a single-frame image export (runs on the GPU render thread, one frame per update). /// State for a single-frame image export (runs on the GPU render thread, one frame per update).
@ -895,6 +897,7 @@ impl ExportOrchestrator {
height: u32, height: u32,
framerate: f64, framerate: f64,
output_path: &std::path::Path, output_path: &std::path::Path,
shared_device: Option<(wgpu::Device, wgpu::Queue, wgpu::Adapter)>,
) -> Option<ZeroCopyVideo> { ) -> Option<ZeroCopyVideo> {
// Zero-copy is 8-bit H.264 only; HDR needs the 10-bit HEVC software path. // Zero-copy is 8-bit H.264 only; HDR needs the 10-bit HEVC software path.
if settings.hdr.is_hdr() if settings.hdr.is_hdr()
@ -902,14 +905,25 @@ impl ExportOrchestrator {
{ {
return None; return None;
} }
let encoder = match gpu_video_encoder::encoder::ZeroCopyEncoder::new( let bitrate = settings.quality.bitrate_kbps();
width, let fr = framerate.round() as i32;
height, let full = settings.color_range.is_full();
framerate.round() as i32, let on_shared_device = shared_device.is_some();
settings.quality.bitrate_kbps(), // Prefer the shared device → decode→composite→encode stay GPU-resident on one device.
output_path, // Without it, the encoder builds its own device (decode still downloads to CPU per Step 1's
settings.color_range.is_full(), // hardware_ok=false on this path).
) { let encoder_result = match shared_device {
Some((device, queue, adapter)) => {
println!("🎬 [EXPORT] zero-copy on shared device (GPU-resident decode)");
gpu_video_encoder::encoder::ZeroCopyEncoder::new_on_device(
device, queue, adapter, width, height, fr, bitrate, output_path, full,
)
}
None => gpu_video_encoder::encoder::ZeroCopyEncoder::new(
width, height, fr, bitrate, output_path, full,
),
};
let encoder = match encoder_result {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
println!("🎬 [EXPORT] zero-copy unavailable ({e}); software path"); println!("🎬 [EXPORT] zero-copy unavailable ({e}); software path");
@ -945,7 +959,7 @@ impl ExportOrchestrator {
view_formats: &[], view_formats: &[],
}); });
println!("🎬 [EXPORT] zero-copy VAAPI H.264 enabled"); println!("🎬 [EXPORT] zero-copy VAAPI H.264 enabled");
Some(ZeroCopyVideo { encoder, renderer, gpu_resources, rgba }) Some(ZeroCopyVideo { encoder, renderer, gpu_resources, rgba, on_shared_device })
} }
/// Start a video export in the background. /// Start a video export in the background.
@ -963,6 +977,7 @@ impl ExportOrchestrator {
/// # Returns /// # Returns
/// Ok(()) on success, Err on failure /// Ok(()) on success, Err on failure
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub fn start_video_export( pub fn start_video_export(
&mut self, &mut self,
settings: VideoExportSettings, settings: VideoExportSettings,
@ -971,6 +986,8 @@ impl ExportOrchestrator {
video_manager: Arc<std::sync::Mutex<VideoManager>>, video_manager: Arc<std::sync::Mutex<VideoManager>>,
raster_store: lightningbeam_core::raster_store::RasterStore, raster_store: lightningbeam_core::raster_store::RasterStore,
container_path: Option<PathBuf>, container_path: Option<PathBuf>,
// The shared VAAPI device, `Some` only when active → zero-copy encode runs on it.
shared_device: Option<(wgpu::Device, wgpu::Queue, wgpu::Adapter)>,
) -> Result<(), String> { ) -> Result<(), String> {
println!("🎬 [VIDEO EXPORT] Starting video export"); println!("🎬 [VIDEO EXPORT] Starting video export");
@ -998,7 +1015,7 @@ impl ExportOrchestrator {
// success it returns here; otherwise we fall through to the software encoder thread. // success it returns here; otherwise we fall through to the software encoder thread.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
if let Some(zc) = Self::try_build_zero_copy(&settings, width, height, framerate, &output_path) { if let Some(zc) = Self::try_build_zero_copy(&settings, width, height, framerate, &output_path, shared_device) {
drop(frame_rx); drop(frame_rx);
let document_snapshot = document.clone(); let document_snapshot = document.clone();
let mut image_cache = ImageCache::new(); let mut image_cache = ImageCache::new();
@ -1062,6 +1079,8 @@ impl ExportOrchestrator {
video_manager: Arc<std::sync::Mutex<VideoManager>>, video_manager: Arc<std::sync::Mutex<VideoManager>>,
raster_store: lightningbeam_core::raster_store::RasterStore, raster_store: lightningbeam_core::raster_store::RasterStore,
container_path: Option<PathBuf>, container_path: Option<PathBuf>,
// The shared VAAPI device, `Some` only when active → zero-copy encode runs on it.
shared_device: Option<(wgpu::Device, wgpu::Queue, wgpu::Adapter)>,
) -> Result<(), String> { ) -> Result<(), String> {
println!("🎬🎵 [PARALLEL EXPORT] Starting parallel video+audio export"); println!("🎬🎵 [PARALLEL EXPORT] Starting parallel video+audio export");
@ -1128,7 +1147,7 @@ impl ExportOrchestrator {
// by `render_next_video_frame` on the UI thread. // by `render_next_video_frame` on the UI thread.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let (video_thread, video_state) = match Self::try_build_zero_copy( let (video_thread, video_state) = match Self::try_build_zero_copy(
&video_settings, video_width, video_height, video_framerate, &temp_video_path, &video_settings, video_width, video_height, video_framerate, &temp_video_path, shared_device,
) { ) {
Some(zc) => { Some(zc) => {
drop(frame_rx); // the zero-copy path renders internally, no frame channel drop(frame_rx); // the zero-copy path renders internally, no frame channel
@ -1500,7 +1519,7 @@ impl ExportOrchestrator {
None, None,
false, false,
Some(&raster_store), Some(&raster_store),
false, // zero-copy runs on its own device → download HW frames to CPU zc.on_shared_device, // GPU-resident decode only when on the shared device
) { ) {
Ok(cmd) => cmd, Ok(cmd) => cmd,
Err(e) => { Err(e) => {

View File

@ -299,14 +299,18 @@ fn main() -> eframe::Result {
options, options,
Box::new(move |cc| { Box::new(move |cc| {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
let app = EditorApp::new(cc, layouts, theme, test_mode_panic_snapshot_for_app, test_mode_pending_event_for_app, test_mode_is_replaying_for_app, test_mode_pending_geometry_for_app); #[allow(unused_mut)]
let mut app = EditorApp::new(cc, layouts, theme, test_mode_panic_snapshot_for_app, test_mode_pending_event_for_app, test_mode_is_replaying_for_app, test_mode_pending_geometry_for_app);
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
let app = EditorApp::new(cc, layouts, theme); #[allow(unused_mut)]
// Wire hardware video decode into the VideoManager now that the shared device exists. let mut app = EditorApp::new(cc, layouts, theme);
// Wire hardware video decode into the VideoManager now that the shared device exists, and
// stash the shared device handles so the zero-copy export encoder can run on it too.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if shared_device_active { if shared_device_active {
if let Some(rs) = cc.wgpu_render_state.as_ref() { if let Some(rs) = cc.wgpu_render_state.as_ref() {
hw_video::install(&app.video_manager, &rs.device, &rs.adapter); hw_video::install(&app.video_manager, &rs.device, &rs.adapter);
app.shared_device = Some((rs.device.clone(), rs.queue.clone(), rs.adapter.clone()));
} }
} }
Ok(Box::new(app)) Ok(Box::new(app))
@ -990,6 +994,9 @@ struct EditorApp {
audio_channels: u32, audio_channels: u32,
// Video decoding and management // Video decoding and management
video_manager: std::sync::Arc<std::sync::Mutex<lightningbeam_core::video::VideoManager>>, // Shared video manager video_manager: std::sync::Arc<std::sync::Mutex<lightningbeam_core::video::VideoManager>>, // Shared video manager
/// The shared VAAPI-capable wgpu device (device, queue, adapter), `Some` only when active. Lets
/// the zero-copy export encoder run on it (GPU-resident decode→composite→encode).
shared_device: Option<(wgpu::Device, wgpu::Queue, wgpu::Adapter)>,
// Webcam capture state // Webcam capture state
webcam: Option<lightningbeam_core::webcam::WebcamCapture>, webcam: Option<lightningbeam_core::webcam::WebcamCapture>,
/// Latest polled webcam frame (updated each frame for preview) /// Latest polled webcam frame (updated each frame for preview)
@ -1327,6 +1334,7 @@ impl EditorApp {
video_manager: std::sync::Arc::new(std::sync::Mutex::new( video_manager: std::sync::Arc::new(std::sync::Mutex::new(
lightningbeam_core::video::VideoManager::new() lightningbeam_core::video::VideoManager::new()
)), )),
shared_device: None,
webcam: None, webcam: None,
webcam_frame: None, webcam_frame: None,
webcam_record_command: None, webcam_record_command: None,
@ -6125,6 +6133,9 @@ impl eframe::App for EditorApp {
self.export_orchestrator = Some(export::ExportOrchestrator::new()); self.export_orchestrator = Some(export::ExportOrchestrator::new());
} }
// Clone before the &mut self.export_orchestrator borrow below.
let shared_device = self.shared_device.clone();
let export_started = if let Some(orchestrator) = &mut self.export_orchestrator { let export_started = if let Some(orchestrator) = &mut self.export_orchestrator {
match export_result { match export_result {
ExportResult::Image(settings, output_path) => { ExportResult::Image(settings, output_path) => {
@ -6163,6 +6174,7 @@ impl eframe::App for EditorApp {
Arc::clone(&self.video_manager), Arc::clone(&self.video_manager),
self.raster_store.clone(), self.raster_store.clone(),
self.current_file_path.clone(), self.current_file_path.clone(),
shared_device.clone(),
) { ) {
Ok(()) => true, Ok(()) => true,
Err(err) => { Err(err) => {
@ -6184,6 +6196,7 @@ impl eframe::App for EditorApp {
Arc::clone(&self.video_manager), Arc::clone(&self.video_manager),
self.raster_store.clone(), self.raster_store.clone(),
self.current_file_path.clone(), self.current_file_path.clone(),
shared_device.clone(),
) { ) {
Ok(()) => true, Ok(()) => true,
Err(err) => { Err(err) => {