Add support for opening GDD documents from the CLI (#4262)

* Wire document-format into graphene-cli

* Support loading both legacy and new graphite files in the cli

* Address PR review: propagate CLI errors and use dyn resolver

* Fix CLI network wrap + preprocessor ordering

* Cleanup preprocessor

* Remove unused import
This commit is contained in:
Dennis Kobert
2026-06-21 18:17:01 +02:00
committed by GitHub
parent de11d29d8e
commit b45cc312dd
8 changed files with 123 additions and 84 deletions

84
Cargo.lock generated
View File

@@ -209,9 +209,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -219,9 +219,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.39.0"
version = "0.37.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549"
dependencies = [
"cc",
"cmake",
@@ -354,7 +354,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2 0.6.4",
"objc2 0.6.3",
]
[[package]]
@@ -421,9 +421,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "bzip2"
@@ -582,7 +582,7 @@ dependencies = [
"clap",
"libc",
"libloading 0.9.0",
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-foundation 0.3.2",
"objc2-io-surface",
"objc2-metal 0.3.2",
@@ -1120,9 +1120,9 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "deranged"
version = "0.5.8"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
]
@@ -1213,7 +1213,7 @@ dependencies = [
"bitflags 2.11.0",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
"objc2 0.6.3",
]
[[package]]
@@ -1994,6 +1994,8 @@ version = "0.1.0"
dependencies = [
"chrono",
"clap",
"document-container",
"document-format",
"fern",
"futures",
"graph-craft",
@@ -2142,7 +2144,7 @@ dependencies = [
"interprocess",
"lzma-rust2",
"muda",
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
@@ -3282,7 +3284,7 @@ dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -3451,9 +3453,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.2.0"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-derive"
@@ -3525,9 +3527,9 @@ dependencies = [
[[package]]
name = "objc2"
version = "0.6.4"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
dependencies = [
"objc2-encode",
]
@@ -3556,7 +3558,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.0",
"block2 0.6.2",
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
@@ -3582,7 +3584,7 @@ dependencies = [
"bitflags 2.11.0",
"block2 0.6.2",
"dispatch2",
"objc2 0.6.4",
"objc2 0.6.3",
]
[[package]]
@@ -3646,7 +3648,7 @@ dependencies = [
"bitflags 2.11.0",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-core-foundation",
]
@@ -3658,7 +3660,7 @@ checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.11.0",
"libc",
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
@@ -3684,7 +3686,7 @@ dependencies = [
"bitflags 2.11.0",
"block2 0.6.2",
"dispatch2",
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-io-surface",
@@ -3710,7 +3712,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.11.0",
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-metal 0.3.2",
@@ -3723,7 +3725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.11.0",
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
@@ -4500,7 +4502,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
dependencies = [
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
@@ -4699,7 +4701,7 @@ dependencies = [
"js-sys",
"libc",
"log",
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -4843,9 +4845,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.37"
version = "0.23.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc"
dependencies = [
"aws-lc-rs",
"log",
@@ -4917,9 +4919,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.10"
version = "0.103.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc"
dependencies = [
"aws-lc-rs",
"ring",
@@ -5702,30 +5704,30 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.47"
version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
[[package]]
name = "time-macros"
version = "0.2.27"
version = "0.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
dependencies = [
"num-conv",
"time-core",
@@ -6784,7 +6786,7 @@ dependencies = [
"log",
"naga",
"ndk-sys",
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-metal 0.3.2",
@@ -7390,7 +7392,7 @@ dependencies = [
"block2 0.6.2",
"dispatch2",
"dpi",
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -7409,7 +7411,7 @@ version = "0.30.12"
source = "git+https://github.com/rust-windowing/winit.git#bd6fef1d80ba063cbe91e150b3fb343927cdc72b"
dependencies = [
"memmap2 0.9.10",
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-core-foundation",
"smol_str",
"tracing",
@@ -7457,7 +7459,7 @@ dependencies = [
"block2 0.6.2",
"dispatch2",
"dpi",
"objc2 0.6.4",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-ui-kit",

View File

@@ -7,8 +7,8 @@ members = [
"desktop/platform/linux",
"desktop/platform/mac",
"desktop/platform/win",
"document/container",
"document/graph-storage",
"document/container",
"document/document-format",
"editor",
"frontend/wrapper",
@@ -106,6 +106,7 @@ rustc-hash = "2.0"
bytemuck = { version = "1.13", features = ["derive", "min_const_generics"] }
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
rmp-serde = "1.3"
serde_bytes = "0.11"
serde-wasm-bindgen = "0.6"
reqwest = { version = "0.13", features = ["blocking", "json"] }
@@ -172,7 +173,6 @@ color = "0.3"
# Linebender ecosystem (END)
rand = { version = "0.9", default-features = false, features = ["std_rng"] }
rand_chacha = "0.9"
rmp-serde = "1.3"
glam = { version = "0.32.1", default-features = false, features = [
"nostd-libm",
"scalar-math",

View File

@@ -348,7 +348,7 @@ impl NodeRuntime {
async fn update_network(&mut self, graph: NodeNetwork) -> Result<ResolvedDocumentNodeTypesDelta, (ResolvedDocumentNodeTypesDelta, String)> {
let mut scoped_network = wrap_network_in_scope(graph, self.editor_api.clone());
if let Err(e) = self.preprocessor.preprocess(&mut scoped_network, &self.resources) {
if let Err(e) = self.preprocessor.preprocess(&mut scoped_network, &|resource_id| self.resources.hash(&resource_id)) {
return Err((ResolvedDocumentNodeTypesDelta::default(), e.to_string()));
}

View File

@@ -7,6 +7,7 @@ pub use core_types::{ProtoNodeIdentifier, Type, TypeDescriptor, concrete, descri
pub mod application_io;
pub mod document;
pub use document::{DocumentNode, NodeNetwork};
pub mod graphene_compiler;
pub mod proto;
#[cfg(feature = "loading")]

View File

@@ -17,6 +17,8 @@ graphene-std = { workspace = true }
interpreted-executor = { workspace = true }
graph-craft = { workspace = true, features = ["loading"] }
preprocessor = { workspace = true }
document-format = { workspace = true, features = ["zip", "xz"] }
document-container = { workspace = true, features = ["zip", "xz"] }
# Workspace dependencies
log = { workspace = true }

View File

@@ -1,6 +1,9 @@
mod export;
use clap::{Args, Parser, Subcommand};
use document_container::AnyContainer;
use document_container::backends::memory::MemoryBackend;
use document_format::{GddV1, GddV1Layout};
use fern::colors::{Color, ColoredLevelConfig};
use futures::executor::block_on;
use graph_craft::application_io::EditorPreferences;
@@ -84,6 +87,11 @@ enum Command {
duration: Option<f64>,
},
ListNodeIdentifiers,
/// Extract embedded legacy .graphite file from the new .gdd file
ExtractLegacyDoc {
document: PathBuf,
},
}
#[derive(Debug, Args)]
@@ -104,6 +112,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let document_path = match app.command {
Command::Compile { ref document, .. } => document,
Command::Export { ref document, .. } => document,
Command::ExtractLegacyDoc { ref document } => document,
Command::ListNodeIdentifiers => {
let mut nodes: Vec<_> = graphene_std::registry::NODE_METADATA.lock().unwrap().keys().cloned().collect();
nodes.sort_by_key(|x| x.as_str().to_string());
@@ -114,10 +123,51 @@ async fn main() -> Result<(), Box<dyn Error>> {
}
};
let document_string = std::fs::read_to_string(document_path).expect("Failed to read document");
// Load the document by extension: `.gdd` opens the new archive format, anything else is treated as a
// legacy `.graphite` document. The legacy path has no `Gdd`, so resources fall back to the default registry.
let is_gdd = document_path.extension().is_some_and(|extension| extension.eq_ignore_ascii_case("gdd"));
let gdd = if is_gdd {
let archive = std::fs::read(document_path).map_err(|error| format!("Failed to read document {}: {error}", document_path.display()))?;
let container = AnyContainer::Memory(MemoryBackend::new());
let gdd = document_format::Gdd::open_from_archive(archive.as_ref(), container, GddV1Layout)
.await
.map_err(|error| format!("Failed to open document: {error}"))?;
Some(gdd)
} else {
None
};
if let Command::ExtractLegacyDoc { ref document } = app.command {
let Some(gdd) = &gdd else { return Err("ExtractLegacyDoc requires a .gdd document".into()) };
let Some(legacy_doc) = gdd.read_legacy_document().await else {
return Err("gdd file did not contain a legacy .graphite document".into());
};
let mut new_path = document.clone();
new_path.set_extension("graphite");
std::fs::write(&new_path, legacy_doc).map_err(|error| format!("Failed to write .graphite file: {error}"))?;
eprintln!("Saved file to {}", new_path.to_string_lossy());
return Ok(());
}
// Build the runtime network: from the `.gdd` registry, or by loading a legacy `.graphite` document.
let node_network = match &gdd {
Some(gdd) => {
let declarations = gdd.declarations(gdd).await;
let (node_network, _metadata) = gdd.registry().to_runtime_with_metadata(&declarations)?;
node_network
}
None => {
let document_string = std::fs::read_to_string(document_path).map_err(|error| format!("Failed to read document {}: {error}", document_path.display()))?;
load_network(&document_string)
}
};
log::info!("Creating GPU context");
let application_io = block_on(PlatformApplicationIo::new());
let mut application_io = PlatformApplicationIo::new().await;
if let Some(gdd) = &gdd {
application_io.inject_resource_proxy(Box::new(gdd.resource_proxy()));
}
// Convert application_io to Arc first
let application_io_arc = Arc::new(application_io);
@@ -137,8 +187,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
node_graph_message_sender: Box::new(UpdateLogger {}),
editor_preferences: Box::new(preferences),
});
let proto_graph = compile_graph(document_string, editor_api)?;
let proto_graph = compile_graph(node_network, editor_api, gdd.as_ref())?;
match app.command {
Command::Compile { print_proto, .. } => {
@@ -218,37 +267,22 @@ fn init_logging(log_level: u8) {
.unwrap();
}
// Migrations are done in the editor which is unfortunately not available here.
// TODO: remove this and share migrations between the editor and the CLI.
fn fix_nodes(network: &mut NodeNetwork) {
for node in network.nodes.values_mut() {
match &mut node.implementation {
// Recursively fix
DocumentNodeImplementation::Network(network) => fix_nodes(network),
// This replicates the migration from the editor linked:
// https://github.com/GraphiteEditor/Graphite/blob/d68f91ccca69e90e6d2df78d544d36cd1aaf348e/editor/src/messages/portfolio/portfolio_message_handler.rs#L535
// Since the CLI doesn't have the document node definitions, a less robust method of just patching the inputs is used.
DocumentNodeImplementation::ProtoNode(proto_node_identifier)
if (proto_node_identifier.as_str().starts_with("graphene_core::ConstructLayerNode") || proto_node_identifier.as_str().starts_with("graphene_core::AddArtboardNode"))
&& node.inputs.len() < 3 =>
{
node.inputs.push(NodeInput::Reflection(DocumentNodeMetadata::DocumentNodePath));
}
_ => {}
}
}
}
fn compile_graph(document_string: String, editor_api: Arc<PlatformEditorApi>) -> Result<ProtoNetwork, Box<dyn Error>> {
let mut network = load_network(&document_string);
fix_nodes(&mut network);
let mut wrapped_network = wrap_network_in_scope(network, editor_api);
fn compile_graph(network: NodeNetwork, editor_api: Arc<PlatformEditorApi>, gdd: Option<&GddV1>) -> Result<ProtoNetwork, Box<dyn Error>> {
let preprocessor = preprocessor::Preprocessor::new();
preprocessor.preprocess(&mut wrapped_network, &ResourceRegistry::default()).expect("Failed to expand network"); // TODO: actually load the resources from the document
let mut network = wrap_network_in_scope(network, editor_api);
// A `.gdd` resolves resource hashes from its registry; a legacy `.graphite` has no resource store, so it
// preprocesses against an empty registry (matching the pre-`.gdd` CLI behavior).
match gdd {
Some(gdd) => preprocessor
.preprocess(&mut network, &|resource_id| gdd.registry().resources.get(&resource_id).and_then(|r| r.hash))
.expect("Failed to expand network"),
None => { preprocessor.preprocess(&mut network, &|_| None) }.expect("Failed to expand network"),
}
let compiler = Compiler {};
compiler.compile_single(wrapped_network).map_err(|x| x.into())
compiler.compile_single(network).map_err(|x| x.into())
}
fn create_executor(proto_network: ProtoNetwork) -> Result<DynamicExecutor, Box<dyn Error>> {

View File

@@ -4,7 +4,6 @@ use futures::executor::block_on;
use graph_craft::proto::ProtoNetwork;
use graph_craft::util::{DEMO_ART, compile, load_from_name};
use graphene_std::application_io::EditorApi;
use graphene_std::application_io::resource::ResourceRegistry;
use interpreted_executor::dynamic_executor::DynamicExecutor;
use interpreted_executor::util::wrap_network_in_scope;
@@ -13,7 +12,7 @@ pub fn setup_network(name: &str) -> (DynamicExecutor, ProtoNetwork) {
let editor_api = std::sync::Arc::new(EditorApi::default());
let mut network = wrap_network_in_scope(network, editor_api);
let preprocessor = preprocessor::Preprocessor::new();
preprocessor.preprocess(&mut network, &ResourceRegistry::default()).unwrap();
preprocessor.preprocess(&mut network, &|_| None).unwrap();
let proto_network = compile(network);
let executor = block_on(DynamicExecutor::new(proto_network.clone())).unwrap();
(executor, proto_network)

View File

@@ -2,11 +2,12 @@
extern crate log;
use graph_craft::Type;
use graph_craft::application_io::resource::{ResourceId, ResourceRegistry};
use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::*;
use graph_craft::document::*;
use graph_craft::proto::RegistryValueSource;
use graph_craft::{ProtoNodeIdentifier, concrete};
use graphene_std::platform_application_io::ResourceHash;
use graphene_std::registry::*;
use graphene_std::*;
use std::collections::{HashMap, HashSet};
@@ -19,9 +20,9 @@ pub struct Preprocessor {
}
impl Preprocessor {
pub fn preprocess(&self, network: &mut NodeNetwork, resources: &ResourceRegistry) -> Result<(), PreprocessorError> {
pub fn preprocess(&self, network: &mut NodeNetwork, resolve_resource: &dyn Fn(ResourceId) -> Option<ResourceHash>) -> Result<(), PreprocessorError> {
self.insert_inject_scopes(network);
self.replace_resource_inputs(network, resources)?;
self.replace_resource_inputs(network, resolve_resource)?;
self.expand_network(network);
Ok(())
}
@@ -41,13 +42,13 @@ impl Preprocessor {
}
/// Replace every `TaggedValue::Resource(hash)` input with a reference to a freshly inserted `resource` proto node.
fn replace_resource_inputs(&self, network: &mut NodeNetwork, resources: &ResourceRegistry) -> Result<(), PreprocessorError> {
fn replace_resource_inputs(&self, network: &mut NodeNetwork, resolve_resource: &dyn Fn(ResourceId) -> Option<ResourceHash>) -> Result<(), PreprocessorError> {
let mut hash_to_node_id: HashMap<graph_craft::application_io::resource::ResourceHash, NodeId> = HashMap::new();
let mut new_resource_nodes: Vec<(NodeId, DocumentNode)> = Vec::new();
for node in network.nodes.values_mut() {
if let DocumentNodeImplementation::Network(nested) = &mut node.implementation {
self.replace_resource_inputs(nested, resources)?;
self.replace_resource_inputs(nested, resolve_resource)?;
continue;
}
@@ -59,7 +60,7 @@ impl Preprocessor {
let NodeInput::Value { tagged_value, .. } = input else { continue };
let TaggedValue::Resource(resource_id) = **tagged_value else { continue };
let Some(hash) = resources.hash(&resource_id) else {
let Some(hash) = resolve_resource(resource_id) else {
return Err(PreprocessorError::ResourceNotFound(resource_id));
};