Add animated GIF export
New export format alongside audio/image/video/SVG. GIF is multi-frame like video but palette-quantized with no audio, so it reuses the per-frame RGBA render/readback path (render_frame_to_gpu_rgba) and streams frames to a background encoder. - core: GifExportSettings (resolution, framerate, loop, transparency, fit, time range) with centisecond-quantized frame delay + tests. - gif_exporter: encoder pipeline. Per-frame NeuQuant quantization is the dominant cost and is per-frame independent, so it's fanned out across a worker pool (cores-1, capped 8); a writer thread reorders and LZW-encodes sequentially. Uses the `gif` crate directly (already resolved via `image`). - orchestrator: start_gif_export + render_next_gif_frame (one frame per egui update), wired into is_exporting/has_pending_progress/cancel. - dialog: GIF tab + settings; main.rs: handle ExportResult::Gif and pump frames. - Cargo: opt-level=3 for gif/color_quant/weezl in the dev profile so debug builds aren't crippled by unoptimized NeuQuant loops. Together these cut a 10s GIF export from ~1:43 to ~3s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6e361aa30c
commit
c373af461e
|
|
@ -3641,6 +3641,7 @@ dependencies = [
|
||||||
"egui_extras",
|
"egui_extras",
|
||||||
"egui_node_graph2",
|
"egui_node_graph2",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
|
"gif",
|
||||||
"gpu-video-encoder",
|
"gpu-video-encoder",
|
||||||
"half",
|
"half",
|
||||||
"image",
|
"image",
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,15 @@ opt-level = 2
|
||||||
[profile.dev.package.cpal]
|
[profile.dev.package.cpal]
|
||||||
opt-level = 2
|
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
|
# Use local egui fork with ibus/Wayland text input fix
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
egui = { path = "../../egui-fork/crates/egui" }
|
egui = { path = "../../egui-fork/crates/egui" }
|
||||||
|
|
|
||||||
|
|
@ -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<u32>,
|
||||||
|
/// Output height in pixels (None = use document height).
|
||||||
|
pub height: Option<u32>,
|
||||||
|
/// 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
|
/// Progress updates during export
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ExportProgress {
|
pub enum ExportProgress {
|
||||||
|
|
@ -728,6 +804,33 @@ mod tests {
|
||||||
assert_eq!(settings.total_frames(), 300);
|
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]
|
#[test]
|
||||||
fn test_export_progress_percentage() {
|
fn test_export_progress_percentage() {
|
||||||
let progress = ExportProgress::FrameRendered { frame: 50, total: 100 };
|
let progress = ExportProgress::FrameRendered { frame: 50, total: 100 };
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ serde_json = { workspace = true }
|
||||||
|
|
||||||
# Image loading
|
# Image loading
|
||||||
image = { workspace = true }
|
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 }
|
resvg = { workspace = true }
|
||||||
tiny-skia = "0.11"
|
tiny-skia = "0.11"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use lightningbeam_core::export::{
|
use lightningbeam_core::export::{
|
||||||
AudioExportSettings, AudioFormat,
|
AudioExportSettings, AudioFormat,
|
||||||
|
GifExportSettings,
|
||||||
ImageExportSettings, ImageFormat,
|
ImageExportSettings, ImageFormat,
|
||||||
VideoExportSettings, VideoCodec, VideoQuality, ColorRange,
|
VideoExportSettings, VideoCodec, VideoQuality, ColorRange,
|
||||||
};
|
};
|
||||||
|
|
@ -25,6 +26,8 @@ pub enum ExportType {
|
||||||
Audio,
|
Audio,
|
||||||
Image,
|
Image,
|
||||||
Video,
|
Video,
|
||||||
|
/// Animated GIF (multi-frame, palette-quantized, no audio).
|
||||||
|
Gif,
|
||||||
/// Vector-only SVG of the current frame (lossless; raster/video layers skipped).
|
/// Vector-only SVG of the current frame (lossless; raster/video layers skipped).
|
||||||
Svg,
|
Svg,
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +39,8 @@ pub enum ExportResult {
|
||||||
Image(ImageExportSettings, PathBuf),
|
Image(ImageExportSettings, PathBuf),
|
||||||
VideoOnly(VideoExportSettings, PathBuf),
|
VideoOnly(VideoExportSettings, PathBuf),
|
||||||
VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf),
|
VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf),
|
||||||
|
/// Animated GIF export.
|
||||||
|
Gif(GifExportSettings, PathBuf),
|
||||||
/// SVG of vector layers at the given document time.
|
/// SVG of vector layers at the given document time.
|
||||||
Svg(f64, PathBuf),
|
Svg(f64, PathBuf),
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +62,9 @@ pub struct ExportDialog {
|
||||||
/// Video export settings
|
/// Video export settings
|
||||||
pub video_settings: VideoExportSettings,
|
pub video_settings: VideoExportSettings,
|
||||||
|
|
||||||
|
/// Animated GIF export settings
|
||||||
|
pub gif_settings: GifExportSettings,
|
||||||
|
|
||||||
/// Include audio with video?
|
/// Include audio with video?
|
||||||
pub include_audio: bool,
|
pub include_audio: bool,
|
||||||
|
|
||||||
|
|
@ -104,6 +112,7 @@ impl Default for ExportDialog {
|
||||||
audio_settings: AudioExportSettings::standard_mp3(),
|
audio_settings: AudioExportSettings::standard_mp3(),
|
||||||
image_settings: ImageExportSettings::default(),
|
image_settings: ImageExportSettings::default(),
|
||||||
video_settings: VideoExportSettings::default(),
|
video_settings: VideoExportSettings::default(),
|
||||||
|
gif_settings: GifExportSettings::default(),
|
||||||
include_audio: true,
|
include_audio: true,
|
||||||
output_path: None,
|
output_path: None,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
|
|
@ -124,6 +133,7 @@ impl ExportDialog {
|
||||||
self.open = true;
|
self.open = true;
|
||||||
self.audio_settings.end_time = timeline_duration;
|
self.audio_settings.end_time = timeline_duration;
|
||||||
self.video_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;
|
self.image_settings.time = hint.current_time;
|
||||||
// Propagate document dimensions as defaults (None means "use doc size").
|
// Propagate document dimensions as defaults (None means "use doc size").
|
||||||
self.image_settings.width = None;
|
self.image_settings.width = None;
|
||||||
|
|
@ -160,6 +170,7 @@ impl ExportDialog {
|
||||||
ExportType::Audio => self.audio_settings.format.extension(),
|
ExportType::Audio => self.audio_settings.format.extension(),
|
||||||
ExportType::Image => self.image_settings.format.extension(),
|
ExportType::Image => self.image_settings.format.extension(),
|
||||||
ExportType::Video => self.video_settings.codec.container_format(),
|
ExportType::Video => self.video_settings.codec.container_format(),
|
||||||
|
ExportType::Gif => "gif",
|
||||||
ExportType::Svg => "svg",
|
ExportType::Svg => "svg",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -203,6 +214,7 @@ impl ExportDialog {
|
||||||
ExportType::Audio => "Export Audio",
|
ExportType::Audio => "Export Audio",
|
||||||
ExportType::Image => "Export Image",
|
ExportType::Image => "Export Image",
|
||||||
ExportType::Video => "Export Video",
|
ExportType::Video => "Export Video",
|
||||||
|
ExportType::Gif => "Export GIF",
|
||||||
ExportType::Svg => "Export SVG",
|
ExportType::Svg => "Export SVG",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -225,6 +237,7 @@ impl ExportDialog {
|
||||||
(ExportType::Audio, "Audio"),
|
(ExportType::Audio, "Audio"),
|
||||||
(ExportType::Image, "Image"),
|
(ExportType::Image, "Image"),
|
||||||
(ExportType::Video, "Video"),
|
(ExportType::Video, "Video"),
|
||||||
|
(ExportType::Gif, "GIF"),
|
||||||
(ExportType::Svg, "SVG"),
|
(ExportType::Svg, "SVG"),
|
||||||
] {
|
] {
|
||||||
if ui.selectable_value(&mut self.export_type, variant, label).clicked() {
|
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::Audio => self.render_audio_basic(ui),
|
||||||
ExportType::Image => self.render_image_settings(ui),
|
ExportType::Image => self.render_image_settings(ui),
|
||||||
ExportType::Video => self.render_video_basic(ui),
|
ExportType::Video => self.render_video_basic(ui),
|
||||||
|
ExportType::Gif => self.render_gif_basic(ui),
|
||||||
ExportType::Svg => self.render_svg_settings(ui),
|
ExportType::Svg => self.render_svg_settings(ui),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,6 +275,7 @@ impl ExportDialog {
|
||||||
ExportType::Audio => self.render_audio_advanced(ui),
|
ExportType::Audio => self.render_audio_advanced(ui),
|
||||||
ExportType::Image => self.render_image_advanced(ui),
|
ExportType::Image => self.render_image_advanced(ui),
|
||||||
ExportType::Video => self.render_video_advanced(ui),
|
ExportType::Video => self.render_video_advanced(ui),
|
||||||
|
ExportType::Gif => self.render_gif_advanced(ui),
|
||||||
ExportType::Svg => {} // SVG has no advanced settings
|
ExportType::Svg => {} // SVG has no advanced settings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -614,12 +629,65 @@ impl ExportDialog {
|
||||||
self.render_time_range(ui);
|
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)
|
/// Render time range UI (common to both audio and video)
|
||||||
fn render_time_range(&mut self, ui: &mut egui::Ui) {
|
fn render_time_range(&mut self, ui: &mut egui::Ui) {
|
||||||
let (start_time, end_time) = match self.export_type {
|
let (start_time, end_time) = match self.export_type {
|
||||||
ExportType::Audio => (&mut self.audio_settings.start_time, &mut self.audio_settings.end_time),
|
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::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::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| {
|
ui.horizontal(|ui| {
|
||||||
|
|
@ -693,6 +761,13 @@ impl ExportDialog {
|
||||||
Some(ExportResult::Image(self.image_settings.clone(), output_path))
|
Some(ExportResult::Image(self.image_settings.clone(), output_path))
|
||||||
}
|
}
|
||||||
ExportType::Svg => Some(ExportResult::Svg(self.image_settings.time, 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 => {
|
ExportType::Audio => {
|
||||||
// Validate audio settings
|
// Validate audio settings
|
||||||
if let Err(err) = self.audio_settings.validate() {
|
if let Err(err) = self.audio_settings.validate() {
|
||||||
|
|
|
||||||
|
|
@ -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<u8> },
|
||||||
|
/// 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<GifFrameMessage>,
|
||||||
|
output_path: PathBuf,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
total_frames: usize,
|
||||||
|
delay_ms: u32,
|
||||||
|
loop_forever: bool,
|
||||||
|
transparency: bool,
|
||||||
|
progress_tx: Sender<ExportProgress>,
|
||||||
|
cancel_flag: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
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<Sender<(usize, Vec<u8>)>> = 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<u8>)>();
|
||||||
|
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<usize, gif::Frame<'static>> = 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<String> = 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
pub mod audio_exporter;
|
pub mod audio_exporter;
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
|
pub mod gif_exporter;
|
||||||
pub mod image_exporter;
|
pub mod image_exporter;
|
||||||
pub mod video_exporter;
|
pub mod video_exporter;
|
||||||
pub mod readback_pipeline;
|
pub mod readback_pipeline;
|
||||||
|
|
@ -13,7 +14,8 @@ pub mod cpu_yuv_converter;
|
||||||
pub mod gpu_yuv;
|
pub mod gpu_yuv;
|
||||||
pub mod hdr_frame;
|
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::document::Document;
|
||||||
use lightningbeam_core::renderer::ImageCache;
|
use lightningbeam_core::renderer::ImageCache;
|
||||||
use lightningbeam_core::video::VideoManager;
|
use lightningbeam_core::video::VideoManager;
|
||||||
|
|
@ -131,6 +133,38 @@ pub struct ExportOrchestrator {
|
||||||
|
|
||||||
/// Single-frame image export state
|
/// Single-frame image export state
|
||||||
image_state: Option<ImageExportState>,
|
image_state: Option<ImageExportState>,
|
||||||
|
|
||||||
|
/// Animated GIF export state (frames rendered on the UI thread, encoded on `thread_handle`).
|
||||||
|
gif_state: Option<GifExportState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<video_exporter::ExportGpuResources>,
|
||||||
|
/// Output RGBA texture (kept separate from gpu_resources to avoid split-borrow issues).
|
||||||
|
output_texture: Option<wgpu::Texture>,
|
||||||
|
output_texture_view: Option<wgpu::TextureView>,
|
||||||
|
/// Staging buffer for synchronous GPU→CPU readback (reused each frame).
|
||||||
|
staging_buffer: Option<wgpu::Buffer>,
|
||||||
|
/// Sender to the encoder thread; dropped after the final frame to signal completion.
|
||||||
|
frame_tx: Option<Sender<GifFrameMessage>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State for parallel audio+video export
|
/// State for parallel audio+video export
|
||||||
|
|
@ -168,6 +202,7 @@ impl ExportOrchestrator {
|
||||||
video_state: None,
|
video_state: None,
|
||||||
parallel_export: None,
|
parallel_export: None,
|
||||||
image_state: 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
|
/// unconsumed terminal message). Used to gate the UI poll loop so it doesn't run every
|
||||||
/// repaint forever after an export finishes.
|
/// repaint forever after an export finishes.
|
||||||
pub fn has_pending_progress(&self) -> bool {
|
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
|
/// Poll progress for parallel video+audio export
|
||||||
|
|
@ -534,6 +569,9 @@ impl ExportOrchestrator {
|
||||||
}
|
}
|
||||||
self.video_state = None;
|
self.video_state = None;
|
||||||
self.image_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.progress_rx = None;
|
||||||
self.thread_handle = None;
|
self.thread_handle = None;
|
||||||
}
|
}
|
||||||
|
|
@ -542,6 +580,7 @@ impl ExportOrchestrator {
|
||||||
pub fn is_exporting(&self) -> bool {
|
pub fn is_exporting(&self) -> bool {
|
||||||
if self.parallel_export.is_some() { return true; }
|
if self.parallel_export.is_some() { return true; }
|
||||||
if self.image_state.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 {
|
if let Some(handle) = &self.thread_handle {
|
||||||
!handle.is_finished()
|
!handle.is_finished()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -719,6 +758,196 @@ impl ExportOrchestrator {
|
||||||
result.map(|_| true)
|
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<std::sync::Mutex<VideoManager>>,
|
||||||
|
raster_store: Option<&lightningbeam_core::raster_store::RasterStore>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
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<u8> = {
|
||||||
|
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
|
/// Wait for the export to complete
|
||||||
///
|
///
|
||||||
/// This blocks until the export thread finishes.
|
/// This blocks until the export thread finishes.
|
||||||
|
|
|
||||||
|
|
@ -6355,6 +6355,17 @@ impl eframe::App for EditorApp {
|
||||||
}
|
}
|
||||||
false // synchronous; no progress dialog
|
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) => {
|
ExportResult::AudioOnly(settings, output_path) => {
|
||||||
println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display());
|
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).
|
// Drive single-frame image export (two-frame async: render then readback).
|
||||||
match orchestrator.render_image_frame(
|
match orchestrator.render_image_frame(
|
||||||
self.action_executor.document_mut(),
|
self.action_executor.document_mut(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue