|
1 | 1 | use graph_craft::document::value::{RenderOutputType, TaggedValue, UVec2}; |
2 | 2 | use graph_craft::graphene_compiler::Executor; |
3 | | -use graphene_std::application_io::{ExportFormat, RenderConfig}; |
| 3 | +use graphene_std::application_io::{ExportFormat, RenderConfig, TimingInformation}; |
4 | 4 | use graphene_std::core_types::ops::Convert; |
5 | 5 | use graphene_std::core_types::transform::Footprint; |
6 | 6 | use graphene_std::raster_types::{CPU, GPU, Raster}; |
7 | 7 | use interpreted_executor::dynamic_executor::DynamicExecutor; |
8 | 8 | use std::error::Error; |
9 | 9 | use std::io::Cursor; |
10 | 10 | use std::path::{Path, PathBuf}; |
| 11 | +use std::time::Duration; |
11 | 12 |
|
12 | 13 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
13 | 14 | pub enum FileType { |
14 | 15 | Svg, |
15 | 16 | Png, |
16 | 17 | Jpg, |
| 18 | + Gif, |
17 | 19 | } |
18 | 20 |
|
19 | 21 | pub fn detect_file_type(path: &Path) -> Result<FileType, String> { |
20 | 22 | match path.extension().and_then(|s| s.to_str()) { |
21 | 23 | Some("svg") => Ok(FileType::Svg), |
22 | 24 | Some("png") => Ok(FileType::Png), |
23 | 25 | Some("jpg" | "jpeg") => Ok(FileType::Jpg), |
24 | | - _ => Err("Unsupported file extension. Supported formats: .svg, .png, .jpg".to_string()), |
| 26 | + Some("gif") => Ok(FileType::Gif), |
| 27 | + _ => Err("Unsupported file extension. Supported formats: .svg, .png, .jpg, .gif".to_string()), |
25 | 28 | } |
26 | 29 | } |
27 | 30 |
|
@@ -109,9 +112,118 @@ fn write_raster_image(output_path: PathBuf, file_type: FileType, data: Vec<u8>, |
109 | 112 | image.write_to(&mut cursor, ImageFormat::Jpeg)?; |
110 | 113 | log::info!("Exported JPG to: {}", output_path.display()); |
111 | 114 | } |
112 | | - FileType::Svg => unreachable!("SVG should have been handled in export_document"), |
| 115 | + FileType::Svg | FileType::Gif => unreachable!("SVG and GIF should have been handled in export_document"), |
113 | 116 | } |
114 | 117 |
|
115 | 118 | std::fs::write(&output_path, cursor.into_inner())?; |
116 | 119 | Ok(()) |
117 | 120 | } |
| 121 | + |
| 122 | +/// Parameters for GIF animation export |
| 123 | +#[derive(Debug, Clone, Copy)] |
| 124 | +pub struct AnimationParams { |
| 125 | + /// Frames per second |
| 126 | + pub fps: f64, |
| 127 | + /// Total number of frames to render |
| 128 | + pub frames: u32, |
| 129 | +} |
| 130 | + |
| 131 | +impl AnimationParams { |
| 132 | + /// Create animation parameters from fps and either frame count or duration |
| 133 | + pub fn new(fps: f64, frames: Option<u32>, duration: Option<f64>) -> Self { |
| 134 | + let frames = match (frames, duration) { |
| 135 | + // Duration takes precedence if both provided |
| 136 | + (_, Some(dur)) => (dur * fps).round() as u32, |
| 137 | + (Some(f), None) => f, |
| 138 | + // Default to 1 frame if neither provided |
| 139 | + (None, None) => 1, |
| 140 | + }; |
| 141 | + Self { fps, frames } |
| 142 | + } |
| 143 | + |
| 144 | + /// Get the frame delay in centiseconds (GIF uses 10ms units) |
| 145 | + pub fn frame_delay_centiseconds(&self) -> u16 { |
| 146 | + ((100.0 / self.fps).round() as u16).max(1) |
| 147 | + } |
| 148 | +} |
| 149 | + |
| 150 | +/// Export an animated GIF by rendering multiple frames at different animation times |
| 151 | +pub async fn export_gif( |
| 152 | + executor: &DynamicExecutor, |
| 153 | + wgpu_executor: &wgpu_executor::WgpuExecutor, |
| 154 | + output_path: PathBuf, |
| 155 | + scale: f64, |
| 156 | + (width, height): (Option<u32>, Option<u32>), |
| 157 | + animation: AnimationParams, |
| 158 | +) -> Result<(), Box<dyn Error>> { |
| 159 | + use image::codecs::gif::{GifEncoder, Repeat}; |
| 160 | + use image::{Frame, RgbaImage}; |
| 161 | + use std::fs::File; |
| 162 | + |
| 163 | + log::info!("Exporting GIF: {} frames at {} fps", animation.frames, animation.fps); |
| 164 | + |
| 165 | + let file = File::create(&output_path)?; |
| 166 | + let mut encoder = GifEncoder::new(file); |
| 167 | + encoder.set_repeat(Repeat::Infinite)?; |
| 168 | + |
| 169 | + let frame_delay = animation.frame_delay_centiseconds(); |
| 170 | + |
| 171 | + for frame_idx in 0..animation.frames { |
| 172 | + let animation_time = Duration::from_secs_f64(frame_idx as f64 / animation.fps); |
| 173 | + |
| 174 | + // Print progress to stderr (overwrites previous line) |
| 175 | + eprint!("\rRendering frame {}/{}...", frame_idx + 1, animation.frames); |
| 176 | + |
| 177 | + log::debug!("Rendering frame {}/{} at time {:?}", frame_idx + 1, animation.frames, animation_time); |
| 178 | + |
| 179 | + // Create render config with animation time |
| 180 | + let mut render_config = RenderConfig { |
| 181 | + scale, |
| 182 | + export_format: ExportFormat::Raster, |
| 183 | + for_export: true, |
| 184 | + time: TimingInformation { |
| 185 | + time: animation_time.as_secs_f64(), |
| 186 | + animation_time, |
| 187 | + }, |
| 188 | + ..Default::default() |
| 189 | + }; |
| 190 | + |
| 191 | + // Set viewport dimensions if specified |
| 192 | + if let (Some(w), Some(h)) = (width, height) { |
| 193 | + render_config.viewport.resolution = UVec2::new(w, h); |
| 194 | + } |
| 195 | + |
| 196 | + // Execute the graph for this frame |
| 197 | + let result = executor.execute(render_config).await?; |
| 198 | + |
| 199 | + // Extract RGBA data from result |
| 200 | + let (data, img_width, img_height) = match result { |
| 201 | + TaggedValue::RenderOutput(output) => match output.data { |
| 202 | + RenderOutputType::Texture(image_texture) => { |
| 203 | + let gpu_raster = Raster::<GPU>::new_gpu(image_texture.texture); |
| 204 | + let cpu_raster: Raster<CPU> = gpu_raster.convert(Footprint::BOUNDLESS, wgpu_executor).await; |
| 205 | + cpu_raster.to_flat_u8() |
| 206 | + } |
| 207 | + RenderOutputType::Buffer { data, width, height } => (data, width, height), |
| 208 | + other => { |
| 209 | + return Err(format!("Unexpected render output type for GIF frame: {:?}. Expected Texture or Buffer.", other).into()); |
| 210 | + } |
| 211 | + }, |
| 212 | + other => return Err(format!("Expected RenderOutput for GIF frame, got: {:?}", other).into()), |
| 213 | + }; |
| 214 | + |
| 215 | + // Create image frame |
| 216 | + let image = RgbaImage::from_raw(img_width, img_height, data).ok_or("Failed to create image from buffer")?; |
| 217 | + |
| 218 | + // Create GIF frame with delay (delay is in 10ms units) |
| 219 | + let frame = Frame::from_parts(image, 0, 0, image::Delay::from_saturating_duration(std::time::Duration::from_millis(frame_delay as u64 * 10))); |
| 220 | + |
| 221 | + encoder.encode_frame(frame)?; |
| 222 | + } |
| 223 | + |
| 224 | + // Clear the progress line |
| 225 | + eprintln!(); |
| 226 | + |
| 227 | + log::info!("Exported GIF to: {}", output_path.display()); |
| 228 | + Ok(()) |
| 229 | +} |
0 commit comments