diff --git a/Cargo.lock b/Cargo.lock index 49299f85a3..843b9c8544 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2160,6 +2160,7 @@ dependencies = [ "futures", "graph-craft", "graphene-std", + "image", "interpreted-executor", "log", "preprocessor", diff --git a/node-graph/graphene-cli/Cargo.toml b/node-graph/graphene-cli/Cargo.toml index 1215842f87..f707d164ca 100644 --- a/node-graph/graphene-cli/Cargo.toml +++ b/node-graph/graphene-cli/Cargo.toml @@ -26,6 +26,7 @@ chrono = { workspace = true } wgpu = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } clap = { workspace = true, features = ["cargo", "derive"] } +image = { workspace = true } # Optional local dependencies wgpu-executor = { path = "../wgpu-executor", optional = true } diff --git a/node-graph/graphene-cli/src/export.rs b/node-graph/graphene-cli/src/export.rs new file mode 100644 index 0000000000..daf8172386 --- /dev/null +++ b/node-graph/graphene-cli/src/export.rs @@ -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 { + 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, + height: Option, + transparent: bool, +) -> Result<(), Box> { + // 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::::new_gpu(image_texture.texture); + let cpu_raster: Raster = 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, width: u32, height: u32, transparent: bool) -> Result<(), Box> { + 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(()) +} diff --git a/node-graph/graphene-cli/src/main.rs b/node-graph/graphene-cli/src/main.rs index 2539957093..b4a08ad320 100644 --- a/node-graph/graphene-cli/src/main.rs +++ b/node-graph/graphene-cli/src/main.rs @@ -1,12 +1,14 @@ +mod export; + use clap::{Args, Parser, Subcommand}; use fern::colors::{Color, ColoredLevelConfig}; use futures::executor::block_on; 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::util::load_network; 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::wasm_application_io::{WasmApplicationIo, WasmEditorApi}; use interpreted_executor::dynamic_executor::DynamicExecutor; @@ -44,17 +46,34 @@ enum Command { /// Path to the .graphite document document: PathBuf, }, - /// Help message for run. - Run { + /// Export a .graphite document to a file (SVG, PNG, or JPG). + Export { /// Path to the .graphite document 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, - /// Run the document in a loop. This is useful for spawning and maintaining a window - #[clap(long, short = 'l')] - run_loop: bool, + /// Scale factor for export (default: 1.0) + #[clap(long, default_value = "1.0")] + scale: f64, + + /// Output width in pixels + #[clap(long)] + width: Option, + + /// Output height in pixels + #[clap(long)] + height: Option, + + /// Transparent background for PNG exports + #[clap(long)] + transparent: bool, }, ListNodeIdentifiers, } @@ -76,7 +95,7 @@ async fn main() -> Result<(), Box> { let document_path = match app.command { Command::Compile { ref document, .. } => document, - Command::Run { ref document, .. } => document, + Command::Export { ref document, .. } => document, Command::ListNodeIdentifiers => { let mut ids: Vec<_> = graphene_std::registry::NODE_METADATA.lock().unwrap().keys().cloned().collect(); ids.sort_by_key(|x| x.name.clone()); @@ -92,15 +111,24 @@ async fn main() -> Result<(), Box> { log::info!("creating gpu context",); 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"))); } - 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 editor_api = Arc::new(WasmEditorApi { font_cache: FontCache::default(), - application_io: Some(application_io.into()), + application_io: Some(application_io_for_api), node_graph_message_sender: Box::new(UpdateLogger {}), editor_preferences: Box::new(preferences), }); @@ -113,24 +141,30 @@ async fn main() -> Result<(), Box> { println!("{proto_graph}"); } } - Command::Run { run_loop, .. } => { + Command::Export { + output, + scale, + width, + height, + transparent, + .. + } => { + // Spawn thread to poll GPU device std::thread::spawn(move || { loop { std::thread::sleep(std::time::Duration::from_nanos(10)); device.poll(wgpu::PollType::Poll).unwrap(); } }); + + // Detect output file type + let file_type = export::detect_file_type(&output)?; + + // Create executor let executor = create_executor(proto_graph)?; - let render_config = RenderConfig::default(); - loop { - let result = (&executor).execute(render_config).await?; - if !run_loop { - println!("{result:?}"); - break; - } - 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"), } diff --git a/node-graph/graphene-cli/test_files/cat.jpg b/node-graph/graphene-cli/test_files/cat.jpg deleted file mode 100644 index 3f2e0d264d..0000000000 Binary files a/node-graph/graphene-cli/test_files/cat.jpg and /dev/null differ diff --git a/node-graph/graphene-cli/test_files/cow_transparent.png b/node-graph/graphene-cli/test_files/cow_transparent.png deleted file mode 100644 index 70f311cf76..0000000000 Binary files a/node-graph/graphene-cli/test_files/cow_transparent.png and /dev/null differ diff --git a/node-graph/graphene-cli/test_files/duck.jpg b/node-graph/graphene-cli/test_files/duck.jpg deleted file mode 100644 index a8af509036..0000000000 Binary files a/node-graph/graphene-cli/test_files/duck.jpg and /dev/null differ diff --git a/node-graph/graphene-cli/test_files/football.jpg b/node-graph/graphene-cli/test_files/football.jpg deleted file mode 100644 index 05fdfb645f..0000000000 Binary files a/node-graph/graphene-cli/test_files/football.jpg and /dev/null differ diff --git a/node-graph/graphene-cli/test_files/mansion.jpg b/node-graph/graphene-cli/test_files/mansion.jpg deleted file mode 100644 index 395722e99e..0000000000 Binary files a/node-graph/graphene-cli/test_files/mansion.jpg and /dev/null differ diff --git a/node-graph/graphene-cli/test_files/paper.jpg b/node-graph/graphene-cli/test_files/paper.jpg deleted file mode 100644 index 7ade495014..0000000000 Binary files a/node-graph/graphene-cli/test_files/paper.jpg and /dev/null differ diff --git a/node-graph/graphene-cli/test_files/pizza.jpg b/node-graph/graphene-cli/test_files/pizza.jpg deleted file mode 100644 index b8ac0c9624..0000000000 Binary files a/node-graph/graphene-cli/test_files/pizza.jpg and /dev/null differ diff --git a/node-graph/graphene-cli/test_files/pizza_transparent.png b/node-graph/graphene-cli/test_files/pizza_transparent.png deleted file mode 100644 index 2eb2b1d4ec..0000000000 Binary files a/node-graph/graphene-cli/test_files/pizza_transparent.png and /dev/null differ