Skip to content

Commit 788e82a

Browse files
authored
Add export command to Graphene CLI (#3400)
* Add export command to cli * Fix format
1 parent f61aebb commit 788e82a

File tree

12 files changed

+174
-22
lines changed

12 files changed

+174
-22
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

node-graph/graphene-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ chrono = { workspace = true }
2626
wgpu = { workspace = true }
2727
tokio = { workspace = true, features = ["rt-multi-thread"] }
2828
clap = { workspace = true, features = ["cargo", "derive"] }
29+
image = { workspace = true }
2930

3031
# Optional local dependencies
3132
wgpu-executor = { path = "../wgpu-executor", optional = true }
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
use graph_craft::document::value::{RenderOutputType, TaggedValue, UVec2};
2+
use graph_craft::graphene_compiler::Executor;
3+
use graphene_std::application_io::{ExportFormat, RenderConfig};
4+
use graphene_std::core_types::ops::Convert;
5+
use graphene_std::core_types::transform::Footprint;
6+
use graphene_std::raster_types::{CPU, GPU, Raster};
7+
use interpreted_executor::dynamic_executor::DynamicExecutor;
8+
use std::error::Error;
9+
use std::io::Cursor;
10+
use std::path::{Path, PathBuf};
11+
12+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13+
pub enum FileType {
14+
Svg,
15+
Png,
16+
Jpg,
17+
}
18+
19+
pub fn detect_file_type(path: &Path) -> Result<FileType, String> {
20+
match path.extension().and_then(|s| s.to_str()) {
21+
Some("svg") => Ok(FileType::Svg),
22+
Some("png") => Ok(FileType::Png),
23+
Some("jpg" | "jpeg") => Ok(FileType::Jpg),
24+
_ => Err(format!("Unsupported file extension. Supported formats: .svg, .png, .jpg")),
25+
}
26+
}
27+
28+
pub async fn export_document(
29+
executor: &DynamicExecutor,
30+
wgpu_executor: &wgpu_executor::WgpuExecutor,
31+
output_path: PathBuf,
32+
file_type: FileType,
33+
scale: f64,
34+
width: Option<u32>,
35+
height: Option<u32>,
36+
transparent: bool,
37+
) -> Result<(), Box<dyn Error>> {
38+
// Determine export format based on file type
39+
let export_format = match file_type {
40+
FileType::Svg => ExportFormat::Svg,
41+
_ => ExportFormat::Raster,
42+
};
43+
44+
// Create render config with export settings
45+
let mut render_config = RenderConfig::default();
46+
render_config.export_format = export_format;
47+
render_config.for_export = true;
48+
render_config.scale = scale;
49+
50+
// Set viewport dimensions if specified
51+
if let (Some(w), Some(h)) = (width, height) {
52+
render_config.viewport.resolution = UVec2::new(w, h);
53+
}
54+
55+
// Execute the graph
56+
let result = executor.execute(render_config).await?;
57+
58+
// Handle the result based on output type
59+
match result {
60+
TaggedValue::RenderOutput(output) => match output.data {
61+
RenderOutputType::Svg { svg, .. } => {
62+
// Write SVG directly to file
63+
std::fs::write(&output_path, svg)?;
64+
log::info!("Exported SVG to: {}", output_path.display());
65+
}
66+
RenderOutputType::Texture(image_texture) => {
67+
// Convert GPU texture to CPU buffer
68+
let gpu_raster = Raster::<GPU>::new_gpu(image_texture.texture);
69+
let cpu_raster: Raster<CPU> = gpu_raster.convert(Footprint::BOUNDLESS, wgpu_executor).await;
70+
let (data, width, height) = cpu_raster.to_flat_u8();
71+
72+
// Encode and write raster image
73+
write_raster_image(output_path, file_type, data, width, height, transparent)?;
74+
}
75+
RenderOutputType::Buffer { data, width, height } => {
76+
// Encode and write raster image when buffer is already provided
77+
write_raster_image(output_path, file_type, data, width, height, transparent)?;
78+
}
79+
other => {
80+
return Err(format!("Unexpected render output type: {:?}. Expected Texture, Buffer for raster export or Svg for SVG export.", other).into());
81+
}
82+
},
83+
other => return Err(format!("Expected RenderOutput, got: {:?}", other).into()),
84+
}
85+
86+
Ok(())
87+
}
88+
89+
fn write_raster_image(output_path: PathBuf, file_type: FileType, data: Vec<u8>, width: u32, height: u32, transparent: bool) -> Result<(), Box<dyn Error>> {
90+
use image::{ImageFormat, RgbaImage};
91+
92+
let image = RgbaImage::from_raw(width, height, data).ok_or("Failed to create image from buffer")?;
93+
94+
let mut cursor = Cursor::new(Vec::new());
95+
96+
match file_type {
97+
FileType::Png => {
98+
if transparent {
99+
image.write_to(&mut cursor, ImageFormat::Png)?;
100+
} else {
101+
let image: image::RgbImage = image::DynamicImage::ImageRgba8(image).to_rgb8();
102+
image.write_to(&mut cursor, ImageFormat::Png)?;
103+
}
104+
log::info!("Exported PNG to: {}", output_path.display());
105+
}
106+
FileType::Jpg => {
107+
let image: image::RgbImage = image::DynamicImage::ImageRgba8(image).to_rgb8();
108+
image.write_to(&mut cursor, ImageFormat::Jpeg)?;
109+
log::info!("Exported JPG to: {}", output_path.display());
110+
}
111+
FileType::Svg => unreachable!("SVG should have been handled in export_document"),
112+
}
113+
114+
std::fs::write(&output_path, cursor.into_inner())?;
115+
Ok(())
116+
}

node-graph/graphene-cli/src/main.rs

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
mod export;
2+
13
use clap::{Args, Parser, Subcommand};
24
use fern::colors::{Color, ColoredLevelConfig};
35
use futures::executor::block_on;
46
use graph_craft::document::*;
5-
use graph_craft::graphene_compiler::{Compiler, Executor};
7+
use graph_craft::graphene_compiler::Compiler;
68
use graph_craft::proto::ProtoNetwork;
79
use graph_craft::util::load_network;
810
use graph_craft::wasm_application_io::EditorPreferences;
9-
use graphene_std::application_io::{ApplicationIo, NodeGraphUpdateMessage, NodeGraphUpdateSender, RenderConfig};
11+
use graphene_std::application_io::{ApplicationIo, NodeGraphUpdateMessage, NodeGraphUpdateSender};
1012
use graphene_std::text::FontCache;
1113
use graphene_std::wasm_application_io::{WasmApplicationIo, WasmEditorApi};
1214
use interpreted_executor::dynamic_executor::DynamicExecutor;
@@ -44,17 +46,34 @@ enum Command {
4446
/// Path to the .graphite document
4547
document: PathBuf,
4648
},
47-
/// Help message for run.
48-
Run {
49+
/// Export a .graphite document to a file (SVG, PNG, or JPG).
50+
Export {
4951
/// Path to the .graphite document
5052
document: PathBuf,
5153

52-
/// Path to the .graphite document
54+
/// Output file path (extension determines format: .svg, .png, .jpg)
55+
#[clap(long, short = 'o')]
56+
output: PathBuf,
57+
58+
/// Optional input image resource
59+
#[clap(long)]
5360
image: Option<PathBuf>,
5461

55-
/// Run the document in a loop. This is useful for spawning and maintaining a window
56-
#[clap(long, short = 'l')]
57-
run_loop: bool,
62+
/// Scale factor for export (default: 1.0)
63+
#[clap(long, default_value = "1.0")]
64+
scale: f64,
65+
66+
/// Output width in pixels
67+
#[clap(long)]
68+
width: Option<u32>,
69+
70+
/// Output height in pixels
71+
#[clap(long)]
72+
height: Option<u32>,
73+
74+
/// Transparent background for PNG exports
75+
#[clap(long)]
76+
transparent: bool,
5877
},
5978
ListNodeIdentifiers,
6079
}
@@ -76,7 +95,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
7695

7796
let document_path = match app.command {
7897
Command::Compile { ref document, .. } => document,
79-
Command::Run { ref document, .. } => document,
98+
Command::Export { ref document, .. } => document,
8099
Command::ListNodeIdentifiers => {
81100
let mut ids: Vec<_> = graphene_std::registry::NODE_METADATA.lock().unwrap().keys().cloned().collect();
82101
ids.sort_by_key(|x| x.name.clone());
@@ -92,15 +111,24 @@ async fn main() -> Result<(), Box<dyn Error>> {
92111
log::info!("creating gpu context",);
93112
let mut application_io = block_on(WasmApplicationIo::new_offscreen());
94113

95-
if let Command::Run { image: Some(ref image_path), .. } = app.command {
114+
if let Command::Export { image: Some(ref image_path), .. } = app.command {
96115
application_io.resources.insert("null".to_string(), Arc::from(std::fs::read(image_path).expect("Failed to read image")));
97116
}
98-
let device = application_io.gpu_executor().unwrap().context.device.clone();
117+
118+
// Convert application_io to Arc first
119+
let application_io_arc = Arc::new(application_io);
120+
121+
// Clone the application_io Arc before borrowing to extract executor
122+
let application_io_for_api = application_io_arc.clone();
123+
124+
// Get reference to wgpu executor and clone device handle
125+
let wgpu_executor_ref = application_io_arc.gpu_executor().unwrap();
126+
let device = wgpu_executor_ref.context.device.clone();
99127

100128
let preferences = EditorPreferences { use_vello: true };
101129
let editor_api = Arc::new(WasmEditorApi {
102130
font_cache: FontCache::default(),
103-
application_io: Some(application_io.into()),
131+
application_io: Some(application_io_for_api),
104132
node_graph_message_sender: Box::new(UpdateLogger {}),
105133
editor_preferences: Box::new(preferences),
106134
});
@@ -113,24 +141,30 @@ async fn main() -> Result<(), Box<dyn Error>> {
113141
println!("{proto_graph}");
114142
}
115143
}
116-
Command::Run { run_loop, .. } => {
144+
Command::Export {
145+
output,
146+
scale,
147+
width,
148+
height,
149+
transparent,
150+
..
151+
} => {
152+
// Spawn thread to poll GPU device
117153
std::thread::spawn(move || {
118154
loop {
119155
std::thread::sleep(std::time::Duration::from_nanos(10));
120156
device.poll(wgpu::PollType::Poll).unwrap();
121157
}
122158
});
159+
160+
// Detect output file type
161+
let file_type = export::detect_file_type(&output)?;
162+
163+
// Create executor
123164
let executor = create_executor(proto_graph)?;
124-
let render_config = RenderConfig::default();
125165

126-
loop {
127-
let result = (&executor).execute(render_config).await?;
128-
if !run_loop {
129-
println!("{result:?}");
130-
break;
131-
}
132-
tokio::time::sleep(std::time::Duration::from_millis(16)).await;
133-
}
166+
// Perform export
167+
export::export_document(&executor, wgpu_executor_ref, output, file_type, scale, width, height, transparent).await?;
134168
}
135169
_ => unreachable!("All other commands should be handled before this match statement is run"),
136170
}
-1.54 MB
Binary file not shown.
-1.4 MB
Binary file not shown.
-1.4 MB
Binary file not shown.
-334 KB
Binary file not shown.
-1.34 MB
Binary file not shown.
-1.41 MB
Binary file not shown.

0 commit comments

Comments
 (0)