editor: zero-copy H.264 for video-only export too

Video-only (no-audio) H.264 export now uses the same background-thread zero-copy
VAAPI path as video+audio, instead of the software encoder. It writes the output
.mp4 directly (no mux step) and reports through the single-export progress
channel; completion clears that channel so the dialog closes cleanly.

Factored the encoder/renderer/resources setup shared by both entry points into
ExportOrchestrator::try_build_zero_copy. start_video_export gains the document/
video_manager/raster_store/container_path inputs to seed the off-thread render
from a Document snapshot. Non-H264 / non-VAAPI still falls back to software.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-06-25 20:55:43 -04:00
parent 5bed2e8adb
commit 05b5bf5f85
2 changed files with 150 additions and 88 deletions

View File

@ -833,21 +833,89 @@ impl ExportOrchestrator {
} }
} }
/// Start a video export in the background (encoder thread) /// 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.
fn try_build_zero_copy(
settings: &VideoExportSettings,
width: u32,
height: u32,
framerate: f64,
output_path: &std::path::Path,
) -> Option<ZeroCopyVideo> {
if !matches!(settings.codec, lightningbeam_core::export::VideoCodec::H264) {
return None;
}
let encoder = match gpu_video_encoder::encoder::ZeroCopyEncoder::new(
width,
height,
framerate.round() as i32,
settings.quality.bitrate_kbps(),
output_path,
) {
Ok(e) => e,
Err(e) => {
println!("🎬 [EXPORT] zero-copy unavailable ({e}); software path");
return None;
}
};
let renderer = match vello::Renderer::new(
encoder.device(),
vello::RendererOptions {
use_cpu: false,
antialiasing_support: vello::AaSupport::all(),
num_init_threads: None,
pipeline_cache: None,
},
) {
Ok(r) => r,
Err(e) => {
println!("🎬 [EXPORT] zero-copy renderer init failed ({e}); software path");
return None;
}
};
let gpu_resources = video_exporter::ExportGpuResources::new(encoder.device(), width, height);
let rgba = encoder.device().create_texture(&wgpu::TextureDescriptor {
label: Some("zerocopy_export_rgba"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
println!("🎬 [EXPORT] zero-copy VAAPI H.264 enabled");
Some(ZeroCopyVideo { encoder, renderer, gpu_resources, rgba })
}
/// Start a video export in the background.
/// ///
/// Returns immediately after spawning encoder thread. Caller must call /// For H.264 with VAAPI available this renders + hardware-encodes on a background thread
/// `render_next_video_frame()` repeatedly from the main thread to feed frames. /// (writing `output_path` directly); otherwise it spawns the software encoder thread and the
/// caller drives frames via `render_next_video_frame()` from the main thread.
/// ///
/// # Arguments /// # Arguments
/// * `settings` - Video export settings /// * `settings` - Video export settings
/// * `output_path` - Output file path /// * `output_path` - Output file path
/// * `document`/`video_manager`/`raster_store`/`container_path` - scene data; the zero-copy
/// path snapshots the document and renders off-thread (the UI keeps the live one).
/// ///
/// # Returns /// # Returns
/// Ok(()) on success, Err on failure /// Ok(()) on success, Err on failure
#[allow(clippy::too_many_arguments)]
pub fn start_video_export( pub fn start_video_export(
&mut self, &mut self,
settings: VideoExportSettings, settings: VideoExportSettings,
output_path: PathBuf, output_path: PathBuf,
document: &Document,
video_manager: Arc<std::sync::Mutex<VideoManager>>,
raster_store: lightningbeam_core::raster_store::RasterStore,
container_path: Option<PathBuf>,
) -> Result<(), String> { ) -> Result<(), String> {
println!("🎬 [VIDEO EXPORT] Starting video export"); println!("🎬 [VIDEO EXPORT] Starting video export");
@ -870,38 +938,69 @@ impl ExportOrchestrator {
self.cancel_flag.store(false, Ordering::Relaxed); self.cancel_flag.store(false, Ordering::Relaxed);
let cancel_flag = Arc::clone(&self.cancel_flag); let cancel_flag = Arc::clone(&self.cancel_flag);
// Spawn encoder thread // Zero-copy VAAPI H.264 writes the final output directly (no audio → no mux), reporting
let handle = std::thread::spawn(move || { // through the single-export progress channel. Otherwise use the software encoder thread.
Self::run_video_encoder( let zero_copy = Self::try_build_zero_copy(&settings, width, height, framerate, &output_path);
settings,
output_path, let (handle, video_state) = match zero_copy {
frame_rx, Some(zc) => {
progress_tx, drop(frame_rx);
cancel_flag, let document_snapshot = document.clone();
total_frames, 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,
);
});
(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.thread_handle = Some(handle);
self.video_state = video_state;
// Initialize video export state
// GPU resources and readback pipeline will be initialized lazily on first frame (needs device)
self.video_state = Some(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()),
});
println!("🎬 [VIDEO EXPORT] Encoder thread spawned, ready for frames"); println!("🎬 [VIDEO EXPORT] Encoder thread spawned, ready for frames");
Ok(()) Ok(())
@ -994,60 +1093,16 @@ impl ExportOrchestrator {
let video_cancel_flag = Arc::clone(&self.cancel_flag); let video_cancel_flag = Arc::clone(&self.cancel_flag);
let audio_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: render + hardware-encode each frame inline // Try the zero-copy VAAPI path for H.264 (renders + hardware-encodes on its own device,
// on its own device, writing the temp .mp4 directly (no readback / swscale / encoder // writing the temp .mp4 directly); falls back to the software encoder thread when the
// thread). Falls back to the software encoder thread when unavailable. // codec isn't H.264 or VAAPI is unavailable.
let zero_copy = if matches!(video_settings.codec, lightningbeam_core::export::VideoCodec::H264) { let zero_copy = Self::try_build_zero_copy(
match gpu_video_encoder::encoder::ZeroCopyEncoder::new( &video_settings,
video_width, video_width,
video_height, video_height,
video_framerate.round() as i32, video_framerate,
video_settings.quality.bitrate_kbps(), &temp_video_path,
&temp_video_path, );
) {
Ok(encoder) => match vello::Renderer::new(
encoder.device(),
vello::RendererOptions {
use_cpu: false,
antialiasing_support: vello::AaSupport::all(),
num_init_threads: None,
pipeline_cache: None,
},
) {
Ok(renderer) => {
let gpu_resources = video_exporter::ExportGpuResources::new(
encoder.device(),
video_width,
video_height,
);
let rgba = encoder.device().create_texture(&wgpu::TextureDescriptor {
label: Some("zerocopy_export_rgba"),
size: wgpu::Extent3d { width: video_width, height: video_height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
println!("🎬 [PARALLEL EXPORT] zero-copy VAAPI H.264 enabled");
Some(ZeroCopyVideo { encoder, renderer, gpu_resources, rgba })
}
Err(e) => {
println!("🎬 [PARALLEL EXPORT] zero-copy renderer init failed ({e}); software path");
None
}
},
Err(e) => {
println!("🎬 [PARALLEL EXPORT] zero-copy unavailable ({e}); software path");
None
}
}
} else {
None
};
// Spawn the video thread: either the background zero-copy renderer/encoder (which owns a // 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 // document snapshot + its own device, decoupled from the UI's vsync loop) or the software

View File

@ -6107,7 +6107,14 @@ impl eframe::App for EditorApp {
ExportResult::VideoOnly(settings, output_path) => { ExportResult::VideoOnly(settings, output_path) => {
println!("🎬 [MAIN] Starting video-only export: {}", output_path.display()); println!("🎬 [MAIN] Starting video-only export: {}", output_path.display());
match orchestrator.start_video_export(settings, output_path) { match orchestrator.start_video_export(
settings,
output_path,
self.action_executor.document(),
Arc::clone(&self.video_manager),
self.raster_store.clone(),
self.current_file_path.clone(),
) {
Ok(()) => true, Ok(()) => true,
Err(err) => { Err(err) => {
eprintln!("❌ Failed to start video export: {}", err); eprintln!("❌ Failed to start video export: {}", err);