mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-10-02 23:08:11 +08:00
Add animated GIF file export to Graphene CLI (#3846)
* Add gif file export via graphene cli * Add check for negative fps numbers
This commit is contained in:
@@ -169,6 +169,7 @@ image = { version = "0.25", default-features = false, features = [
|
|||||||
"png",
|
"png",
|
||||||
"jpeg",
|
"jpeg",
|
||||||
"bmp",
|
"bmp",
|
||||||
|
"gif",
|
||||||
] }
|
] }
|
||||||
pretty_assertions = "1.4"
|
pretty_assertions = "1.4"
|
||||||
fern = { version = "0.7", features = ["colored"] }
|
fern = { version = "0.7", features = ["colored"] }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use graph_craft::document::value::{RenderOutputType, TaggedValue, UVec2};
|
use graph_craft::document::value::{RenderOutputType, TaggedValue, UVec2};
|
||||||
use graph_craft::graphene_compiler::Executor;
|
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::ops::Convert;
|
||||||
use graphene_std::core_types::transform::Footprint;
|
use graphene_std::core_types::transform::Footprint;
|
||||||
use graphene_std::raster_types::{CPU, GPU, Raster};
|
use graphene_std::raster_types::{CPU, GPU, Raster};
|
||||||
@@ -8,12 +8,14 @@ use interpreted_executor::dynamic_executor::DynamicExecutor;
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum FileType {
|
pub enum FileType {
|
||||||
Svg,
|
Svg,
|
||||||
Png,
|
Png,
|
||||||
Jpg,
|
Jpg,
|
||||||
|
Gif,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn detect_file_type(path: &Path) -> Result<FileType, String> {
|
pub fn detect_file_type(path: &Path) -> Result<FileType, String> {
|
||||||
@@ -21,7 +23,8 @@ pub fn detect_file_type(path: &Path) -> Result<FileType, String> {
|
|||||||
Some("svg") => Ok(FileType::Svg),
|
Some("svg") => Ok(FileType::Svg),
|
||||||
Some("png") => Ok(FileType::Png),
|
Some("png") => Ok(FileType::Png),
|
||||||
Some("jpg" | "jpeg") => Ok(FileType::Jpg),
|
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<u8>,
|
|||||||
image.write_to(&mut cursor, ImageFormat::Jpeg)?;
|
image.write_to(&mut cursor, ImageFormat::Jpeg)?;
|
||||||
log::info!("Exported JPG to: {}", output_path.display());
|
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())?;
|
std::fs::write(&output_path, cursor.into_inner())?;
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,12 +46,12 @@ enum Command {
|
|||||||
/// Path to the .graphite document
|
/// Path to the .graphite document
|
||||||
document: PathBuf,
|
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 {
|
Export {
|
||||||
/// Path to the .graphite document
|
/// Path to the .graphite document
|
||||||
document: PathBuf,
|
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')]
|
#[clap(long, short = 'o')]
|
||||||
output: PathBuf,
|
output: PathBuf,
|
||||||
|
|
||||||
@@ -74,6 +74,18 @@ enum Command {
|
|||||||
/// Transparent background for PNG exports
|
/// Transparent background for PNG exports
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
transparent: bool,
|
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,
|
ListNodeIdentifiers,
|
||||||
}
|
}
|
||||||
@@ -149,6 +161,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
transparent,
|
transparent,
|
||||||
|
fps,
|
||||||
|
frames,
|
||||||
|
duration,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
// Spawn thread to poll GPU device
|
// Spawn thread to poll GPU device
|
||||||
@@ -165,8 +180,17 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
// Create executor
|
// Create executor
|
||||||
let executor = create_executor(proto_graph)?;
|
let executor = create_executor(proto_graph)?;
|
||||||
|
|
||||||
// Perform export
|
if fps <= 0. {
|
||||||
export::export_document(&executor, wgpu_executor_ref, output, file_type, scale, (width, height), transparent).await?;
|
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"),
|
_ => unreachable!("All other commands should be handled before this match statement is run"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ use interpreted_executor::util::wrap_network_in_scope;
|
|||||||
pub fn setup_network(name: &str) -> (DynamicExecutor, ProtoNetwork) {
|
pub fn setup_network(name: &str) -> (DynamicExecutor, ProtoNetwork) {
|
||||||
let mut network = load_from_name(name);
|
let mut network = load_from_name(name);
|
||||||
let editor_api = std::sync::Arc::new(EditorApi::default());
|
let editor_api = std::sync::Arc::new(EditorApi::default());
|
||||||
println!("generating substitutions");
|
|
||||||
let substitutions = preprocessor::generate_node_substitutions();
|
let substitutions = preprocessor::generate_node_substitutions();
|
||||||
println!("expanding network");
|
|
||||||
preprocessor::expand_network(&mut network, &substitutions);
|
preprocessor::expand_network(&mut network, &substitutions);
|
||||||
let network = wrap_network_in_scope(network, editor_api);
|
let network = wrap_network_in_scope(network, editor_api);
|
||||||
let proto_network = compile(network);
|
let proto_network = compile(network);
|
||||||
|
|||||||
Reference in New Issue
Block a user