editor: gate zero-copy VAAPI export to Linux (fix macOS/Windows build)

gpu_video_encoder's encoder/vaapi/vk_device/dmabuf modules are #[cfg(linux)]
(VAAPI is Linux-only), but the editor referenced them unconditionally, so
macOS/Windows failed to compile (cannot find `encoder` in `gpu_video_encoder`).

Gate the zero-copy path (ZeroCopyVideo, try_build_zero_copy,
run_zerocopy_video_export, and the branches in start_video_export /
start_video_with_audio_export) behind cfg(target_os = "linux"). Factor the
software encoder-thread spawn into spawn_software_video so both the None arm
and the non-Linux path share it without duplication. Non-Linux always uses
the software path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-06-25 21:27:06 -04:00
parent 05b5bf5f85
commit 7ebd64391c
1 changed files with 100 additions and 109 deletions

View File

@ -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<VideoFrameMessage>,
frame_tx: Sender<VideoFrameMessage>,
progress_tx: Sender<ExportProgress>,
cancel_flag: Arc<AtomicBool>,
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,