editor: run zero-copy H.264 export on a background thread
The zero-copy VAAPI export previously rendered one frame per egui repaint on the UI thread, which pinned throughput to the 60Hz vsync of the present loop (measured exactly 16ms/frame) -- so the near-free hardware encode bought nothing. Because the export runs on its own VAAPI device (independent of eframe), it can run entirely off the UI thread. - run_zerocopy_video_export: a background thread owning the encoder + its own vello renderer/device, a Document snapshot (Document is Clone+Send; the UI keeps the live one), its own ImageCache, and a RasterStore clone. Renders + hardware-encodes every frame and reports through the same video_progress channel the software encoder thread uses. - start_video_with_audio_export takes the document/video_manager/raster_store/ container_path to seed the thread; video_state is None for this path. - Throttle export-time UI repaints (~6Hz) and the thread's progress sends so the render thread keeps the cores; the breakdown print stays. - cancel() tears down parallel_export (detaches threads, removes temp files) so the progress dialog dismisses; the call site closes the dialog. - Gate the progress poll loop on has_pending_progress() so it stops once the export ends instead of polling/logging every repaint forever; the single export path clears its channel on the terminal event. Vsync overhead is gone (0.1ms/frame); export is now render-bound (~11ms/frame Vello scene-build). ~1:50 -> ~56s (~2x) on the validation clip.
This commit is contained in:
parent
2bce5e93a6
commit
ecfa192245
|
|
@ -66,10 +66,6 @@ pub struct VideoExportState {
|
||||||
next_frame_to_encode: usize,
|
next_frame_to_encode: usize,
|
||||||
/// Performance metrics for instrumentation
|
/// Performance metrics for instrumentation
|
||||||
perf_metrics: Option<perf_metrics::ExportMetrics>,
|
perf_metrics: Option<perf_metrics::ExportMetrics>,
|
||||||
/// When `Some`, the video is produced zero-copy on its own VAAPI-capable device
|
|
||||||
/// (renders + hardware-encodes inline), bypassing the readback pipeline + encoder
|
|
||||||
/// thread. Only set for the parallel video+audio H.264 path when VAAPI is available.
|
|
||||||
zero_copy: Option<ZeroCopyVideo>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zero-copy VAAPI video production: renders each frame to RGBA and hardware-encodes it
|
/// Zero-copy VAAPI video production: renders each frame to RGBA and hardware-encodes it
|
||||||
|
|
@ -210,18 +206,36 @@ impl ExportOrchestrator {
|
||||||
return self.poll_parallel_progress();
|
return self.poll_parallel_progress();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle single export (audio-only or video-only)
|
// Handle single export (audio-only or video-only). Recv into a local first so we can
|
||||||
if let Some(rx) = &self.progress_rx {
|
// clear the channel on a terminal event without a borrow conflict — that lets
|
||||||
match rx.try_recv() {
|
// `has_pending_progress()` (and thus the UI poll loop) go quiet once the export ends,
|
||||||
Ok(progress) => {
|
// instead of polling forever. The thread may already be finished here, so we must drain
|
||||||
|
// the final Complete/Error from the channel rather than rely on `is_exporting()`.
|
||||||
|
let recv = self.progress_rx.as_ref().map(|rx| rx.try_recv());
|
||||||
|
match recv {
|
||||||
|
Some(Ok(progress)) => {
|
||||||
println!("📨 [ORCHESTRATOR] Received progress: {:?}", std::mem::discriminant(&progress));
|
println!("📨 [ORCHESTRATOR] Received progress: {:?}", std::mem::discriminant(&progress));
|
||||||
|
if matches!(progress, ExportProgress::Complete { .. } | ExportProgress::Error { .. }) {
|
||||||
|
self.progress_rx = None;
|
||||||
|
self.thread_handle = None;
|
||||||
|
}
|
||||||
Some(progress)
|
Some(progress)
|
||||||
}
|
}
|
||||||
Err(_) => None,
|
Some(Err(std::sync::mpsc::TryRecvError::Disconnected)) => {
|
||||||
}
|
// Thread gone without a terminal message; stop polling.
|
||||||
} else {
|
self.progress_rx = None;
|
||||||
|
self.thread_handle = None;
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
_ => None, // Empty, or no channel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the orchestrator still has progress to report (an active export, or an
|
||||||
|
/// unconsumed terminal message). Used to gate the UI poll loop so it doesn't run every
|
||||||
|
/// repaint forever after an export finishes.
|
||||||
|
pub fn has_pending_progress(&self) -> bool {
|
||||||
|
self.parallel_export.is_some() || self.image_state.is_some() || self.progress_rx.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll progress for parallel video+audio export
|
/// Poll progress for parallel video+audio export
|
||||||
|
|
@ -494,6 +508,19 @@ impl ExportOrchestrator {
|
||||||
/// Cancel the current export
|
/// Cancel the current export
|
||||||
pub fn cancel(&mut self) {
|
pub fn cancel(&mut self) {
|
||||||
self.cancel_flag.store(true, Ordering::Relaxed);
|
self.cancel_flag.store(true, Ordering::Relaxed);
|
||||||
|
// Tear down so `is_exporting()` goes false and the UI can drop the progress dialog.
|
||||||
|
// The background threads observe the cancel flag and exit on their own; we detach their
|
||||||
|
// handles here rather than joining (joining would block the UI). Partial temp files are
|
||||||
|
// removed — any still-open encoder fd just writes to the unlinked inode, which is freed
|
||||||
|
// on close.
|
||||||
|
if let Some(parallel) = self.parallel_export.take() {
|
||||||
|
std::fs::remove_file(¶llel.temp_video_path).ok();
|
||||||
|
std::fs::remove_file(¶llel.temp_audio_path).ok();
|
||||||
|
}
|
||||||
|
self.video_state = None;
|
||||||
|
self.image_state = None;
|
||||||
|
self.progress_rx = None;
|
||||||
|
self.thread_handle = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if an export is in progress
|
/// Check if an export is in progress
|
||||||
|
|
@ -874,7 +901,6 @@ impl ExportOrchestrator {
|
||||||
frames_in_flight: 0,
|
frames_in_flight: 0,
|
||||||
next_frame_to_encode: 0,
|
next_frame_to_encode: 0,
|
||||||
perf_metrics: Some(perf_metrics::ExportMetrics::new()),
|
perf_metrics: Some(perf_metrics::ExportMetrics::new()),
|
||||||
zero_copy: None, // video-only export uses the software path for now
|
|
||||||
});
|
});
|
||||||
|
|
||||||
println!("🎬 [VIDEO EXPORT] Encoder thread spawned, ready for frames");
|
println!("🎬 [VIDEO EXPORT] Encoder thread spawned, ready for frames");
|
||||||
|
|
@ -895,12 +921,19 @@ impl ExportOrchestrator {
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
/// Ok(()) on success, Err on failure
|
/// Ok(()) on success, Err on failure
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn start_video_with_audio_export(
|
pub fn start_video_with_audio_export(
|
||||||
&mut self,
|
&mut self,
|
||||||
video_settings: VideoExportSettings,
|
video_settings: VideoExportSettings,
|
||||||
mut audio_settings: AudioExportSettings,
|
mut audio_settings: AudioExportSettings,
|
||||||
output_path: PathBuf,
|
output_path: PathBuf,
|
||||||
audio_controller: Arc<std::sync::Mutex<daw_backend::EngineController>>,
|
audio_controller: Arc<std::sync::Mutex<daw_backend::EngineController>>,
|
||||||
|
// For the zero-copy H.264 path the export runs on a background thread, so it needs an
|
||||||
|
// owned snapshot of the scene data (the live document/caches stay with the UI thread).
|
||||||
|
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!("🎬🎵 [PARALLEL EXPORT] Starting parallel video+audio export");
|
println!("🎬🎵 [PARALLEL EXPORT] Starting parallel video+audio export");
|
||||||
|
|
||||||
|
|
@ -1016,11 +1049,42 @@ impl ExportOrchestrator {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Spawn the software video encoder thread only when not zero-copy.
|
// Spawn the video thread: either the background zero-copy renderer/encoder (which owns a
|
||||||
let video_thread = if zero_copy.is_none() {
|
// 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 {
|
||||||
|
Some(zc) => {
|
||||||
|
drop(frame_rx); // the zero-copy path renders internally, no frame channel
|
||||||
|
let document_snapshot = document.clone();
|
||||||
|
let mut image_cache = ImageCache::new();
|
||||||
|
image_cache.set_container_path(container_path.clone());
|
||||||
|
let raster_store = raster_store.clone();
|
||||||
|
let video_manager = Arc::clone(&video_manager);
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// No UI-thread video state: rendering happens entirely on the background thread.
|
||||||
|
(Some(handle), None)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
let video_settings_clone = video_settings.clone();
|
let video_settings_clone = video_settings.clone();
|
||||||
let temp_video_path_clone = temp_video_path.clone();
|
let temp_video_path_clone = temp_video_path.clone();
|
||||||
Some(std::thread::spawn(move || {
|
let handle = std::thread::spawn(move || {
|
||||||
Self::run_video_encoder(
|
Self::run_video_encoder(
|
||||||
video_settings_clone,
|
video_settings_clone,
|
||||||
temp_video_path_clone,
|
temp_video_path_clone,
|
||||||
|
|
@ -1029,13 +1093,25 @@ impl ExportOrchestrator {
|
||||||
video_cancel_flag,
|
video_cancel_flag,
|
||||||
total_frames,
|
total_frames,
|
||||||
);
|
);
|
||||||
}))
|
});
|
||||||
} else {
|
let state = VideoExportState {
|
||||||
// In zero-copy mode the render loop writes the temp .mp4 directly and sets
|
current_frame: 0,
|
||||||
// `video_progress` on completion, so these are unused.
|
total_frames,
|
||||||
drop(frame_rx);
|
start_time: video_start_time,
|
||||||
drop(video_progress_tx);
|
end_time: video_end_time,
|
||||||
None
|
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))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Spawn audio export thread
|
// Spawn audio export thread
|
||||||
|
|
@ -1050,26 +1126,10 @@ impl ExportOrchestrator {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize video export state for incremental rendering. GPU resources + readback
|
// The software path drives frames from the UI thread (state is `Some`); the zero-copy
|
||||||
// pipeline init lazily on the first frame (software path); zero-copy carries its own.
|
// path renders on its own background thread (`None`). GPU resources + readback pipeline
|
||||||
let frame_tx = if zero_copy.is_some() { None } else { Some(frame_tx) };
|
// init lazily on the first frame for the software path.
|
||||||
self.video_state = Some(VideoExportState {
|
self.video_state = video_state;
|
||||||
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,
|
|
||||||
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()),
|
|
||||||
zero_copy,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize parallel export state
|
// Initialize parallel export state
|
||||||
self.parallel_export = Some(ParallelExportState {
|
self.parallel_export = Some(ParallelExportState {
|
||||||
|
|
@ -1116,12 +1176,9 @@ impl ExportOrchestrator {
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
// Zero-copy VAAPI path renders + hardware-encodes inline on its own device,
|
// The zero-copy VAAPI H.264 path runs entirely on its own background thread
|
||||||
// ignoring the passed eframe device/queue/renderer.
|
// (see `run_zerocopy_video_export`); this UI-thread entry only drives the software
|
||||||
if self.video_state.as_ref().map_or(false, |s| s.zero_copy.is_some()) {
|
// readback/encode pipeline.
|
||||||
return self.render_next_video_frame_zerocopy(document, image_cache, video_manager, raster_store);
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = self.video_state.as_mut()
|
let state = self.video_state.as_mut()
|
||||||
.ok_or("No video export in progress")?;
|
.ok_or("No video export in progress")?;
|
||||||
|
|
||||||
|
|
@ -1293,66 +1350,109 @@ impl ExportOrchestrator {
|
||||||
/// Zero-copy video production: render the document to RGBA on the encoder's own device
|
/// Zero-copy video production: render the document to RGBA on the encoder's own device
|
||||||
/// and hardware-encode it into a VAAPI surface, writing the temp `.mp4` directly. Drives
|
/// and hardware-encode it into a VAAPI surface, writing the temp `.mp4` directly. Drives
|
||||||
/// the parallel export's `video_progress` and triggers the mux on completion.
|
/// the parallel export's `video_progress` and triggers the mux on completion.
|
||||||
fn render_next_video_frame_zerocopy(
|
/// Background thread for the zero-copy VAAPI H.264 path: renders every frame with Vello on
|
||||||
&mut self,
|
/// the encoder's own VAAPI-capable device and hardware-encodes it straight into the temp
|
||||||
document: &mut Document,
|
/// `.mp4`. Runs entirely off the UI thread (its own device + a `Document` snapshot), so it's
|
||||||
image_cache: &mut ImageCache,
|
/// not throttled by egui's vsync'd repaint loop. Reports progress through `progress_tx`
|
||||||
video_manager: &Arc<std::sync::Mutex<VideoManager>>,
|
/// (the same channel the software encoder thread uses); `poll_parallel_progress` muxes with
|
||||||
raster_store: Option<&lightningbeam_core::raster_store::RasterStore>,
|
/// the audio track once both stream's `Complete` arrive.
|
||||||
) -> Result<bool, String> {
|
#[allow(clippy::too_many_arguments)]
|
||||||
let (current, total) = {
|
fn run_zerocopy_video_export(
|
||||||
let s = self.video_state.as_ref().ok_or("No video export in progress")?;
|
mut zc: ZeroCopyVideo,
|
||||||
(s.current_frame, s.total_frames)
|
mut document: Document,
|
||||||
};
|
mut image_cache: ImageCache,
|
||||||
|
video_manager: Arc<std::sync::Mutex<VideoManager>>,
|
||||||
|
raster_store: lightningbeam_core::raster_store::RasterStore,
|
||||||
|
total_frames: usize,
|
||||||
|
start_time: f64,
|
||||||
|
framerate: f64,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
temp_video_path: PathBuf,
|
||||||
|
progress_tx: Sender<ExportProgress>,
|
||||||
|
cancel_flag: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
progress_tx.send(ExportProgress::Started { total_frames }).ok();
|
||||||
|
|
||||||
if current >= total {
|
let wall = std::time::Instant::now();
|
||||||
// All frames rendered: flush + write the container trailer, then signal Complete
|
let mut render_time = std::time::Duration::ZERO;
|
||||||
// so `poll_parallel_progress` muxes with the audio track.
|
let mut encode_time = std::time::Duration::ZERO;
|
||||||
let zc = self.video_state.as_mut().unwrap().zero_copy.take().unwrap();
|
// Throttle progress sends to ~6/s: each one forces a full editor repaint on the UI thread,
|
||||||
let ZeroCopyVideo { encoder, .. } = zc;
|
// which steals CPU/GPU from this render loop. The dialog doesn't need finer granularity.
|
||||||
encoder.finish()?;
|
let mut last_progress = std::time::Instant::now();
|
||||||
if let Some(p) = self.parallel_export.as_mut() {
|
|
||||||
let path = p.temp_video_path.clone();
|
for frame in 0..total_frames {
|
||||||
p.video_progress = Some(ExportProgress::Complete { output_path: path });
|
if cancel_flag.load(Ordering::Relaxed) {
|
||||||
}
|
println!("🎬 [VIDEO EXPORT] zero-copy cancelled at frame {frame}");
|
||||||
self.video_state = None;
|
return; // dropping `zc` closes the encoder / temp file; no Complete → no mux
|
||||||
println!("🎬 [VIDEO EXPORT] zero-copy complete: {} frames", total);
|
|
||||||
return Ok(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render one frame to RGBA on the encoder's device, then hardware-encode it.
|
let timestamp = start_time + (frame as f64 / framerate);
|
||||||
{
|
|
||||||
let state = self.video_state.as_mut().unwrap();
|
|
||||||
let zc = state.zero_copy.as_mut().unwrap();
|
|
||||||
let (w, h) = (state.width, state.height);
|
|
||||||
let timestamp = state.start_time + (current as f64 / state.framerate);
|
|
||||||
let rgba_view = zc.rgba.create_view(&Default::default());
|
let rgba_view = zc.rgba.create_view(&Default::default());
|
||||||
let cmd = video_exporter::render_frame_to_gpu_rgba(
|
|
||||||
document,
|
let t0 = std::time::Instant::now();
|
||||||
|
let cmd = match video_exporter::render_frame_to_gpu_rgba(
|
||||||
|
&mut document,
|
||||||
timestamp,
|
timestamp,
|
||||||
w,
|
width,
|
||||||
h,
|
height,
|
||||||
zc.encoder.device(),
|
zc.encoder.device(),
|
||||||
zc.encoder.queue(),
|
zc.encoder.queue(),
|
||||||
&mut zc.renderer,
|
&mut zc.renderer,
|
||||||
image_cache,
|
&mut image_cache,
|
||||||
video_manager,
|
&video_manager,
|
||||||
&mut zc.gpu_resources,
|
&mut zc.gpu_resources,
|
||||||
&rgba_view,
|
&rgba_view,
|
||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
raster_store,
|
Some(&raster_store),
|
||||||
)?;
|
) {
|
||||||
|
Ok(cmd) => cmd,
|
||||||
|
Err(e) => {
|
||||||
|
progress_tx.send(ExportProgress::Error { message: format!("render: {e}") }).ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
zc.encoder.queue().submit(Some(cmd.finish()));
|
zc.encoder.queue().submit(Some(cmd.finish()));
|
||||||
zc.encoder.encode_rgba(&zc.rgba)?;
|
let t1 = std::time::Instant::now();
|
||||||
state.current_frame += 1;
|
if let Err(e) = zc.encoder.encode_rgba(&zc.rgba) {
|
||||||
|
progress_tx.send(ExportProgress::Error { message: format!("encode: {e}") }).ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let t2 = std::time::Instant::now();
|
||||||
|
render_time += t1 - t0;
|
||||||
|
encode_time += t2 - t1;
|
||||||
|
|
||||||
|
if last_progress.elapsed() >= std::time::Duration::from_millis(160) || frame + 1 == total_frames {
|
||||||
|
progress_tx
|
||||||
|
.send(ExportProgress::FrameRendered { frame: frame + 1, total: total_frames })
|
||||||
|
.ok();
|
||||||
|
last_progress = std::time::Instant::now();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let cur = self.video_state.as_ref().unwrap().current_frame;
|
// Flush the encoder + write the container trailer.
|
||||||
if let Some(p) = self.parallel_export.as_mut() {
|
let ZeroCopyVideo { encoder, .. } = zc;
|
||||||
p.video_progress = Some(ExportProgress::FrameRendered { frame: cur, total });
|
if let Err(e) = encoder.finish() {
|
||||||
|
progress_tx.send(ExportProgress::Error { message: format!("finish: {e}") }).ok();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
Ok(true)
|
|
||||||
|
// Performance breakdown.
|
||||||
|
let wall = wall.elapsed();
|
||||||
|
let n = total_frames.max(1) as f64;
|
||||||
|
let fps = if wall.as_secs_f64() > 0.0 { total_frames as f64 / wall.as_secs_f64() } else { 0.0 };
|
||||||
|
println!("🎬 [VIDEO EXPORT] zero-copy complete: {} frames", total_frames);
|
||||||
|
println!(
|
||||||
|
" ⏱ wall {:.2}s ({:.1} fps) | render {:.2}ms/frame | nv12+encode {:.2}ms/frame | overhead {:.2}ms/frame",
|
||||||
|
wall.as_secs_f64(),
|
||||||
|
fps,
|
||||||
|
render_time.as_secs_f64() * 1000.0 / n,
|
||||||
|
encode_time.as_secs_f64() * 1000.0 / n,
|
||||||
|
(wall.saturating_sub(render_time + encode_time)).as_secs_f64() * 1000.0 / n,
|
||||||
|
);
|
||||||
|
|
||||||
|
progress_tx.send(ExportProgress::Complete { output_path: temp_video_path }).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background thread that receives frames and encodes them
|
/// Background thread that receives frames and encodes them
|
||||||
|
|
|
||||||
|
|
@ -6124,6 +6124,10 @@ impl eframe::App for EditorApp {
|
||||||
audio_settings,
|
audio_settings,
|
||||||
output_path,
|
output_path,
|
||||||
Arc::clone(audio_controller),
|
Arc::clone(audio_controller),
|
||||||
|
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) => {
|
||||||
|
|
@ -6149,10 +6153,11 @@ impl eframe::App for EditorApp {
|
||||||
|
|
||||||
// Render export progress dialog and handle cancel
|
// Render export progress dialog and handle cancel
|
||||||
if self.export_progress_dialog.render(ctx) {
|
if self.export_progress_dialog.render(ctx) {
|
||||||
// User clicked Cancel
|
// User clicked Cancel: stop + tear down the export, then dismiss the dialog.
|
||||||
if let Some(orchestrator) = &mut self.export_orchestrator {
|
if let Some(orchestrator) = &mut self.export_orchestrator {
|
||||||
orchestrator.cancel();
|
orchestrator.cancel();
|
||||||
}
|
}
|
||||||
|
self.export_progress_dialog.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep requesting repaints while export progress dialog is open
|
// Keep requesting repaints while export progress dialog is open
|
||||||
|
|
@ -6181,6 +6186,11 @@ impl eframe::App for EditorApp {
|
||||||
// Render video frames incrementally (if video export in progress)
|
// Render video frames incrementally (if video export in progress)
|
||||||
let exporting = self.export_orchestrator.as_ref().map_or(false, |o| o.is_exporting());
|
let exporting = self.export_orchestrator.as_ref().map_or(false, |o| o.is_exporting());
|
||||||
if exporting {
|
if exporting {
|
||||||
|
// Keep the UI loop alive so progress is polled/drained even when the video is
|
||||||
|
// produced on a background thread (zero-copy path) that emits no UI-thread frames.
|
||||||
|
// Poll at ~6 Hz (not 60): the progress bar doesn't need more, and repainting the full
|
||||||
|
// editor every frame steals CPU/GPU from the background render thread, slowing export.
|
||||||
|
ctx.request_repaint_after(std::time::Duration::from_millis(160));
|
||||||
if let Some(render_state) = frame.wgpu_render_state() {
|
if let Some(render_state) = frame.wgpu_render_state() {
|
||||||
let device = &render_state.device;
|
let device = &render_state.device;
|
||||||
let queue = &render_state.queue;
|
let queue = &render_state.queue;
|
||||||
|
|
@ -6246,8 +6256,10 @@ impl eframe::App for EditorApp {
|
||||||
self.export_image_cache = None;
|
self.export_image_cache = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll export orchestrator for progress
|
// Poll export orchestrator for progress — only while there's something to report
|
||||||
if let Some(orchestrator) = &mut self.export_orchestrator {
|
// (otherwise this runs every repaint forever, spamming logs and wasting work). The
|
||||||
|
// orchestrator clears its state once the terminal Complete/Error is consumed.
|
||||||
|
if let Some(orchestrator) = self.export_orchestrator.as_mut().filter(|o| o.has_pending_progress()) {
|
||||||
// Only log occasionally to avoid spam
|
// Only log occasionally to avoid spam
|
||||||
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
|
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
|
||||||
static POLL_COUNT: AtomicU32 = AtomicU32::new(0);
|
static POLL_COUNT: AtomicU32 = AtomicU32::new(0);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue