Add export command to Graphene CLI (#3400)
* Add export command to cli * Fix format
@@ -2160,6 +2160,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"graph-craft",
|
"graph-craft",
|
||||||
"graphene-std",
|
"graphene-std",
|
||||||
|
"image",
|
||||||
"interpreted-executor",
|
"interpreted-executor",
|
||||||
"log",
|
"log",
|
||||||
"preprocessor",
|
"preprocessor",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ chrono = { workspace = true }
|
|||||||
wgpu = { workspace = true }
|
wgpu = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||||
clap = { workspace = true, features = ["cargo", "derive"] }
|
clap = { workspace = true, features = ["cargo", "derive"] }
|
||||||
|
image = { workspace = true }
|
||||||
|
|
||||||
# Optional local dependencies
|
# Optional local dependencies
|
||||||
wgpu-executor = { path = "../wgpu-executor", optional = true }
|
wgpu-executor = { path = "../wgpu-executor", optional = true }
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
use graph_craft::document::value::{RenderOutputType, TaggedValue, UVec2};
|
||||||
|
use graph_craft::graphene_compiler::Executor;
|
||||||
|
use graphene_std::application_io::{ExportFormat, RenderConfig};
|
||||||
|
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};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum FileType {
|
||||||
|
Svg,
|
||||||
|
Png,
|
||||||
|
Jpg,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(format!("Unsupported file extension. Supported formats: .svg, .png, .jpg")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn export_document(
|
||||||
|
executor: &DynamicExecutor,
|
||||||
|
wgpu_executor: &wgpu_executor::WgpuExecutor,
|
||||||
|
output_path: PathBuf,
|
||||||
|
file_type: FileType,
|
||||||
|
scale: f64,
|
||||||
|
width: Option<u32>,
|
||||||
|
height: Option<u32>,
|
||||||
|
transparent: bool,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
// Determine export format based on file type
|
||||||
|
let export_format = match file_type {
|
||||||
|
FileType::Svg => ExportFormat::Svg,
|
||||||
|
_ => ExportFormat::Raster,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create render config with export settings
|
||||||
|
let mut render_config = RenderConfig::default();
|
||||||
|
render_config.export_format = export_format;
|
||||||
|
render_config.for_export = true;
|
||||||
|
render_config.scale = scale;
|
||||||
|
|
||||||
|
// Set viewport dimensions if specified
|
||||||
|
if let (Some(w), Some(h)) = (width, height) {
|
||||||
|
render_config.viewport.resolution = UVec2::new(w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the graph
|
||||||
|
let result = executor.execute(render_config).await?;
|
||||||
|
|
||||||
|
// Handle the result based on output type
|
||||||
|
match result {
|
||||||
|
TaggedValue::RenderOutput(output) => match output.data {
|
||||||
|
RenderOutputType::Svg { svg, .. } => {
|
||||||
|
// Write SVG directly to file
|
||||||
|
std::fs::write(&output_path, svg)?;
|
||||||
|
log::info!("Exported SVG to: {}", output_path.display());
|
||||||
|
}
|
||||||
|
RenderOutputType::Texture(image_texture) => {
|
||||||
|
// Convert GPU texture to CPU buffer
|
||||||
|
let gpu_raster = Raster::<GPU>::new_gpu(image_texture.texture);
|
||||||
|
let cpu_raster: Raster<CPU> = gpu_raster.convert(Footprint::BOUNDLESS, wgpu_executor).await;
|
||||||
|
let (data, width, height) = cpu_raster.to_flat_u8();
|
||||||
|
|
||||||
|
// Encode and write raster image
|
||||||
|
write_raster_image(output_path, file_type, data, width, height, transparent)?;
|
||||||
|
}
|
||||||
|
RenderOutputType::Buffer { data, width, height } => {
|
||||||
|
// Encode and write raster image when buffer is already provided
|
||||||
|
write_raster_image(output_path, file_type, data, width, height, transparent)?;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(format!("Unexpected render output type: {:?}. Expected Texture, Buffer for raster export or Svg for SVG export.", other).into());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
other => return Err(format!("Expected RenderOutput, got: {:?}", other).into()),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_raster_image(output_path: PathBuf, file_type: FileType, data: Vec<u8>, width: u32, height: u32, transparent: bool) -> Result<(), Box<dyn Error>> {
|
||||||
|
use image::{ImageFormat, RgbaImage};
|
||||||
|
|
||||||
|
let image = RgbaImage::from_raw(width, height, data).ok_or("Failed to create image from buffer")?;
|
||||||
|
|
||||||
|
let mut cursor = Cursor::new(Vec::new());
|
||||||
|
|
||||||
|
match file_type {
|
||||||
|
FileType::Png => {
|
||||||
|
if transparent {
|
||||||
|
image.write_to(&mut cursor, ImageFormat::Png)?;
|
||||||
|
} else {
|
||||||
|
let image: image::RgbImage = image::DynamicImage::ImageRgba8(image).to_rgb8();
|
||||||
|
image.write_to(&mut cursor, ImageFormat::Png)?;
|
||||||
|
}
|
||||||
|
log::info!("Exported PNG to: {}", output_path.display());
|
||||||
|
}
|
||||||
|
FileType::Jpg => {
|
||||||
|
let image: image::RgbImage = image::DynamicImage::ImageRgba8(image).to_rgb8();
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::write(&output_path, cursor.into_inner())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
|
mod export;
|
||||||
|
|
||||||
use clap::{Args, Parser, Subcommand};
|
use clap::{Args, Parser, Subcommand};
|
||||||
use fern::colors::{Color, ColoredLevelConfig};
|
use fern::colors::{Color, ColoredLevelConfig};
|
||||||
use futures::executor::block_on;
|
use futures::executor::block_on;
|
||||||
use graph_craft::document::*;
|
use graph_craft::document::*;
|
||||||
use graph_craft::graphene_compiler::{Compiler, Executor};
|
use graph_craft::graphene_compiler::Compiler;
|
||||||
use graph_craft::proto::ProtoNetwork;
|
use graph_craft::proto::ProtoNetwork;
|
||||||
use graph_craft::util::load_network;
|
use graph_craft::util::load_network;
|
||||||
use graph_craft::wasm_application_io::EditorPreferences;
|
use graph_craft::wasm_application_io::EditorPreferences;
|
||||||
use graphene_std::application_io::{ApplicationIo, NodeGraphUpdateMessage, NodeGraphUpdateSender, RenderConfig};
|
use graphene_std::application_io::{ApplicationIo, NodeGraphUpdateMessage, NodeGraphUpdateSender};
|
||||||
use graphene_std::text::FontCache;
|
use graphene_std::text::FontCache;
|
||||||
use graphene_std::wasm_application_io::{WasmApplicationIo, WasmEditorApi};
|
use graphene_std::wasm_application_io::{WasmApplicationIo, WasmEditorApi};
|
||||||
use interpreted_executor::dynamic_executor::DynamicExecutor;
|
use interpreted_executor::dynamic_executor::DynamicExecutor;
|
||||||
@@ -44,17 +46,34 @@ enum Command {
|
|||||||
/// Path to the .graphite document
|
/// Path to the .graphite document
|
||||||
document: PathBuf,
|
document: PathBuf,
|
||||||
},
|
},
|
||||||
/// Help message for run.
|
/// Export a .graphite document to a file (SVG, PNG, or JPG).
|
||||||
Run {
|
Export {
|
||||||
/// Path to the .graphite document
|
/// Path to the .graphite document
|
||||||
document: PathBuf,
|
document: PathBuf,
|
||||||
|
|
||||||
/// Path to the .graphite document
|
/// Output file path (extension determines format: .svg, .png, .jpg)
|
||||||
|
#[clap(long, short = 'o')]
|
||||||
|
output: PathBuf,
|
||||||
|
|
||||||
|
/// Optional input image resource
|
||||||
|
#[clap(long)]
|
||||||
image: Option<PathBuf>,
|
image: Option<PathBuf>,
|
||||||
|
|
||||||
/// Run the document in a loop. This is useful for spawning and maintaining a window
|
/// Scale factor for export (default: 1.0)
|
||||||
#[clap(long, short = 'l')]
|
#[clap(long, default_value = "1.0")]
|
||||||
run_loop: bool,
|
scale: f64,
|
||||||
|
|
||||||
|
/// Output width in pixels
|
||||||
|
#[clap(long)]
|
||||||
|
width: Option<u32>,
|
||||||
|
|
||||||
|
/// Output height in pixels
|
||||||
|
#[clap(long)]
|
||||||
|
height: Option<u32>,
|
||||||
|
|
||||||
|
/// Transparent background for PNG exports
|
||||||
|
#[clap(long)]
|
||||||
|
transparent: bool,
|
||||||
},
|
},
|
||||||
ListNodeIdentifiers,
|
ListNodeIdentifiers,
|
||||||
}
|
}
|
||||||
@@ -76,7 +95,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
|
|
||||||
let document_path = match app.command {
|
let document_path = match app.command {
|
||||||
Command::Compile { ref document, .. } => document,
|
Command::Compile { ref document, .. } => document,
|
||||||
Command::Run { ref document, .. } => document,
|
Command::Export { ref document, .. } => document,
|
||||||
Command::ListNodeIdentifiers => {
|
Command::ListNodeIdentifiers => {
|
||||||
let mut ids: Vec<_> = graphene_std::registry::NODE_METADATA.lock().unwrap().keys().cloned().collect();
|
let mut ids: Vec<_> = graphene_std::registry::NODE_METADATA.lock().unwrap().keys().cloned().collect();
|
||||||
ids.sort_by_key(|x| x.name.clone());
|
ids.sort_by_key(|x| x.name.clone());
|
||||||
@@ -92,15 +111,24 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
log::info!("creating gpu context",);
|
log::info!("creating gpu context",);
|
||||||
let mut application_io = block_on(WasmApplicationIo::new_offscreen());
|
let mut application_io = block_on(WasmApplicationIo::new_offscreen());
|
||||||
|
|
||||||
if let Command::Run { image: Some(ref image_path), .. } = app.command {
|
if let Command::Export { image: Some(ref image_path), .. } = app.command {
|
||||||
application_io.resources.insert("null".to_string(), Arc::from(std::fs::read(image_path).expect("Failed to read image")));
|
application_io.resources.insert("null".to_string(), Arc::from(std::fs::read(image_path).expect("Failed to read image")));
|
||||||
}
|
}
|
||||||
let device = application_io.gpu_executor().unwrap().context.device.clone();
|
|
||||||
|
// Convert application_io to Arc first
|
||||||
|
let application_io_arc = Arc::new(application_io);
|
||||||
|
|
||||||
|
// Clone the application_io Arc before borrowing to extract executor
|
||||||
|
let application_io_for_api = application_io_arc.clone();
|
||||||
|
|
||||||
|
// Get reference to wgpu executor and clone device handle
|
||||||
|
let wgpu_executor_ref = application_io_arc.gpu_executor().unwrap();
|
||||||
|
let device = wgpu_executor_ref.context.device.clone();
|
||||||
|
|
||||||
let preferences = EditorPreferences { use_vello: true };
|
let preferences = EditorPreferences { use_vello: true };
|
||||||
let editor_api = Arc::new(WasmEditorApi {
|
let editor_api = Arc::new(WasmEditorApi {
|
||||||
font_cache: FontCache::default(),
|
font_cache: FontCache::default(),
|
||||||
application_io: Some(application_io.into()),
|
application_io: Some(application_io_for_api),
|
||||||
node_graph_message_sender: Box::new(UpdateLogger {}),
|
node_graph_message_sender: Box::new(UpdateLogger {}),
|
||||||
editor_preferences: Box::new(preferences),
|
editor_preferences: Box::new(preferences),
|
||||||
});
|
});
|
||||||
@@ -113,24 +141,30 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
println!("{proto_graph}");
|
println!("{proto_graph}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::Run { run_loop, .. } => {
|
Command::Export {
|
||||||
|
output,
|
||||||
|
scale,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
transparent,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
// Spawn thread to poll GPU device
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(std::time::Duration::from_nanos(10));
|
std::thread::sleep(std::time::Duration::from_nanos(10));
|
||||||
device.poll(wgpu::PollType::Poll).unwrap();
|
device.poll(wgpu::PollType::Poll).unwrap();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let executor = create_executor(proto_graph)?;
|
|
||||||
let render_config = RenderConfig::default();
|
|
||||||
|
|
||||||
loop {
|
// Detect output file type
|
||||||
let result = (&executor).execute(render_config).await?;
|
let file_type = export::detect_file_type(&output)?;
|
||||||
if !run_loop {
|
|
||||||
println!("{result:?}");
|
// Create executor
|
||||||
break;
|
let executor = create_executor(proto_graph)?;
|
||||||
}
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(16)).await;
|
// Perform export
|
||||||
}
|
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"),
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2.4 MiB |