From 72bac983d40dee1e55acea908896fd23200e51f0 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Mon, 2 Mar 2026 10:37:18 +0100 Subject: [PATCH 1/2] Add gif file export via graphene cli --- Cargo.toml | 1 + node-graph/graphene-cli/src/export.rs | 118 +++++++++++++++++- node-graph/graphene-cli/src/main.rs | 28 ++++- .../benches/benchmark_util.rs | 2 - 4 files changed, 140 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 012978fb17..2d85a289e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -168,6 +168,7 @@ image = { version = "0.25", default-features = false, features = [ "png", "jpeg", "bmp", + "gif", ] } pretty_assertions = "1.4" fern = { version = "0.7", features = ["colored"] } diff --git a/node-graph/graphene-cli/src/export.rs b/node-graph/graphene-cli/src/export.rs index d54015ac28..dee1847c7c 100644 --- a/node-graph/graphene-cli/src/export.rs +++ b/node-graph/graphene-cli/src/export.rs @@ -1,6 +1,6 @@ use graph_craft::document::value::{RenderOutputType, TaggedValue, UVec2}; use graph_craft::graphene_compiler::Executor; -use graphene_std::application_io::{ExportFormat, RenderConfig}; +use graphene_std::application_io::{ExportFormat, RenderConfig, TimingInformation}; use graphene_std::core_types::ops::Convert; use graphene_std::core_types::transform::Footprint; use graphene_std::raster_types::{CPU, GPU, Raster}; @@ -8,12 +8,14 @@ use interpreted_executor::dynamic_executor::DynamicExecutor; use std::error::Error; use std::io::Cursor; use std::path::{Path, PathBuf}; +use std::time::Duration; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FileType { Svg, Png, Jpg, + Gif, } pub fn detect_file_type(path: &Path) -> Result { @@ -21,7 +23,8 @@ pub fn detect_file_type(path: &Path) -> Result { Some("svg") => Ok(FileType::Svg), Some("png") => Ok(FileType::Png), Some("jpg" | "jpeg") => Ok(FileType::Jpg), - _ => Err("Unsupported file extension. Supported formats: .svg, .png, .jpg".to_string()), + Some("gif") => Ok(FileType::Gif), + _ => Err("Unsupported file extension. Supported formats: .svg, .png, .jpg, .gif".to_string()), } } @@ -109,9 +112,118 @@ fn write_raster_image(output_path: PathBuf, file_type: FileType, data: Vec, image.write_to(&mut cursor, ImageFormat::Jpeg)?; log::info!("Exported JPG to: {}", output_path.display()); } - FileType::Svg => unreachable!("SVG should have been handled in export_document"), + FileType::Svg | FileType::Gif => unreachable!("SVG and GIF should have been handled in export_document"), } std::fs::write(&output_path, cursor.into_inner())?; Ok(()) } + +/// Parameters for GIF animation export +#[derive(Debug, Clone, Copy)] +pub struct AnimationParams { + /// Frames per second + pub fps: f64, + /// Total number of frames to render + pub frames: u32, +} + +impl AnimationParams { + /// Create animation parameters from fps and either frame count or duration + pub fn new(fps: f64, frames: Option, duration: Option) -> Self { + let frames = match (frames, duration) { + // Duration takes precedence if both provided + (_, Some(dur)) => (dur * fps).round() as u32, + (Some(f), None) => f, + // Default to 1 frame if neither provided + (None, None) => 1, + }; + Self { fps, frames } + } + + /// Get the frame delay in centiseconds (GIF uses 10ms units) + pub fn frame_delay_centiseconds(&self) -> u16 { + ((100.0 / self.fps).round() as u16).max(1) + } +} + +/// Export an animated GIF by rendering multiple frames at different animation times +pub async fn export_gif( + executor: &DynamicExecutor, + wgpu_executor: &wgpu_executor::WgpuExecutor, + output_path: PathBuf, + scale: f64, + (width, height): (Option, Option), + animation: AnimationParams, +) -> Result<(), Box> { + use image::codecs::gif::{GifEncoder, Repeat}; + use image::{Frame, RgbaImage}; + use std::fs::File; + + log::info!("Exporting GIF: {} frames at {} fps", animation.frames, animation.fps); + + let file = File::create(&output_path)?; + let mut encoder = GifEncoder::new(file); + encoder.set_repeat(Repeat::Infinite)?; + + let frame_delay = animation.frame_delay_centiseconds(); + + for frame_idx in 0..animation.frames { + let animation_time = Duration::from_secs_f64(frame_idx as f64 / animation.fps); + + // Print progress to stderr (overwrites previous line) + eprint!("\rRendering frame {}/{}...", frame_idx + 1, animation.frames); + + log::debug!("Rendering frame {}/{} at time {:?}", frame_idx + 1, animation.frames, animation_time); + + // Create render config with animation time + let mut render_config = RenderConfig { + scale, + export_format: ExportFormat::Raster, + for_export: true, + time: TimingInformation { + time: animation_time.as_secs_f64(), + animation_time, + }, + ..Default::default() + }; + + // Set viewport dimensions if specified + if let (Some(w), Some(h)) = (width, height) { + render_config.viewport.resolution = UVec2::new(w, h); + } + + // Execute the graph for this frame + let result = executor.execute(render_config).await?; + + // Extract RGBA data from result + let (data, img_width, img_height) = match result { + TaggedValue::RenderOutput(output) => match output.data { + RenderOutputType::Texture(image_texture) => { + let gpu_raster = Raster::::new_gpu(image_texture.texture); + let cpu_raster: Raster = gpu_raster.convert(Footprint::BOUNDLESS, wgpu_executor).await; + cpu_raster.to_flat_u8() + } + RenderOutputType::Buffer { data, width, height } => (data, width, height), + other => { + return Err(format!("Unexpected render output type for GIF frame: {:?}. Expected Texture or Buffer.", other).into()); + } + }, + other => return Err(format!("Expected RenderOutput for GIF frame, got: {:?}", other).into()), + }; + + // Create image frame + let image = RgbaImage::from_raw(img_width, img_height, data).ok_or("Failed to create image from buffer")?; + + // Create GIF frame with delay (delay is in 10ms units) + let frame = Frame::from_parts(image, 0, 0, image::Delay::from_saturating_duration(std::time::Duration::from_millis(frame_delay as u64 * 10))); + + encoder.encode_frame(frame)?; + } + + // Clear the progress line + eprintln!(); + + log::info!("Exported GIF to: {}", output_path.display()); + Ok(()) +} diff --git a/node-graph/graphene-cli/src/main.rs b/node-graph/graphene-cli/src/main.rs index 4f559adf8f..f3ddd79624 100644 --- a/node-graph/graphene-cli/src/main.rs +++ b/node-graph/graphene-cli/src/main.rs @@ -46,12 +46,12 @@ enum Command { /// Path to the .graphite document document: PathBuf, }, - /// Export a .graphite document to a file (SVG, PNG, or JPG). + /// Export a .graphite document to a file (SVG, PNG, JPG, or GIF). Export { /// Path to the .graphite document document: PathBuf, - /// Output file path (extension determines format: .svg, .png, .jpg) + /// Output file path (extension determines format: .svg, .png, .jpg, .gif) #[clap(long, short = 'o')] output: PathBuf, @@ -74,6 +74,18 @@ enum Command { /// Transparent background for PNG exports #[clap(long)] transparent: bool, + + /// Frames per second for GIF animation (default: 30) + #[clap(long, default_value = "30")] + fps: f64, + + /// Total number of frames for GIF animation + #[clap(long)] + frames: Option, + + /// Animation duration in seconds for GIF (takes precedence over --frames) + #[clap(long)] + duration: Option, }, ListNodeIdentifiers, } @@ -149,6 +161,9 @@ async fn main() -> Result<(), Box> { width, height, transparent, + fps, + frames, + duration, .. } => { // Spawn thread to poll GPU device @@ -165,8 +180,13 @@ async fn main() -> Result<(), Box> { // Create executor let executor = create_executor(proto_graph)?; - // Perform export - export::export_document(&executor, wgpu_executor_ref, output, file_type, scale, (width, height), transparent).await?; + // Perform export based on file type + if file_type == export::FileType::Gif { + let animation = export::AnimationParams::new(fps, frames, duration); + export::export_gif(&executor, wgpu_executor_ref, output, scale, (width, height), animation).await?; + } else { + export::export_document(&executor, wgpu_executor_ref, output, file_type, scale, (width, height), transparent).await?; + } } _ => unreachable!("All other commands should be handled before this match statement is run"), } diff --git a/node-graph/interpreted-executor/benches/benchmark_util.rs b/node-graph/interpreted-executor/benches/benchmark_util.rs index 71ab004f09..0b078e87c9 100644 --- a/node-graph/interpreted-executor/benches/benchmark_util.rs +++ b/node-graph/interpreted-executor/benches/benchmark_util.rs @@ -10,9 +10,7 @@ use interpreted_executor::util::wrap_network_in_scope; pub fn setup_network(name: &str) -> (DynamicExecutor, ProtoNetwork) { let mut network = load_from_name(name); let editor_api = std::sync::Arc::new(EditorApi::default()); - println!("generating substitutions"); let substitutions = preprocessor::generate_node_substitutions(); - println!("expanding network"); preprocessor::expand_network(&mut network, &substitutions); let network = wrap_network_in_scope(network, editor_api); let proto_network = compile(network); From 24476f55e87a6f80fd2d3efeaee0364e4ec8935d Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Thu, 5 Mar 2026 13:01:02 +0100 Subject: [PATCH 2/2] Add check for negative fps numbers --- node-graph/graphene-cli/src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/node-graph/graphene-cli/src/main.rs b/node-graph/graphene-cli/src/main.rs index f3ddd79624..00cddd2bf0 100644 --- a/node-graph/graphene-cli/src/main.rs +++ b/node-graph/graphene-cli/src/main.rs @@ -180,6 +180,10 @@ async fn main() -> Result<(), Box> { // Create executor let executor = create_executor(proto_graph)?; + if fps <= 0. { + return Err("Fps number must be positive".into()); + } + // Perform export based on file type if file_type == export::FileType::Gif { let animation = export::AnimationParams::new(fps, frames, duration);