Skip to content

Commit 7ffa25e

Browse files
committed
Add gif file export via graphene cli
1 parent a8b5203 commit 7ffa25e

File tree

4 files changed

+140
-9
lines changed

4 files changed

+140
-9
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ image = { version = "0.25", default-features = false, features = [
205205
"png",
206206
"jpeg",
207207
"bmp",
208+
"gif",
208209
] }
209210
pretty_assertions = "1.4"
210211
fern = { version = "0.7", features = ["colored"] }

node-graph/graphene-cli/src/export.rs

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,30 @@
11
use graph_craft::document::value::{RenderOutputType, TaggedValue, UVec2};
22
use graph_craft::graphene_compiler::Executor;
3-
use graphene_std::application_io::{ExportFormat, RenderConfig};
3+
use graphene_std::application_io::{ExportFormat, RenderConfig, TimingInformation};
44
use graphene_std::core_types::ops::Convert;
55
use graphene_std::core_types::transform::Footprint;
66
use graphene_std::raster_types::{CPU, GPU, Raster};
77
use interpreted_executor::dynamic_executor::DynamicExecutor;
88
use std::error::Error;
99
use std::io::Cursor;
1010
use std::path::{Path, PathBuf};
11+
use std::time::Duration;
1112

1213
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1314
pub enum FileType {
1415
Svg,
1516
Png,
1617
Jpg,
18+
Gif,
1719
}
1820

1921
pub fn detect_file_type(path: &Path) -> Result<FileType, String> {
2022
match path.extension().and_then(|s| s.to_str()) {
2123
Some("svg") => Ok(FileType::Svg),
2224
Some("png") => Ok(FileType::Png),
2325
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()),
2528
}
2629
}
2730

@@ -109,9 +112,118 @@ fn write_raster_image(output_path: PathBuf, file_type: FileType, data: Vec<u8>,
109112
image.write_to(&mut cursor, ImageFormat::Jpeg)?;
110113
log::info!("Exported JPG to: {}", output_path.display());
111114
}
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"),
113116
}
114117

115118
std::fs::write(&output_path, cursor.into_inner())?;
116119
Ok(())
117120
}
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+
}

node-graph/graphene-cli/src/main.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,12 @@ enum Command {
4646
/// Path to the .graphite document
4747
document: PathBuf,
4848
},
49-
/// Export a .graphite document to a file (SVG, PNG, or JPG).
49+
/// Export a .graphite document to a file (SVG, PNG, JPG, or GIF).
5050
Export {
5151
/// Path to the .graphite document
5252
document: PathBuf,
5353

54-
/// Output file path (extension determines format: .svg, .png, .jpg)
54+
/// Output file path (extension determines format: .svg, .png, .jpg, .gif)
5555
#[clap(long, short = 'o')]
5656
output: PathBuf,
5757

@@ -74,6 +74,18 @@ enum Command {
7474
/// Transparent background for PNG exports
7575
#[clap(long)]
7676
transparent: bool,
77+
78+
/// Frames per second for GIF animation (default: 30)
79+
#[clap(long, default_value = "30")]
80+
fps: f64,
81+
82+
/// Total number of frames for GIF animation
83+
#[clap(long)]
84+
frames: Option<u32>,
85+
86+
/// Animation duration in seconds for GIF (takes precedence over --frames)
87+
#[clap(long)]
88+
duration: Option<f64>,
7789
},
7890
ListNodeIdentifiers,
7991
}
@@ -149,6 +161,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
149161
width,
150162
height,
151163
transparent,
164+
fps,
165+
frames,
166+
duration,
152167
..
153168
} => {
154169
// Spawn thread to poll GPU device
@@ -165,8 +180,13 @@ async fn main() -> Result<(), Box<dyn Error>> {
165180
// Create executor
166181
let executor = create_executor(proto_graph)?;
167182

168-
// Perform export
169-
export::export_document(&executor, wgpu_executor_ref, output, file_type, scale, (width, height), transparent).await?;
183+
// Perform export based on file type
184+
if file_type == export::FileType::Gif {
185+
let animation = export::AnimationParams::new(fps, frames, duration);
186+
export::export_gif(&executor, wgpu_executor_ref, output, scale, (width, height), animation).await?;
187+
} else {
188+
export::export_document(&executor, wgpu_executor_ref, output, file_type, scale, (width, height), transparent).await?;
189+
}
170190
}
171191
_ => unreachable!("All other commands should be handled before this match statement is run"),
172192
}

node-graph/interpreted-executor/benches/benchmark_util.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ use interpreted_executor::util::wrap_network_in_scope;
1010
pub fn setup_network(name: &str) -> (DynamicExecutor, ProtoNetwork) {
1111
let mut network = load_from_name(name);
1212
let editor_api = std::sync::Arc::new(EditorApi::default());
13-
println!("generating substitutions");
1413
let substitutions = preprocessor::generate_node_substitutions();
15-
println!("expanding network");
1614
preprocessor::expand_network(&mut network, &substitutions);
1715
let network = wrap_network_in_scope(network, editor_api);
1816
let proto_network = compile(network);

0 commit comments

Comments
 (0)