diff --git a/lightningbeam-ui/lightningbeam-core/src/export.rs b/lightningbeam-ui/lightningbeam-core/src/export.rs index 0905756..9c95045 100644 --- a/lightningbeam-ui/lightningbeam-core/src/export.rs +++ b/lightningbeam-ui/lightningbeam-core/src/export.rs @@ -390,6 +390,59 @@ impl VideoExportSettings { } } +// ── Image export ───────────────────────────────────────────────────────────── + +/// Image export formats (single-frame still image) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ImageFormat { + Png, + Jpeg, + WebP, +} + +impl ImageFormat { + pub fn name(self) -> &'static str { + match self { Self::Png => "PNG", Self::Jpeg => "JPEG", Self::WebP => "WebP" } + } + pub fn extension(self) -> &'static str { + match self { Self::Png => "png", Self::Jpeg => "jpg", Self::WebP => "webp" } + } + /// Whether quality (1–100) applies to this format. + pub fn has_quality(self) -> bool { matches!(self, Self::Jpeg | Self::WebP) } +} + +/// Settings for exporting a single frame as a still image. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageExportSettings { + pub format: ImageFormat, + /// Document time (seconds) of the frame to render. + pub time: f64, + /// Override width; None = use document canvas width. + pub width: Option, + /// Override height; None = use document canvas height. + pub height: Option, + /// Encode quality 1–100 (JPEG / WebP only). + pub quality: u8, + /// Preserve the alpha channel in the output (respect document background alpha). + /// When false, the image is composited onto an opaque background before encoding. + /// Only meaningful for formats that support alpha (PNG, WebP). + pub allow_transparency: bool, +} + +impl Default for ImageExportSettings { + fn default() -> Self { + Self { format: ImageFormat::Png, time: 0.0, width: None, height: None, quality: 90, allow_transparency: false } + } +} + +impl ImageExportSettings { + pub fn validate(&self) -> Result<(), String> { + if let Some(w) = self.width { if w == 0 { return Err("Width must be > 0".into()); } } + if let Some(h) = self.height { if h == 0 { return Err("Height must be > 0".into()); } } + Ok(()) + } +} + /// Progress updates during export #[derive(Debug, Clone)] pub enum ExportProgress { diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs index 9f90f0e..2038198 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs @@ -3,13 +3,29 @@ //! Provides a user interface for configuring and starting audio/video exports. use eframe::egui; -use lightningbeam_core::export::{AudioExportSettings, AudioFormat, VideoExportSettings, VideoCodec, VideoQuality}; +use lightningbeam_core::export::{ + AudioExportSettings, AudioFormat, + ImageExportSettings, ImageFormat, + VideoExportSettings, VideoCodec, VideoQuality, +}; use std::path::PathBuf; +/// Hint about document content, used to pick a smart default export type. +pub struct DocumentHint { + pub has_video: bool, + pub has_audio: bool, + pub has_raster: bool, + pub has_vector: bool, + pub current_time: f64, + pub doc_width: u32, + pub doc_height: u32, +} + /// Export type selection #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExportType { Audio, + Image, Video, } @@ -17,6 +33,7 @@ pub enum ExportType { #[derive(Debug, Clone)] pub enum ExportResult { AudioOnly(AudioExportSettings, PathBuf), + Image(ImageExportSettings, PathBuf), VideoOnly(VideoExportSettings, PathBuf), VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf), } @@ -32,6 +49,9 @@ pub struct ExportDialog { /// Audio export settings pub audio_settings: AudioExportSettings, + /// Image export settings + pub image_settings: ImageExportSettings, + /// Video export settings pub video_settings: VideoExportSettings, @@ -55,6 +75,12 @@ pub struct ExportDialog { /// Output directory pub output_dir: PathBuf, + + /// Project name from the last `open()` call — used to detect file switches. + current_project: String, + + /// Export type used the last time the user actually clicked Export for `current_project`. + last_export_type: Option, } impl Default for ExportDialog { @@ -71,6 +97,7 @@ impl Default for ExportDialog { open: false, export_type: ExportType::Audio, audio_settings: AudioExportSettings::standard_mp3(), + image_settings: ImageExportSettings::default(), video_settings: VideoExportSettings::default(), include_audio: true, output_path: None, @@ -78,23 +105,52 @@ impl Default for ExportDialog { show_advanced: false, selected_video_preset: 0, output_filename: String::new(), + current_project: String::new(), + last_export_type: None, output_dir: music_dir, } } } impl ExportDialog { - /// Open the dialog with default settings - pub fn open(&mut self, timeline_duration: f64, project_name: &str) { + /// Open the dialog with default settings, using `hint` to pick a smart default tab. + pub fn open(&mut self, timeline_duration: f64, project_name: &str, hint: &DocumentHint) { self.open = true; self.audio_settings.end_time = timeline_duration; self.video_settings.end_time = timeline_duration; + self.image_settings.time = hint.current_time; + // Propagate document dimensions as defaults (None means "use doc size"). + self.image_settings.width = None; + self.image_settings.height = None; self.error_message = None; - // Pre-populate filename from project name if not already set + // Determine export type: prefer the type used last time for this file, + // then fall back to document-content hints. + let same_project = self.current_project == project_name; + self.export_type = if same_project && self.last_export_type.is_some() { + self.last_export_type.unwrap() + } else { + let only_audio = hint.has_audio && !hint.has_video && !hint.has_raster && !hint.has_vector; + let only_raster = hint.has_raster && !hint.has_video && !hint.has_audio && !hint.has_vector; + if hint.has_video { ExportType::Video } + else if only_audio { ExportType::Audio } + else if only_raster { ExportType::Image } + else { self.export_type } // keep current as fallback + }; + self.current_project = project_name.to_owned(); + + // Pre-populate filename from project name if not already set. if self.output_filename.is_empty() || !self.output_filename.contains(project_name) { - let ext = self.audio_settings.format.extension(); - self.output_filename = format!("{}.{}", project_name, ext); + self.output_filename = format!("{}.{}", project_name, self.current_extension()); + } + } + + /// Extension for the currently selected export type. + fn current_extension(&self) -> &'static str { + match self.export_type { + ExportType::Audio => self.audio_settings.format.extension(), + ExportType::Image => self.image_settings.format.extension(), + ExportType::Video => self.video_settings.codec.container_format(), } } @@ -106,10 +162,7 @@ impl ExportDialog { /// Update the filename extension to match the current format fn update_filename_extension(&mut self) { - let ext = match self.export_type { - ExportType::Audio => self.audio_settings.format.extension(), - ExportType::Video => self.video_settings.codec.container_format(), - }; + let ext = self.current_extension(); // Replace extension in filename if let Some(dot_pos) = self.output_filename.rfind('.') { self.output_filename.truncate(dot_pos + 1); @@ -138,6 +191,7 @@ impl ExportDialog { let window_title = match self.export_type { ExportType::Audio => "Export Audio", + ExportType::Image => "Export Image", ExportType::Video => "Export Video", }; @@ -156,11 +210,14 @@ impl ExportDialog { // Export type selection (tabs) ui.horizontal(|ui| { - if ui.selectable_value(&mut self.export_type, ExportType::Audio, "Audio").clicked() { - self.update_filename_extension(); - } - if ui.selectable_value(&mut self.export_type, ExportType::Video, "Video").clicked() { - self.update_filename_extension(); + for (variant, label) in [ + (ExportType::Audio, "Audio"), + (ExportType::Image, "Image"), + (ExportType::Video, "Video"), + ] { + if ui.selectable_value(&mut self.export_type, variant, label).clicked() { + self.update_filename_extension(); + } } }); @@ -171,6 +228,7 @@ impl ExportDialog { // Basic settings match self.export_type { ExportType::Audio => self.render_audio_basic(ui), + ExportType::Image => self.render_image_settings(ui), ExportType::Video => self.render_video_basic(ui), } @@ -188,6 +246,7 @@ impl ExportDialog { ui.add_space(8.0); match self.export_type { ExportType::Audio => self.render_audio_advanced(ui), + ExportType::Image => self.render_image_advanced(ui), ExportType::Video => self.render_video_advanced(ui), } } @@ -260,6 +319,62 @@ impl ExportDialog { }); } + /// Render basic image export settings (format, quality, transparency). + fn render_image_settings(&mut self, ui: &mut egui::Ui) { + // Format + ui.horizontal(|ui| { + ui.label("Format:"); + let prev = self.image_settings.format; + egui::ComboBox::from_id_salt("image_format") + .selected_text(self.image_settings.format.name()) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.image_settings.format, ImageFormat::Png, "PNG"); + ui.selectable_value(&mut self.image_settings.format, ImageFormat::Jpeg, "JPEG"); + ui.selectable_value(&mut self.image_settings.format, ImageFormat::WebP, "WebP"); + }); + if self.image_settings.format != prev { + self.update_filename_extension(); + } + }); + + // Quality (JPEG / WebP only) + if self.image_settings.format.has_quality() { + ui.horizontal(|ui| { + ui.label("Quality:"); + ui.add(egui::Slider::new(&mut self.image_settings.quality, 1..=100)); + }); + } + + // Transparency (PNG / WebP only — JPEG has no alpha) + if self.image_settings.format != ImageFormat::Jpeg { + ui.checkbox(&mut self.image_settings.allow_transparency, "Allow transparency"); + } + } + + /// Render advanced image export settings (time, resolution override). + fn render_image_advanced(&mut self, ui: &mut egui::Ui) { + // Time (which frame to export) + ui.horizontal(|ui| { + ui.label("Time:"); + ui.add(egui::DragValue::new(&mut self.image_settings.time) + .speed(0.01) + .range(0.0..=f64::MAX) + .suffix(" s")); + }); + + // Resolution override (None = use document size; 0 means "use doc size") + ui.horizontal(|ui| { + ui.label("Size:"); + let mut w = self.image_settings.width.unwrap_or(0); + let mut h = self.image_settings.height.unwrap_or(0); + let changed_w = ui.add(egui::DragValue::new(&mut w).range(0..=u32::MAX).prefix("W ")).changed(); + let changed_h = ui.add(egui::DragValue::new(&mut h).range(0..=u32::MAX).prefix("H ")).changed(); + if changed_w { self.image_settings.width = if w == 0 { None } else { Some(w) }; } + if changed_h { self.image_settings.height = if h == 0 { None } else { Some(h) }; } + ui.weak("(0 = document size)"); + }); + } + /// Render advanced audio settings (sample rate, channels, bit depth, bitrate, time range) fn render_audio_advanced(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { @@ -419,6 +534,7 @@ impl ExportDialog { fn render_time_range(&mut self, ui: &mut egui::Ui) { let (start_time, end_time) = match self.export_type { ExportType::Audio => (&mut self.audio_settings.start_time, &mut self.audio_settings.end_time), + ExportType::Image => return, // image uses a single time field, not a range ExportType::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time), }; @@ -440,26 +556,35 @@ impl ExportDialog { ui.label(format!("Duration: {:.2} seconds", duration)); } - /// Render output file selection UI + /// Render output file selection UI — single OS save-file dialog. fn render_output_selection(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { + // Show the current path (truncated if long). + let full_path = self.build_output_path(); + let path_str = full_path.display().to_string(); ui.label("Save to:"); - let dir_text = self.output_dir.display().to_string(); - ui.label(&dir_text); - if ui.button("Change...").clicked() { - if let Some(dir) = rfd::FileDialog::new() - .set_directory(&self.output_dir) - .pick_folder() - { - self.output_dir = dir; - } - } + ui.add(egui::Label::new( + egui::RichText::new(&path_str).weak() + ).truncate()); }); - ui.horizontal(|ui| { - ui.label("Filename:"); - ui.text_edit_singleline(&mut self.output_filename); - }); + if ui.button("Choose location...").clicked() { + let ext = self.current_extension(); + let mut dialog = rfd::FileDialog::new() + .set_directory(&self.output_dir) + .set_file_name(&self.output_filename) + .add_filter(ext.to_uppercase(), &[ext]); + if let Some(path) = dialog.save_file() { + if let Some(dir) = path.parent() { + self.output_dir = dir.to_path_buf(); + } + if let Some(name) = path.file_name() { + self.output_filename = name.to_string_lossy().into_owned(); + // Ensure the extension matches the selected format. + self.update_filename_extension(); + } + } + } } /// Handle export button click @@ -471,7 +596,17 @@ impl ExportDialog { let output_path = self.output_path.clone().unwrap(); + // Remember this export type for next time this file is opened. + self.last_export_type = Some(self.export_type); + let result = match self.export_type { + ExportType::Image => { + if let Err(err) = self.image_settings.validate() { + self.error_message = Some(err); + return None; + } + Some(ExportResult::Image(self.image_settings.clone(), output_path)) + } ExportType::Audio => { // Validate audio settings if let Err(err) = self.audio_settings.validate() { diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/image_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/image_exporter.rs new file mode 100644 index 0000000..9352bfe --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/export/image_exporter.rs @@ -0,0 +1,70 @@ +//! Image encoding — save raw RGBA bytes as PNG / JPEG / WebP. + +use lightningbeam_core::export::ImageFormat; +use std::path::Path; + +/// Encode `pixels` (raw RGBA8, top-left origin) and write to `path`. +/// +/// * `allow_transparency` — when true the alpha channel is preserved (PNG/WebP); +/// when false each pixel is composited onto black before encoding. +pub fn save_rgba_image( + pixels: &[u8], + width: u32, + height: u32, + format: ImageFormat, + quality: u8, + allow_transparency: bool, + path: &Path, +) -> Result<(), String> { + use image::{ImageBuffer, Rgba}; + + let img = ImageBuffer::, _>::from_raw(width, height, pixels.to_vec()) + .ok_or_else(|| "Pixel buffer size mismatch".to_string())?; + + match format { + ImageFormat::Png => { + if allow_transparency { + img.save(path).map_err(|e| format!("PNG save failed: {e}")) + } else { + let flat = flatten_alpha(img); + flat.save(path).map_err(|e| format!("PNG save failed: {e}")) + } + } + ImageFormat::Jpeg => { + use image::codecs::jpeg::JpegEncoder; + use image::DynamicImage; + use std::fs::File; + use std::io::BufWriter; + + // Flatten alpha onto black before JPEG encoding (JPEG has no alpha). + let flat = flatten_alpha(img); + let rgb_img = DynamicImage::ImageRgb8(flat).to_rgb8(); + let file = File::create(path).map_err(|e| format!("Cannot create file: {e}"))?; + let writer = BufWriter::new(file); + let mut encoder = JpegEncoder::new_with_quality(writer, quality); + encoder.encode_image(&rgb_img).map_err(|e| format!("JPEG encode failed: {e}")) + } + ImageFormat::WebP => { + if allow_transparency { + img.save(path).map_err(|e| format!("WebP save failed: {e}")) + } else { + let flat = flatten_alpha(img); + flat.save(path).map_err(|e| format!("WebP save failed: {e}")) + } + } + } +} + +/// Composite RGBA pixels onto an opaque black background, returning an RGB image. +fn flatten_alpha(img: image::ImageBuffer, Vec>) -> image::ImageBuffer, Vec> { + use image::{ImageBuffer, Rgb}; + ImageBuffer::from_fn(img.width(), img.height(), |x, y| { + let p = img.get_pixel(x, y); + let a = p[3] as f32 / 255.0; + Rgb([ + (p[0] as f32 * a) as u8, + (p[1] as f32 * a) as u8, + (p[2] as f32 * a) as u8, + ]) + }) +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index 273ed64..6e4c923 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -5,12 +5,13 @@ pub mod audio_exporter; pub mod dialog; +pub mod image_exporter; pub mod video_exporter; pub mod readback_pipeline; pub mod perf_metrics; pub mod cpu_yuv_converter; -use lightningbeam_core::export::{AudioExportSettings, VideoExportSettings, ExportProgress}; +use lightningbeam_core::export::{AudioExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress}; use lightningbeam_core::document::Document; use lightningbeam_core::renderer::ImageCache; use lightningbeam_core::video::VideoManager; @@ -66,6 +67,25 @@ pub struct VideoExportState { perf_metrics: Option, } +/// State for a single-frame image export (runs on the GPU render thread, one frame per update). +pub struct ImageExportState { + pub settings: ImageExportSettings, + pub output_path: PathBuf, + /// Resolved pixel dimensions (after applying any width/height overrides). + pub width: u32, + pub height: u32, + /// True once rendering has been submitted; the next call reads back and encodes. + pub rendered: bool, + /// GPU resources allocated on the first render call. + pub gpu_resources: Option, + /// Output RGBA texture — kept separate from gpu_resources to avoid split-borrow issues. + pub output_texture: Option, + /// View for output_texture. + pub output_texture_view: Option, + /// Staging buffer for synchronous GPU→CPU readback. + pub staging_buffer: Option, +} + /// Export orchestrator that manages the export process pub struct ExportOrchestrator { /// Channel for receiving progress updates (video or audio-only export) @@ -82,6 +102,9 @@ pub struct ExportOrchestrator { /// Parallel audio+video export state parallel_export: Option, + + /// Single-frame image export state + image_state: Option, } /// State for parallel audio+video export @@ -115,6 +138,7 @@ impl ExportOrchestrator { cancel_flag: Arc::new(AtomicBool::new(false)), video_state: None, parallel_export: None, + image_state: None, } } @@ -446,12 +470,8 @@ impl ExportOrchestrator { /// Check if an export is in progress pub fn is_exporting(&self) -> bool { - // Check parallel export first - if self.parallel_export.is_some() { - return true; - } - - // Check single export + if self.parallel_export.is_some() { return true; } + if self.image_state.is_some() { return true; } if let Some(handle) = &self.thread_handle { !handle.is_finished() } else { @@ -459,6 +479,168 @@ impl ExportOrchestrator { } } + /// Enqueue a single-frame image export. Call `render_image_frame()` from the + /// egui update loop (where the wgpu device/queue are available) to complete it. + pub fn start_image_export( + &mut self, + settings: ImageExportSettings, + output_path: PathBuf, + doc_width: u32, + doc_height: u32, + ) { + self.cancel_flag.store(false, Ordering::Relaxed); + let width = settings.width.unwrap_or(doc_width).max(1); + let height = settings.height.unwrap_or(doc_height).max(1); + self.image_state = Some(ImageExportState { + settings, + output_path, + width, + height, + rendered: false, + gpu_resources: None, + output_texture: None, + output_texture_view: None, + staging_buffer: None, + }); + } + + /// Drive the single-frame image export. Returns `Ok(true)` when done (success or + /// cancelled), `Ok(false)` if another call is needed next frame. + pub fn render_image_frame( + &mut self, + document: &mut Document, + device: &wgpu::Device, + queue: &wgpu::Queue, + renderer: &mut vello::Renderer, + image_cache: &mut ImageCache, + video_manager: &Arc>, + ) -> Result { + if self.cancel_flag.load(Ordering::Relaxed) { + self.image_state = None; + return Ok(true); + } + + let state = match self.image_state.as_mut() { + Some(s) => s, + None => return Ok(true), + }; + + if !state.rendered { + // ── First call: render the frame to the GPU output texture ──────── + let w = state.width; + let h = state.height; + + if state.gpu_resources.is_none() { + state.gpu_resources = Some(video_exporter::ExportGpuResources::new(device, w, h)); + } + if state.output_texture.is_none() { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("image_export_output"), + size: wgpu::Extent3d { width: w, height: h, 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::COPY_SRC, + view_formats: &[], + }); + state.output_texture_view = Some(tex.create_view(&wgpu::TextureViewDescriptor::default())); + state.output_texture = Some(tex); + } + + // Borrow separately to avoid a split-borrow conflict (gpu mutably, view immutably). + let gpu = state.gpu_resources.as_mut().unwrap(); + let output_view = state.output_texture_view.as_ref().unwrap(); + + let mut encoder = video_exporter::render_frame_to_gpu_rgba( + document, + state.settings.time, + w, h, + device, queue, renderer, image_cache, video_manager, + gpu, + output_view, + )?; + queue.submit(Some(encoder.finish())); + + // Create a staging buffer for synchronous readback. + // wgpu requires bytes_per_row to be a multiple of 256. + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let bytes_per_row = (w * 4 + align - 1) / align * align; + let staging = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("image_export_staging"), + size: (bytes_per_row * h) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let mut copy_enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("image_export_copy"), + }); + let output_tex = state.output_texture.as_ref().unwrap(); + copy_enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: output_tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &staging, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + queue.submit(Some(copy_enc.finish())); + + state.staging_buffer = Some(staging); + state.rendered = true; + return Ok(false); // Come back next frame to read the result. + } + + // ── Second call: map the staging buffer, encode, and save ───────────── + let staging = match state.staging_buffer.as_ref() { + Some(b) => b, + None => { self.image_state = None; return Ok(true); } + }; + + // Map synchronously. + let slice = staging.slice(..); + slice.map_async(wgpu::MapMode::Read, |_| {}); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); + + let w = state.width; + let h = state.height; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let bytes_per_row = (w * 4 + align - 1) / align * align; + + let pixels: Vec = { + let mapped = slice.get_mapped_range(); + // Strip row padding: copy only w*4 bytes from each bytes_per_row-wide row. + let mut out = Vec::with_capacity((w * h * 4) as usize); + for row in 0..h { + let start = (row * bytes_per_row) as usize; + out.extend_from_slice(&mapped[start..start + (w * 4) as usize]); + } + out + }; + staging.unmap(); + + let result = image_exporter::save_rgba_image( + &pixels, w, h, + state.settings.format, + state.settings.quality, + state.settings.allow_transparency, + &state.output_path, + ); + + self.image_state = None; + result.map(|_| true) + } + /// Wait for the export to complete /// /// This blocks until the export thread finishes. diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index b520bc2..8630c07 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -2869,14 +2869,42 @@ impl EditorApp { } MenuAction::Export => { println!("Menu: Export"); - // Open export dialog with calculated timeline endpoint let timeline_endpoint = self.action_executor.document().calculate_timeline_endpoint(); - // Derive project name from the .beam file path, falling back to document name let project_name = self.current_file_path.as_ref() .and_then(|p| p.file_stem()) .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| self.action_executor.document().name.clone()); - self.export_dialog.open(timeline_endpoint, &project_name); + + // Build document hint for smart export-type defaulting. + let hint = { + use lightningbeam_core::layer::AnyLayer; + use export::dialog::DocumentHint; + fn scan(layers: &[AnyLayer], hint: &mut DocumentHint) { + for l in layers { + match l { + AnyLayer::Video(_) => hint.has_video = true, + AnyLayer::Audio(_) => hint.has_audio = true, + AnyLayer::Raster(_) => hint.has_raster = true, + AnyLayer::Vector(_) | AnyLayer::Effect(_) => hint.has_vector = true, + AnyLayer::Group(g) => scan(&g.children, hint), + } + } + } + let doc = self.action_executor.document(); + let mut h = DocumentHint { + has_video: false, + has_audio: false, + has_raster: false, + has_vector: false, + current_time: doc.current_time, + doc_width: doc.width as u32, + doc_height: doc.height as u32, + }; + scan(&doc.root.children, &mut h); + h + }; + + self.export_dialog.open(timeline_endpoint, &project_name, &hint); } MenuAction::Quit => { println!("Menu: Quit"); @@ -5180,6 +5208,17 @@ impl eframe::App for EditorApp { let export_started = if let Some(orchestrator) = &mut self.export_orchestrator { match export_result { + ExportResult::Image(settings, output_path) => { + println!("🖼 [MAIN] Starting image export: {}", output_path.display()); + let doc = self.action_executor.document(); + orchestrator.start_image_export( + settings, + output_path, + doc.width as u32, + doc.height as u32, + ); + false // image export is silent (no progress dialog) + } ExportResult::AudioOnly(settings, output_path) => { println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display()); @@ -5290,6 +5329,7 @@ impl eframe::App for EditorApp { let mut temp_image_cache = lightningbeam_core::renderer::ImageCache::new(); if let Some(renderer) = &mut temp_renderer { + // Drive incremental video export. if let Ok(has_more) = orchestrator.render_next_video_frame( self.action_executor.document_mut(), device, @@ -5299,10 +5339,23 @@ impl eframe::App for EditorApp { &self.video_manager, ) { if has_more { - // More frames to render - request repaint for next frame ctx.request_repaint(); } } + + // Drive single-frame image export (two-frame async: render then readback). + match orchestrator.render_image_frame( + self.action_executor.document_mut(), + device, + queue, + renderer, + &mut temp_image_cache, + &self.video_manager, + ) { + Ok(false) => { ctx.request_repaint(); } // readback pending + Ok(true) => {} // done or cancelled + Err(e) => { eprintln!("Image export failed: {e}"); } + } } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index a8f0fd2..707078b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -11803,9 +11803,17 @@ impl PaneRenderer for StagePane { shared.action_executor.document().get_layer(&id) }).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_))); - if is_raster_paint { + // Only override the cursor when no higher-order layer (e.g. a modal dialog) + // is covering the canvas at this position. + let canvas_is_topmost = ui.ctx() + .layer_id_at(pos) + .map_or(true, |l| l == ui.layer_id()); + + if is_raster_paint && canvas_is_topmost { ui.ctx().set_cursor_icon(egui::CursorIcon::None); self.draw_brush_cursor(ui, rect, pos, shared); + } else if is_raster_paint { + // A modal is covering the canvas — let the system cursor show normally. } else { crate::custom_cursor::set( ui.ctx(),