Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
118 changes: 115 additions & 3 deletions node-graph/graphene-cli/src/export.rs
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
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};
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<FileType, String> {
match path.extension().and_then(|s| s.to_str()) {
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()),
}
}

Expand Down Expand Up @@ -109,9 +112,118 @@ fn write_raster_image(output_path: PathBuf, file_type: FileType, data: Vec<u8>,
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<u32>, duration: Option<f64>) -> 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<u32>, Option<u32>),
animation: AnimationParams,
) -> Result<(), Box<dyn Error>> {
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::<GPU>::new_gpu(image_texture.texture);
let cpu_raster: Raster<CPU> = 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(())
}
32 changes: 28 additions & 4 deletions node-graph/graphene-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand All @@ -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<u32>,

/// Animation duration in seconds for GIF (takes precedence over --frames)
#[clap(long)]
duration: Option<f64>,
},
ListNodeIdentifiers,
}
Expand Down Expand Up @@ -149,6 +161,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
width,
height,
transparent,
fps,
frames,
duration,
..
} => {
// Spawn thread to poll GPU device
Expand All @@ -165,8 +180,17 @@ async fn main() -> Result<(), Box<dyn Error>> {
// 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?;
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);
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"),
}
Expand Down
2 changes: 0 additions & 2 deletions node-graph/interpreted-executor/benches/benchmark_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down