diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index 957d415..c164e8f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -113,10 +113,10 @@ struct ParallelExportState { video_progress_rx: Receiver, /// Audio progress channel audio_progress_rx: Receiver, - /// Video encoder thread handle - video_thread: std::thread::JoinHandle<()>, - /// Audio export thread handle - audio_thread: std::thread::JoinHandle<()>, + /// Video encoder thread handle (taken when the mux thread is spawned). + video_thread: Option>, + /// Audio export thread handle (taken when the mux thread is spawned). + audio_thread: Option>, /// Temporary video file path temp_video_path: PathBuf, /// Temporary audio file path @@ -127,6 +127,9 @@ struct ParallelExportState { video_progress: Option, /// Latest audio progress audio_progress: Option, + /// Result channel for the background mux. `Some` once muxing has started; the + /// mux runs off the UI thread so the app stays responsive during finalization. + mux_rx: Option>>, } impl ExportOrchestrator { @@ -220,47 +223,33 @@ impl ExportOrchestrator { parallel.audio_progress = Some(progress); } - // Check if both are complete - let video_complete = matches!(parallel.video_progress, Some(ExportProgress::Complete { .. })); - let audio_complete = matches!(parallel.audio_progress, Some(ExportProgress::Complete { .. })); - - if video_complete && audio_complete { - println!("🎬🎵 [PARALLEL] Both video and audio complete, starting mux"); - - // Take parallel state to extract file paths - let parallel_state = self.parallel_export.take().unwrap(); - - // Wait for threads to finish - parallel_state.video_thread.join().ok(); - parallel_state.audio_thread.join().ok(); - - // Start muxing - match Self::mux_video_and_audio( - ¶llel_state.temp_video_path, - ¶llel_state.temp_audio_path, - ¶llel_state.final_output_path, - ) { - Ok(()) => { + // If a background mux is already running, poll it without blocking the UI. + if parallel.mux_rx.is_some() { + match parallel.mux_rx.as_ref().unwrap().try_recv() { + Ok(Ok(())) => { println!("✅ [MUX] Muxing complete, cleaning up temp files"); - - // Clean up temp files - std::fs::remove_file(¶llel_state.temp_video_path).ok(); - std::fs::remove_file(¶llel_state.temp_audio_path).ok(); - - return Some(ExportProgress::Complete { - output_path: parallel_state.final_output_path, - }); + let state = self.parallel_export.take().unwrap(); + std::fs::remove_file(&state.temp_video_path).ok(); + std::fs::remove_file(&state.temp_audio_path).ok(); + return Some(ExportProgress::Complete { output_path: state.final_output_path }); } - Err(err) => { + Ok(Err(err)) => { println!("❌ [MUX] Muxing failed: {}", err); - return Some(ExportProgress::Error { - message: format!("Muxing failed: {}", err), - }); + self.parallel_export = None; + return Some(ExportProgress::Error { message: format!("Muxing failed: {}", err) }); + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + // Still muxing — keep the UI responsive and show finalizing state. + return Some(ExportProgress::Finalizing); + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.parallel_export = None; + return Some(ExportProgress::Error { message: "Mux thread terminated unexpectedly".to_string() }); } } } - // Check for errors + // Check for errors before completion. if let Some(ExportProgress::Error { ref message }) = parallel.video_progress { return Some(ExportProgress::Error { message: format!("Video: {}", message) }); } @@ -268,6 +257,30 @@ impl ExportOrchestrator { return Some(ExportProgress::Error { message: format!("Audio: {}", message) }); } + // Both streams done → spawn the mux on a background thread (the previous + // implementation muxed synchronously here on the UI thread, which froze the + // app for the whole re-mux pass after progress already hit 100%). + let video_complete = matches!(parallel.video_progress, Some(ExportProgress::Complete { .. })); + let audio_complete = matches!(parallel.audio_progress, Some(ExportProgress::Complete { .. })); + if video_complete && audio_complete { + println!("🎬🎵 [PARALLEL] Both video and audio complete, starting background mux"); + let video_thread = parallel.video_thread.take(); + let audio_thread = parallel.audio_thread.take(); + let video_path = parallel.temp_video_path.clone(); + let audio_path = parallel.temp_audio_path.clone(); + let output_path = parallel.final_output_path.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + // The export threads have signalled Complete; join is near-instant. + if let Some(t) = video_thread { t.join().ok(); } + if let Some(t) = audio_thread { t.join().ok(); } + let result = Self::mux_video_and_audio(&video_path, &audio_path, &output_path); + tx.send(result).ok(); + }); + parallel.mux_rx = Some(rx); + return Some(ExportProgress::Finalizing); + } + // Return combined progress match (¶llel.video_progress, ¶llel.audio_progress) { (Some(ExportProgress::FrameRendered { frame, total }), _) => { @@ -979,13 +992,14 @@ impl ExportOrchestrator { self.parallel_export = Some(ParallelExportState { video_progress_rx, audio_progress_rx, - video_thread, - audio_thread, + video_thread: Some(video_thread), + audio_thread: Some(audio_thread), temp_video_path, temp_audio_path, final_output_path: output_path, video_progress: None, audio_progress: None, + mux_rx: None, }); println!("🎬🎵 [PARALLEL EXPORT] Both threads spawned, ready for frames"); diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index 352791e..4f42d13 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -982,8 +982,18 @@ pub fn render_frame_to_rgba_hdr( // Set document time to the frame timestamp document.current_time = timestamp; - // Use identity transform for export (document coordinates = pixel coordinates) - let base_transform = Affine::IDENTITY; + // Scale the document to the export resolution. The core renderer bakes this + // base transform into every layer (vector scenes, raster and video layer + // transforms), so the whole stage scales up/down to fill the output. When the + // export size matches the document this is the identity. + let base_transform = if document.width > 0.0 && document.height > 0.0 { + Affine::scale_non_uniform( + width as f64 / document.width, + height as f64 / document.height, + ) + } else { + Affine::IDENTITY + }; // Render document for compositing (returns per-layer scenes) let composite_result = render_document_for_compositing( @@ -1189,8 +1199,18 @@ pub fn render_frame_to_gpu_rgba( // Set document time to the frame timestamp document.current_time = timestamp; - // Use identity transform for export (document coordinates = pixel coordinates) - let base_transform = Affine::IDENTITY; + // Scale the document to the export resolution. The core renderer bakes this + // base transform into every layer (vector scenes, raster and video layer + // transforms), so the whole stage scales up/down to fill the output. When the + // export size matches the document this is the identity. + let base_transform = if document.width > 0.0 && document.height > 0.0 { + Affine::scale_non_uniform( + width as f64 / document.width, + height as f64 / document.height, + ) + } else { + Affine::IDENTITY + }; // Render document for compositing (returns per-layer scenes) let composite_result = render_document_for_compositing( diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index a902d2a..e02bc60 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -5616,11 +5616,8 @@ impl eframe::App for EditorApp { // Close the progress dialog after a brief delay self.export_progress_dialog.close(); - // Send desktop notification - if let Err(e) = notifications::notify_export_complete(output_path) { - // Log but don't fail - notifications are non-critical - eprintln!("⚠️ Could not send desktop notification: {}", e); - } + // Send desktop notification (fire-and-forget; never blocks the UI) + notifications::notify_export_complete(output_path); } lightningbeam_core::export::ExportProgress::Error { ref message } => { eprintln!("❌ Export error: {}", message); @@ -5630,11 +5627,8 @@ impl eframe::App for EditorApp { ); // Keep the dialog open to show the error - // Send desktop notification for error - if let Err(e) = notifications::notify_export_error(message) { - // Log but don't fail - notifications are non-critical - eprintln!("⚠️ Could not send desktop notification: {}", e); - } + // Send desktop notification for error (fire-and-forget) + notifications::notify_export_error(message); } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/notifications.rs b/lightningbeam-ui/lightningbeam-editor/src/notifications.rs index 573626e..2222d58 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/notifications.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/notifications.rs @@ -3,52 +3,52 @@ use notify_rust::Notification; use std::path::Path; -/// Send a desktop notification for successful export -/// -/// # Arguments -/// * `output_path` - Path to the exported file -/// -/// # Returns -/// Ok(()) if notification sent successfully, Err with log message if failed -pub fn notify_export_complete(output_path: &Path) -> Result<(), String> { +// `notify_rust`'s `Notification::show()` is a SYNCHRONOUS D-Bus call. When no +// notification daemon is running it can block for the service-activation timeout +// (~25s) before failing, so these are fire-and-forget on a detached thread — the +// UI must never wait on them (e.g. they're triggered from the export-complete +// handler on the UI thread). + +/// Show a desktop notification for a successful export (fire-and-forget). +pub fn notify_export_complete(output_path: &Path) { let filename = output_path .file_name() .and_then(|n| n.to_str()) - .unwrap_or("unknown"); + .unwrap_or("unknown") + .to_string(); - Notification::new() - .summary("Export Complete") - .body(&format!("Successfully exported: {}", filename)) - .icon("dialog-information") // Standard icon name (freedesktop.org) - .timeout(5000) // 5 seconds - .show() - .map_err(|e| format!("Failed to show notification: {}", e))?; - - Ok(()) + std::thread::spawn(move || { + if let Err(e) = Notification::new() + .summary("Export Complete") + .body(&format!("Successfully exported: {}", filename)) + .icon("dialog-information") // Standard icon name (freedesktop.org) + .timeout(5000) // 5 seconds + .show() + { + eprintln!("⚠️ Could not send desktop notification: {}", e); + } + }); } -/// Send a desktop notification for export error -/// -/// # Arguments -/// * `error_message` - The error message to display -/// -/// # Returns -/// Ok(()) if notification sent successfully, Err with log message if failed -pub fn notify_export_error(error_message: &str) -> Result<(), String> { - // Truncate very long error messages - let truncated = if error_message.len() > 100 { - format!("{}...", &error_message[..97]) +/// Show a desktop notification for an export error (fire-and-forget). +pub fn notify_export_error(error_message: &str) { + // Truncate very long error messages (on a char boundary). + let truncated = if error_message.chars().count() > 100 { + let prefix: String = error_message.chars().take(97).collect(); + format!("{}...", prefix) } else { error_message.to_string() }; - Notification::new() - .summary("Export Failed") - .body(&truncated) - .icon("dialog-error") // Standard error icon - .timeout(10000) // 10 seconds for errors (longer to read) - .show() - .map_err(|e| format!("Failed to show notification: {}", e))?; - - Ok(()) + std::thread::spawn(move || { + if let Err(e) = Notification::new() + .summary("Export Failed") + .body(&truncated) + .icon("dialog-error") // Standard error icon + .timeout(10000) // 10 seconds for errors (longer to read) + .show() + { + eprintln!("⚠️ Could not send desktop notification: {}", e); + } + }); }