diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index 1f6c4ca..48824a8 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -3641,6 +3641,7 @@ dependencies = [ "egui_extras", "egui_node_graph2", "ffmpeg-next", + "gif", "gpu-video-encoder", "half", "image", diff --git a/lightningbeam-ui/Cargo.toml b/lightningbeam-ui/Cargo.toml index b20938d..1b7ea56 100644 --- a/lightningbeam-ui/Cargo.toml +++ b/lightningbeam-ui/Cargo.toml @@ -83,6 +83,15 @@ opt-level = 2 [profile.dev.package.cpal] opt-level = 2 +# GIF export: NeuQuant palette quantization is tight numeric loops that are punishingly slow +# unoptimized (10–30× in debug). Optimize the encoder crates even in dev builds. +[profile.dev.package.gif] +opt-level = 3 +[profile.dev.package.color_quant] +opt-level = 3 +[profile.dev.package.weezl] +opt-level = 3 + # Use local egui fork with ibus/Wayland text input fix [patch.crates-io] egui = { path = "../../egui-fork/crates/egui" } diff --git a/lightningbeam-ui/lightningbeam-core/src/export.rs b/lightningbeam-ui/lightningbeam-core/src/export.rs index 6e62d50..0f773ec 100644 --- a/lightningbeam-ui/lightningbeam-core/src/export.rs +++ b/lightningbeam-ui/lightningbeam-core/src/export.rs @@ -543,6 +543,82 @@ impl ImageExportSettings { } } +// ── Animated GIF export ────────────────────────────────────────────────────── + +/// Settings for exporting an animated GIF (multi-frame, palette-quantized, no audio). +/// +/// GIF stores a per-frame delay in centiseconds (1/100 s), so effective frame rate is quantized to +/// whole centiseconds — [`Self::frame_delay_ms`] rounds accordingly and the dialog offers sensible +/// GIF rates. Each frame is quantized to a 256-color palette by the encoder. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GifExportSettings { + /// Output width in pixels (None = use document width). + pub width: Option, + /// Output height in pixels (None = use document height). + pub height: Option, + /// Frame rate (fps). Snapped to whole-centisecond delays at encode time. + pub framerate: f64, + /// Loop the animation forever (GIF `NETSCAPE2.0` infinite loop). False = play once. + pub loop_forever: bool, + /// Preserve full alpha as GIF 1-bit transparency (pixels below the alpha threshold become the + /// transparent index). When false, frames are composited onto an opaque background first. + pub transparency: bool, + /// How the document is fit into the output frame when aspect ratios differ (default Letterbox). + #[serde(default)] + pub fit: ExportFitMode, + /// Start time in seconds. + pub start_time: f64, + /// End time in seconds. + pub end_time: f64, +} + +impl Default for GifExportSettings { + fn default() -> Self { + Self { + width: None, + height: None, + framerate: 15.0, + loop_forever: true, + transparency: false, + fit: ExportFitMode::Letterbox, + start_time: 0.0, + end_time: 5.0, + } + } +} + +impl GifExportSettings { + 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()); } } + if self.framerate <= 0.0 { + return Err("Framerate must be greater than 0".into()); + } + if self.start_time < 0.0 { + return Err("Start time cannot be negative".into()); + } + if self.end_time <= self.start_time { + return Err("End time must be greater than start time".into()); + } + Ok(()) + } + + /// Duration in seconds. + pub fn duration(&self) -> f64 { self.end_time - self.start_time } + + /// Total number of frames to render. + pub fn total_frames(&self) -> usize { + (self.duration() * self.framerate).ceil().max(1.0) as usize + } + + /// Per-frame delay in milliseconds, from the framerate (GIF stores this at centisecond + /// resolution, so the effective rate is snapped to the nearest 10 ms, min 10 ms). + pub fn frame_delay_ms(&self) -> u32 { + let ms = 1000.0 / self.framerate; + ((ms / 10.0).round().max(1.0) * 10.0) as u32 + } +} + /// Progress updates during export #[derive(Debug, Clone)] pub enum ExportProgress { @@ -728,6 +804,33 @@ mod tests { assert_eq!(settings.total_frames(), 300); } + #[test] + fn test_gif_frame_delay_and_frames() { + // Frame rates that map to clean centisecond delays. + let mk = |fps: f64| GifExportSettings { framerate: fps, ..Default::default() }; + assert_eq!(mk(10.0).frame_delay_ms(), 100); // 100 ms + assert_eq!(mk(20.0).frame_delay_ms(), 50); // 50 ms + assert_eq!(mk(50.0).frame_delay_ms(), 20); // 20 ms + // 15 fps = 66.6 ms rounds to 70 ms (7 cs); 25 fps = 40 ms. + assert_eq!(mk(15.0).frame_delay_ms(), 70); + assert_eq!(mk(25.0).frame_delay_ms(), 40); + // Very high fps clamps to the 10 ms minimum (1 cs). + assert_eq!(mk(1000.0).frame_delay_ms(), 10); + + let settings = GifExportSettings { framerate: 20.0, start_time: 0.0, end_time: 3.0, ..Default::default() }; + assert_eq!(settings.total_frames(), 60); + } + + #[test] + fn test_gif_validate() { + let mut s = GifExportSettings::default(); + assert!(s.validate().is_ok()); + s.framerate = 0.0; + assert!(s.validate().is_err()); + s = GifExportSettings { start_time: 5.0, end_time: 2.0, ..Default::default() }; + assert!(s.validate().is_err()); + } + #[test] fn test_export_progress_percentage() { let progress = ExportProgress::FrameRendered { frame: 50, total: 100 }; diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index c80d068..77019b4 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -41,6 +41,10 @@ serde_json = { workspace = true } # Image loading image = { workspace = true } +# Animated GIF encoding — used directly (not just via `image`) so per-frame NeuQuant palette +# quantization can be parallelized across a worker pool. Pinned to the version `image` already +# resolves (0.13), so this adds no new transitive graph. +gif = "0.13" resvg = { workspace = true } tiny-skia = "0.11" diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs index 7438836..17fcee5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs @@ -5,6 +5,7 @@ use eframe::egui; use lightningbeam_core::export::{ AudioExportSettings, AudioFormat, + GifExportSettings, ImageExportSettings, ImageFormat, VideoExportSettings, VideoCodec, VideoQuality, ColorRange, }; @@ -25,6 +26,8 @@ pub enum ExportType { Audio, Image, Video, + /// Animated GIF (multi-frame, palette-quantized, no audio). + Gif, /// Vector-only SVG of the current frame (lossless; raster/video layers skipped). Svg, } @@ -36,6 +39,8 @@ pub enum ExportResult { Image(ImageExportSettings, PathBuf), VideoOnly(VideoExportSettings, PathBuf), VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf), + /// Animated GIF export. + Gif(GifExportSettings, PathBuf), /// SVG of vector layers at the given document time. Svg(f64, PathBuf), } @@ -57,6 +62,9 @@ pub struct ExportDialog { /// Video export settings pub video_settings: VideoExportSettings, + /// Animated GIF export settings + pub gif_settings: GifExportSettings, + /// Include audio with video? pub include_audio: bool, @@ -104,6 +112,7 @@ impl Default for ExportDialog { audio_settings: AudioExportSettings::standard_mp3(), image_settings: ImageExportSettings::default(), video_settings: VideoExportSettings::default(), + gif_settings: GifExportSettings::default(), include_audio: true, output_path: None, error_message: None, @@ -124,6 +133,7 @@ impl ExportDialog { self.open = true; self.audio_settings.end_time = timeline_duration; self.video_settings.end_time = timeline_duration; + self.gif_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; @@ -160,6 +170,7 @@ impl ExportDialog { ExportType::Audio => self.audio_settings.format.extension(), ExportType::Image => self.image_settings.format.extension(), ExportType::Video => self.video_settings.codec.container_format(), + ExportType::Gif => "gif", ExportType::Svg => "svg", } } @@ -203,6 +214,7 @@ impl ExportDialog { ExportType::Audio => "Export Audio", ExportType::Image => "Export Image", ExportType::Video => "Export Video", + ExportType::Gif => "Export GIF", ExportType::Svg => "Export SVG", }; @@ -225,6 +237,7 @@ impl ExportDialog { (ExportType::Audio, "Audio"), (ExportType::Image, "Image"), (ExportType::Video, "Video"), + (ExportType::Gif, "GIF"), (ExportType::Svg, "SVG"), ] { if ui.selectable_value(&mut self.export_type, variant, label).clicked() { @@ -242,6 +255,7 @@ impl ExportDialog { ExportType::Audio => self.render_audio_basic(ui), ExportType::Image => self.render_image_settings(ui), ExportType::Video => self.render_video_basic(ui), + ExportType::Gif => self.render_gif_basic(ui), ExportType::Svg => self.render_svg_settings(ui), } @@ -261,6 +275,7 @@ impl ExportDialog { ExportType::Audio => self.render_audio_advanced(ui), ExportType::Image => self.render_image_advanced(ui), ExportType::Video => self.render_video_advanced(ui), + ExportType::Gif => self.render_gif_advanced(ui), ExportType::Svg => {} // SVG has no advanced settings } } @@ -614,12 +629,65 @@ impl ExportDialog { self.render_time_range(ui); } + /// GIF frame-rate presets (fps). GIF delays are centisecond-quantized, so these map to clean + /// per-frame delays (10/15/20/25/50 fps → 100/70/50/40/20 ms after rounding). + const GIF_FPS: &'static [f64] = &[10.0, 15.0, 20.0, 25.0, 50.0]; + + /// Render basic GIF settings (frame rate + loop). + fn render_gif_basic(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.label("Frame rate:"); + egui::ComboBox::from_id_salt("gif_fps") + .selected_text(format!("{} fps", self.gif_settings.framerate as u32)) + .show_ui(ui, |ui| { + for &fps in Self::GIF_FPS { + ui.selectable_value(&mut self.gif_settings.framerate, fps, format!("{} fps", fps as u32)); + } + }); + }); + + ui.checkbox(&mut self.gif_settings.loop_forever, "Loop forever"); + + ui.add_space(8.0); + self.render_time_range(ui); + } + + /// Render advanced GIF settings (resolution, fit, transparency). + fn render_gif_advanced(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.label("Size:"); + let mut w = self.gif_settings.width.unwrap_or(0); + let mut h = self.gif_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.gif_settings.width = if w == 0 { None } else { Some(w) }; } + if changed_h { self.gif_settings.height = if h == 0 { None } else { Some(h) }; } + ui.weak("(0 = document size)"); + }); + + ui.horizontal(|ui| { + use lightningbeam_core::export::ExportFitMode; + ui.label("Fit:"); + egui::ComboBox::from_id_salt("gif_fit_mode") + .selected_text(self.gif_settings.fit.name()) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.gif_settings.fit, ExportFitMode::Letterbox, ExportFitMode::Letterbox.name()); + ui.selectable_value(&mut self.gif_settings.fit, ExportFitMode::Crop, ExportFitMode::Crop.name()); + ui.selectable_value(&mut self.gif_settings.fit, ExportFitMode::Stretch, ExportFitMode::Stretch.name()); + }); + }); + + ui.checkbox(&mut self.gif_settings.transparency, "Preserve transparency (1-bit)"); + ui.label(egui::RichText::new("GIF supports only on/off transparency; semi-transparent pixels are keyed out.").weak().small()); + } + /// Render time range UI (common to both audio and video) 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 | ExportType::Svg => return, // single time field, not a range ExportType::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time), + ExportType::Gif => (&mut self.gif_settings.start_time, &mut self.gif_settings.end_time), }; ui.horizontal(|ui| { @@ -693,6 +761,13 @@ impl ExportDialog { Some(ExportResult::Image(self.image_settings.clone(), output_path)) } ExportType::Svg => Some(ExportResult::Svg(self.image_settings.time, output_path)), + ExportType::Gif => { + if let Err(err) = self.gif_settings.validate() { + self.error_message = Some(err); + return None; + } + Some(ExportResult::Gif(self.gif_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/gif_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/gif_exporter.rs new file mode 100644 index 0000000..51c95b7 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/export/gif_exporter.rs @@ -0,0 +1,196 @@ +//! Animated GIF encoding. +//! +//! Palette-quantizes a stream of RGBA8 frames and writes them to a `.gif`. The expensive part — +//! per-frame NeuQuant 256-color quantization — is embarrassingly parallel (each frame gets its own +//! local palette), so it's fanned out across a worker pool. A single writer thread collects the +//! quantized frames, reorders them, and LZW-encodes them to the file in sequence. +//! +//! Pipeline (all off the UI thread): +//! ```text +//! UI render thread ──RGBA──▶ coordinator ──round-robin──▶ N quantizer workers +//! │ (idx, gif::Frame) +//! ▼ +//! writer thread ──▶ .gif +//! ``` +//! Rendering + readback happen on the UI thread (see `render_next_gif_frame`); this module owns +//! everything after a raw RGBA frame arrives. + +use lightningbeam_core::export::ExportProgress; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::sync::Arc; + +/// Message from the UI (render) thread to the GIF encoder coordinator. +pub enum GifFrameMessage { + /// One RGBA8 frame (top-left origin, tightly packed `width*height*4` bytes). + Frame { frame_num: usize, pixels: Vec }, + /// All frames have been sent. + Done, +} + +/// gif crate quantization speed (1 = slowest/best, 30 = fastest/worst). 10 balances palette quality +/// against per-frame cost; the parallelism below is what actually recovers the wall-clock. +const QUANT_SPEED: i32 = 10; + +/// Run the GIF encoder pipeline. Receives RGBA8 frames from `frame_rx`, quantizes them in parallel, +/// and writes the ordered result to `output_path`, reporting progress. `transparency == false` +/// composites each frame onto opaque black first (GIF's 1-bit transparency would otherwise key out +/// semi-transparent pixels). +#[allow(clippy::too_many_arguments)] +pub fn run_gif_encoder( + frame_rx: Receiver, + output_path: PathBuf, + width: u32, + height: u32, + total_frames: usize, + delay_ms: u32, + loop_forever: bool, + transparency: bool, + progress_tx: Sender, + cancel_flag: Arc, +) { + let _ = progress_tx.send(ExportProgress::Started { total_frames }); + + let delay_cs = ((delay_ms / 10).max(1)) as u16; + let expected_len = (width as usize) * (height as usize) * 4; + + // One quantizer worker per spare core (leave one for the UI render thread), capped so we don't + // spawn absurdly many for short exports. + let n_workers = std::thread::available_parallelism() + .map(|n| n.get().saturating_sub(1)) + .unwrap_or(1) + .clamp(1, 8); + + // Per-worker input channels (coordinator dispatches round-robin) + one shared result channel. + let mut worker_txs: Vec)>> = Vec::with_capacity(n_workers); + let (result_tx, result_rx) = channel::<(usize, gif::Frame<'static>)>(); + let mut worker_handles = Vec::with_capacity(n_workers); + + for _ in 0..n_workers { + let (wtx, wrx) = channel::<(usize, Vec)>(); + worker_txs.push(wtx); + let result_tx = result_tx.clone(); + let cancel = Arc::clone(&cancel_flag); + worker_handles.push(std::thread::spawn(move || { + while let Ok((idx, mut pixels)) = wrx.recv() { + if cancel.load(Ordering::Relaxed) { + break; + } + // NeuQuant local-palette quantization (the expensive step). `from_rgba_speed` uses + // the RGBA buffer as scratch, so it's fine that we own `pixels` here. + let mut frame = + gif::Frame::from_rgba_speed(width as u16, height as u16, &mut pixels, QUANT_SPEED); + frame.delay = delay_cs; + if result_tx.send((idx, frame)).is_err() { + break; // writer gone + } + } + })); + } + drop(result_tx); // only the workers hold senders now; writer's rx ends when they all finish + + // Writer thread: order frames by index and LZW-encode them sequentially. + let writer_progress = progress_tx.clone(); + let writer_cancel = Arc::clone(&cancel_flag); + let writer_output = output_path.clone(); + let writer = std::thread::spawn(move || -> Result<(), String> { + let file = std::fs::File::create(&writer_output) + .map_err(|e| format!("Failed to create GIF file: {e}"))?; + let mut buf = std::io::BufWriter::new(file); + let mut encoder = gif::Encoder::new(&mut buf, width as u16, height as u16, &[]) + .map_err(|e| format!("GIF encoder init failed: {e}"))?; + if loop_forever { + encoder + .set_repeat(gif::Repeat::Infinite) + .map_err(|e| format!("GIF set_repeat failed: {e}"))?; + } + + // Frames may arrive out of order; hold stragglers until their turn. + let mut pending: HashMap> = HashMap::new(); + let mut next = 0usize; + let mut written = 0usize; + + while let Ok((idx, frame)) = result_rx.recv() { + if writer_cancel.load(Ordering::Relaxed) { + break; + } + pending.insert(idx, frame); + while let Some(f) = pending.remove(&next) { + encoder + .write_frame(&f) + .map_err(|e| format!("GIF write_frame failed: {e}"))?; + next += 1; + written += 1; + let _ = writer_progress.send(ExportProgress::FrameRendered { + frame: written, + total: total_frames, + }); + } + } + // Encoder/BufWriter flush on drop. + Ok(()) + }); + + // Coordinator: pull RGBA frames from the UI thread and dispatch round-robin to the workers. + let mut dispatched = 0usize; + let mut fatal: Option = None; + loop { + match frame_rx.recv() { + Ok(GifFrameMessage::Frame { frame_num, mut pixels }) => { + if cancel_flag.load(Ordering::Relaxed) { + break; + } + if pixels.len() != expected_len { + fatal = Some("GIF frame size mismatch".into()); + break; + } + if !transparency { + // Premultiply onto opaque black, then force alpha opaque. + for px in pixels.chunks_exact_mut(4) { + let a = px[3] as u32; + px[0] = (px[0] as u32 * a / 255) as u8; + px[1] = (px[1] as u32 * a / 255) as u8; + px[2] = (px[2] as u32 * a / 255) as u8; + px[3] = 255; + } + } + let w = dispatched % n_workers; + if worker_txs[w].send((frame_num, pixels)).is_err() { + fatal = Some("GIF quantizer worker died".into()); + break; + } + dispatched += 1; + } + Ok(GifFrameMessage::Done) => break, + Err(_) => break, // UI thread dropped the sender + } + } + + let _ = progress_tx.send(ExportProgress::Finalizing); + + // Close worker inputs → workers finish → their result senders drop → writer's loop ends. + drop(worker_txs); + for h in worker_handles { + let _ = h.join(); + } + let writer_result = writer.join().unwrap_or_else(|_| Err("GIF writer thread panicked".into())); + + if cancel_flag.load(Ordering::Relaxed) { + std::fs::remove_file(&output_path).ok(); + // Emit Complete so the UI poll loop clears its state; the dialog was closed on cancel. + let _ = progress_tx.send(ExportProgress::Complete { output_path }); + return; + } + + match fatal.or_else(|| writer_result.err()) { + Some(message) => { + std::fs::remove_file(&output_path).ok(); + let _ = progress_tx.send(ExportProgress::Error { message }); + } + None => { + let _ = progress_tx.send(ExportProgress::Complete { output_path }); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index d5b2851..a493f79 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -5,6 +5,7 @@ pub mod audio_exporter; pub mod dialog; +pub mod gif_exporter; pub mod image_exporter; pub mod video_exporter; pub mod readback_pipeline; @@ -13,7 +14,8 @@ pub mod cpu_yuv_converter; pub mod gpu_yuv; pub mod hdr_frame; -use lightningbeam_core::export::{AudioExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress}; +use lightningbeam_core::export::{AudioExportSettings, GifExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress}; +use gif_exporter::GifFrameMessage; use lightningbeam_core::document::Document; use lightningbeam_core::renderer::ImageCache; use lightningbeam_core::video::VideoManager; @@ -131,6 +133,38 @@ pub struct ExportOrchestrator { /// Single-frame image export state image_state: Option, + + /// Animated GIF export state (frames rendered on the UI thread, encoded on `thread_handle`). + gif_state: Option, +} + +/// State for an in-progress animated GIF export. Frames are rendered + read back on the UI thread +/// (one per `render_next_gif_frame` call) and streamed to the encoder thread over `frame_tx`. +struct GifExportState { + /// Resolved pixel dimensions (after applying any width/height overrides). + width: u32, + height: u32, + /// Total frames to render. + total_frames: usize, + /// Next frame index to render (0-based). + next_frame: usize, + /// Document time (seconds) of frame 0. + start_time: f64, + /// Seconds between frames (1 / framerate). + frame_step: f64, + /// How the document is fit into the export frame. + fit: lightningbeam_core::export::ExportFitMode, + /// Preserve alpha as GIF transparency (else the encoder flattens onto black). + transparency: bool, + /// GPU resources allocated on the first render call, reused each frame. + gpu_resources: Option, + /// Output RGBA texture (kept separate from gpu_resources to avoid split-borrow issues). + output_texture: Option, + output_texture_view: Option, + /// Staging buffer for synchronous GPU→CPU readback (reused each frame). + staging_buffer: Option, + /// Sender to the encoder thread; dropped after the final frame to signal completion. + frame_tx: Option>, } /// State for parallel audio+video export @@ -168,6 +202,7 @@ impl ExportOrchestrator { video_state: None, parallel_export: None, image_state: None, + gif_state: None, } } @@ -250,7 +285,7 @@ impl ExportOrchestrator { /// 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() + self.parallel_export.is_some() || self.image_state.is_some() || self.gif_state.is_some() || self.progress_rx.is_some() } /// Poll progress for parallel video+audio export @@ -534,6 +569,9 @@ impl ExportOrchestrator { } self.video_state = None; self.image_state = None; + // Dropping gif_state drops its frame sender, unblocking the encoder thread's recv() so it + // observes the cancel flag, removes the partial file, and exits. + self.gif_state = None; self.progress_rx = None; self.thread_handle = None; } @@ -542,6 +580,7 @@ impl ExportOrchestrator { pub fn is_exporting(&self) -> bool { if self.parallel_export.is_some() { return true; } if self.image_state.is_some() { return true; } + if self.gif_state.is_some() { return true; } if let Some(handle) = &self.thread_handle { !handle.is_finished() } else { @@ -719,6 +758,196 @@ impl ExportOrchestrator { result.map(|_| true) } + /// Enqueue an animated GIF export. Spawns the encoder thread now; call `render_next_gif_frame()` + /// from the egui update loop (where the wgpu device/queue are available) to render + stream each + /// frame to it. + pub fn start_gif_export( + &mut self, + settings: GifExportSettings, + 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); + let total_frames = settings.total_frames(); + let delay_ms = settings.frame_delay_ms(); + let frame_step = 1.0 / settings.framerate; + + let (progress_tx, progress_rx) = channel(); + let (frame_tx, frame_rx) = channel(); + self.progress_rx = Some(progress_rx); + + let cancel_flag = Arc::clone(&self.cancel_flag); + let loop_forever = settings.loop_forever; + let transparency = settings.transparency; + let handle = std::thread::spawn(move || { + gif_exporter::run_gif_encoder( + frame_rx, output_path, width, height, total_frames, delay_ms, + loop_forever, transparency, progress_tx, cancel_flag, + ); + }); + self.thread_handle = Some(handle); + + self.gif_state = Some(GifExportState { + width, + height, + total_frames, + next_frame: 0, + start_time: settings.start_time, + frame_step, + fit: settings.fit, + transparency, + gpu_resources: None, + output_texture: None, + output_texture_view: None, + staging_buffer: None, + frame_tx: Some(frame_tx), + }); + } + + /// Drive the animated GIF export: render + read back one frame per call and stream it to the + /// encoder thread. Returns `Ok(true)` while more frames remain (call again next egui frame), + /// `Ok(false)` once every frame has been sent (encoding then finishes on the background thread). + pub fn render_next_gif_frame( + &mut self, + document: &mut Document, + device: &wgpu::Device, + queue: &wgpu::Queue, + renderer: &mut vello::Renderer, + image_cache: &mut ImageCache, + video_manager: &Arc>, + raster_store: Option<&lightningbeam_core::raster_store::RasterStore>, + ) -> Result { + if self.cancel_flag.load(Ordering::Relaxed) { + // Dropping frame_tx unblocks the encoder thread's recv() so it can clean up. + self.gif_state = None; + return Ok(false); + } + + let state = match self.gif_state.as_mut() { + Some(s) => s, + None => return Ok(false), + }; + + // All frames sent → drop the sender (signals the encoder to finalize) and finish. + if state.next_frame >= state.total_frames { + if let Some(tx) = state.frame_tx.take() { + let _ = tx.send(GifFrameMessage::Done); + drop(tx); + } + self.gif_state = None; + return Ok(false); + } + + let w = state.width; + let h = state.height; + let fit = state.fit; + let timestamp = state.start_time + state.next_frame as f64 * state.frame_step; + + 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("gif_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); + } + + // Render the frame (transparency preserved through readback; the encoder flattens if needed). + { + let gpu = state.gpu_resources.as_mut().unwrap(); + let output_view = state.output_texture_view.as_ref().unwrap(); + let encoder = video_exporter::render_frame_to_gpu_rgba( + document, + timestamp, + w, h, + device, queue, renderer, image_cache, video_manager, + gpu, + output_view, + None, // no floating raster selection during export + state.transparency, + raster_store, + true, // GIF export composites on the shared device + fit, + )?; + queue.submit(Some(encoder.finish())); + } + + // Synchronous readback (wgpu requires bytes_per_row aligned to 256). + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let bytes_per_row = (w * 4 + align - 1) / align * align; + if state.staging_buffer.is_none() { + state.staging_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gif_export_staging"), + size: (bytes_per_row * h) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + })); + } + let staging = state.staging_buffer.as_ref().unwrap(); + + let mut copy_enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("gif_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())); + + let slice = staging.slice(..); + slice.map_async(wgpu::MapMode::Read, |_| {}); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); + + let pixels: Vec = { + let mapped = slice.get_mapped_range(); + 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 frame_num = state.next_frame; + if let Some(tx) = state.frame_tx.as_ref() { + // If the encoder thread died, stop — its dropped receiver returns an error here. + if tx.send(GifFrameMessage::Frame { frame_num, pixels }).is_err() { + self.gif_state = None; + return Ok(false); + } + } + state.next_frame += 1; + Ok(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 40f9e37..b01a4de 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -6355,6 +6355,17 @@ impl eframe::App for EditorApp { } false // synchronous; no progress dialog } + ExportResult::Gif(settings, output_path) => { + println!("🎞 [MAIN] Starting GIF export: {}", output_path.display()); + let doc = self.action_executor.document(); + orchestrator.start_gif_export( + settings, + output_path, + doc.width as u32, + doc.height as u32, + ); + true // background encode with progress dialog + } ExportResult::AudioOnly(settings, output_path) => { println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display()); @@ -6509,6 +6520,21 @@ impl eframe::App for EditorApp { } } + // Drive incremental GIF export (one frame rendered + streamed per call). + match orchestrator.render_next_gif_frame( + self.action_executor.document_mut(), + device, + queue, + renderer, + image_cache, + &self.video_manager, + Some(&self.raster_store), + ) { + Ok(true) => { ctx.request_repaint(); } // more frames to render + Ok(false) => {} // done or not a GIF export + Err(e) => { eprintln!("GIF export failed: {e}"); } + } + // Drive single-frame image export (two-frame async: render then readback). match orchestrator.render_image_frame( self.action_executor.document_mut(),