Skip to content
Merged
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.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node-graph/graphene-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
116 changes: 116 additions & 0 deletions node-graph/graphene-cli/src/export.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
78 changes: 56 additions & 22 deletions node-graph/graphene-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<PathBuf>,

/// 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<u32>,

/// Output height in pixels
#[clap(long)]
height: Option<u32>,

/// Transparent background for PNG exports
#[clap(long)]
transparent: bool,
},
ListNodeIdentifiers,
}
Expand All @@ -76,7 +95,7 @@ async fn main() -> Result<(), Box<dyn Error>> {

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());
Expand All @@ -92,15 +111,24 @@ async fn main() -> Result<(), Box<dyn Error>> {
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),
});
Expand All @@ -113,24 +141,30 @@ async fn main() -> Result<(), Box<dyn Error>> {
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"),
}
Expand Down
Binary file removed node-graph/graphene-cli/test_files/cat.jpg
Binary file not shown.
Binary file not shown.
Binary file removed node-graph/graphene-cli/test_files/duck.jpg
Binary file not shown.
Binary file removed node-graph/graphene-cli/test_files/football.jpg
Binary file not shown.
Binary file removed node-graph/graphene-cli/test_files/mansion.jpg
Binary file not shown.
Binary file removed node-graph/graphene-cli/test_files/paper.jpg
Binary file not shown.
Binary file removed node-graph/graphene-cli/test_files/pizza.jpg
Binary file not shown.
Binary file not shown.