Release 1.0.6-alpha

This commit is contained in:
Skyler Lehmkuhl 2026-06-25 21:27:57 -04:00
commit a933fc3ca8
4 changed files with 204 additions and 135 deletions

View File

@ -118,6 +118,19 @@ jobs:
run: |
sed -i.bak 's/ffmpeg-next = { version = "8.0", features = \["static"\] }/ffmpeg-next = { version = "8.0", features = ["build", "static"] }/' lightningbeam-ui/lightningbeam-editor/Cargo.toml
# ffmpeg-sys-next configures FFmpeg with --disable-autodetect and only passes --enable-vaapi
# when its build-vaapi feature is set; ffmpeg-next 8.0 doesn't forward it, so depend on the
# sys crate directly. Linux only (the feature is a no-op elsewhere and we only ship VAAPI on
# Linux). With it set, a missing libva-dev makes FFmpeg configure fail loudly instead of
# silently shipping a software-only build.
- name: Enable VAAPI in FFmpeg build (Linux)
if: matrix.platform == 'ubuntu-24.04'
shell: bash
run: |
sed -i '/^ffmpeg-next = /a ffmpeg-sys-next = { version = "8.0", features = ["build-vaapi"] }' \
lightningbeam-ui/lightningbeam-editor/Cargo.toml
grep -n 'ffmpeg-sys-next' lightningbeam-ui/lightningbeam-editor/Cargo.toml
- name: Setup FFmpeg (Windows)
if: matrix.platform == 'windows-latest'
shell: pwsh

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,21 +836,131 @@ impl ExportOrchestrator {
}
}
/// Start a video export in the background (encoder thread)
/// 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. VAAPI is Linux-only, so this whole path is `cfg`-gated.
#[cfg(target_os = "linux")]
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
/// `render_next_video_frame()` repeatedly from the main thread to feed frames.
/// For H.264 with VAAPI available this renders + hardware-encodes on a background thread
/// (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
/// * `settings` - Video export settings
/// * `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
/// Ok(()) on success, Err on failure
#[allow(clippy::too_many_arguments)]
pub fn start_video_export(
&mut self,
settings: VideoExportSettings,
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> {
println!("🎬 [VIDEO EXPORT] Starting video export");
@ -870,38 +983,43 @@ impl ExportOrchestrator {
self.cancel_flag.store(false, Ordering::Relaxed);
let cancel_flag = Arc::clone(&self.cancel_flag);
// Spawn encoder thread
let handle = std::thread::spawn(move || {
Self::run_video_encoder(
settings,
output_path,
frame_rx,
progress_tx,
cancel_flag,
total_frames,
// 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,
);
});
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);
// 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()),
});
self.video_state = video_state;
println!("🎬 [VIDEO EXPORT] Encoder thread spawned, ready for frames");
Ok(())
@ -994,65 +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: render + hardware-encode each frame inline
// on its own device, writing the temp .mp4 directly (no readback / swscale / encoder
// thread). Falls back to the software encoder thread when unavailable.
let zero_copy = if matches!(video_settings.codec, lightningbeam_core::export::VideoCodec::H264) {
match gpu_video_encoder::encoder::ZeroCopyEncoder::new(
video_width,
video_height,
video_framerate.round() as i32,
video_settings.quality.bitrate_kbps(),
&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
// 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();
@ -1063,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();
@ -1355,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,

View File

@ -6107,7 +6107,14 @@ impl eframe::App for EditorApp {
ExportResult::VideoOnly(settings, output_path) => {
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,
Err(err) => {
eprintln!("❌ Failed to start video export: {}", err);

View File

@ -67,6 +67,9 @@ cd /build/src/lightningbeam-ui
# Build FFmpeg from source and link statically (no .so bundling needed)
sed -i 's/ffmpeg-next = { version = \"8.0\", features = \[\"static\"\] }/ffmpeg-next = { version = \"8.0\", features = [\"build\", \"static\"] }/' lightningbeam-editor/Cargo.toml
# Enable VAAPI: ffmpeg-sys-next configures with --disable-autodetect and only passes
# --enable-vaapi when its build-vaapi feature is set (ffmpeg-next 8.0 doesn't forward it).
sed -i '/^ffmpeg-next = /a ffmpeg-sys-next = { version = "8.0", features = ["build-vaapi"] }' lightningbeam-editor/Cargo.toml
export FFMPEG_STATIC=1
# Stage factory presets for packaging