From ec422b63a54ec40c2da737d68b4d617926e27a1e Mon Sep 17 00:00:00 2001 From: Timon Date: Mon, 18 May 2026 13:28:55 +0000 Subject: [PATCH 1/9] Content hash based resource storage --- Cargo.lock | 70 +++- Cargo.toml | 2 + desktop/src/app.rs | 6 +- desktop/src/consts.rs | 1 + desktop/src/dirs.rs | 8 +- desktop/wrapper/src/lib.rs | 11 +- editor/src/application.rs | 18 +- editor/src/dispatcher.rs | 32 +- .../preferences_dialog_message_handler.rs | 4 +- editor/src/messages/frontend/utility_types.rs | 5 + editor/src/messages/message.rs | 2 + editor/src/messages/mod.rs | 1 + .../document/document_message_handler.rs | 38 ++- .../document/graph_operation/utility_types.rs | 17 +- .../utility_types/embedded_resources.rs | 86 +++++ .../portfolio/document/utility_types/mod.rs | 1 + .../utility_types/network_interface.rs | 20 ++ .../messages/portfolio/portfolio_message.rs | 1 + .../portfolio/portfolio_message_handler.rs | 37 ++- editor/src/messages/prelude.rs | 1 + editor/src/messages/resource/mod.rs | 7 + .../src/messages/resource/resource_message.rs | 10 + .../resource/resource_message_handler.rs | 88 ++++++ editor/src/node_graph_executor/runtime.rs | 37 +-- frontend/src/App.svelte | 2 +- frontend/wrapper/src/editor_wrapper.rs | 42 ++- node-graph/graph-craft/Cargo.toml | 20 +- node-graph/graph-craft/src/application_io.rs | 93 +++++- .../graph-craft/src/application_io/native.rs | 113 ------- .../src/application_io/resource.rs | 47 +++ .../src/application_io/resource/indexed_db.rs | 298 ++++++++++++++++++ .../src/application_io/resource/mmap.rs | 160 ++++++++++ .../graph-craft/src/application_io/wasm.rs | 105 ------ node-graph/graph-craft/src/document/value.rs | 1 + node-graph/graphene-cli/src/main.rs | 6 +- .../interpreted-executor/src/node_registry.rs | 1 + .../libraries/application-io/Cargo.toml | 1 + .../libraries/application-io/src/lib.rs | 26 +- .../libraries/application-io/src/resource.rs | 213 +++++++++++++ .../nodes/gstd/src/platform_application_io.rs | 58 +++- node-graph/nodes/gstd/src/render_node.rs | 2 +- node-graph/preprocessor/src/lib.rs | 50 ++- 42 files changed, 1397 insertions(+), 344 deletions(-) create mode 100644 editor/src/messages/portfolio/document/utility_types/embedded_resources.rs create mode 100644 editor/src/messages/resource/mod.rs create mode 100644 editor/src/messages/resource/resource_message.rs create mode 100644 editor/src/messages/resource/resource_message_handler.rs delete mode 100644 node-graph/graph-craft/src/application_io/native.rs create mode 100644 node-graph/graph-craft/src/application_io/resource.rs create mode 100644 node-graph/graph-craft/src/application_io/resource/indexed_db.rs create mode 100644 node-graph/graph-craft/src/application_io/resource/mmap.rs delete mode 100644 node-graph/graph-craft/src/application_io/wasm.rs create mode 100644 node-graph/libraries/application-io/src/resource.rs diff --git a/Cargo.lock b/Cargo.lock index 531cc58d3d..1e3e967f25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -304,6 +304,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "blending-nodes" version = "0.1.0" @@ -812,6 +826,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.8.0" @@ -928,6 +948,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crate-hierarchy-viz" version = "0.0.0" @@ -1527,7 +1556,7 @@ checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" dependencies = [ "fontconfig-parser", "log", - "memmap2", + "memmap2 0.9.10", "slotmap", "tinyvec", "ttf-parser", @@ -1541,7 +1570,7 @@ checksum = "7c20b425addb8661e97fe1d51c4d8bcec3ec29ed6ad0db983976a7521276b8f7" dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", - "memmap2", + "memmap2 0.9.10", "parlance", "read-fonts", "smallvec", @@ -1853,6 +1882,7 @@ dependencies = [ "gungraun", "js-sys", "log", + "mmap-io", "pretty_assertions", "raster-nodes", "rendering", @@ -1866,6 +1896,7 @@ dependencies = [ "url", "vector-nodes", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", "wgpu-executor", "winit", @@ -1875,6 +1906,7 @@ dependencies = [ name = "graphene-application-io" version = "0.1.0" dependencies = [ + "blake3", "core-types", "dyn-any", "glam", @@ -3105,6 +3137,15 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "memmap2" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49388d20533534cd19360ad3d6a7dadc885944aa802ba3995040c5ec11288c6" +dependencies = [ + "libc", +] + [[package]] name = "memmap2" version = "0.9.10" @@ -3141,6 +3182,21 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "mmap-io" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34b926fd173419d630eb9d8ee3a485345237bf699d6f7ea6e5c1ee97cf1fa47" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "log", + "memmap2 0.7.1", + "parking_lot", + "thiserror 1.0.69", +] + [[package]] name = "muda" version = "0.17.1" @@ -4852,7 +4908,7 @@ checksum = "1dd3accc0f3f4bbaf2c9e1957a030dc582028130c67660d44c0a0345a22ca69b" dependencies = [ "ab_glyph", "log", - "memmap2", + "memmap2 0.9.10", "smithay-client-toolkit", "tiny-skia", ] @@ -5034,7 +5090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5120,7 +5176,7 @@ dependencies = [ "cursor-icon", "libc", "log", - "memmap2", + "memmap2 0.9.10", "rustix", "thiserror 2.0.18", "wayland-backend", @@ -7230,7 +7286,7 @@ name = "winit-common" version = "0.30.12" source = "git+https://github.com/rust-windowing/winit.git#bd6fef1d80ba063cbe91e150b3fb343927cdc72b" dependencies = [ - "memmap2", + "memmap2 0.9.10", "objc2 0.6.4", "objc2-core-foundation", "smol_str", @@ -7302,7 +7358,7 @@ dependencies = [ "cursor-icon", "dpi", "libc", - "memmap2", + "memmap2 0.9.10", "raw-window-handle", "rustix", "sctk-adwaita", diff --git a/Cargo.toml b/Cargo.toml index 3e3fdddb43..412ffcee74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -171,6 +171,8 @@ glam = { version = "0.32.1", default-features = false, features = [ "bytemuck", ] } base64 = "0.22" +blake3 = "1.5" +mmap-io = { version = "0.9", features = ["hugepages"] } image = { version = "0.25", default-features = false, features = [ "png", "jpeg", diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 63d7d13495..cce30bcf4f 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -16,13 +16,14 @@ use winit::window::WindowId; use crate::cef; use crate::consts::CEF_MESSAGE_LOOP_MAX_ITERATIONS; +use crate::dirs; use crate::event::{AppEvent, AppEventScheduler}; use crate::persist; use crate::preferences; use crate::render::{RenderError, RenderState}; use crate::window::Window; use crate::wrapper::messages::{DesktopFrontendMessage, DesktopWrapperMessage, InputMessage, MouseKeys, MouseState, Preferences}; -use crate::wrapper::{DesktopWrapper, NodeGraphExecutionResult, WgpuContext, serialize_frontend_messages}; +use crate::wrapper::{DesktopWrapper, MmapResourceStorage, NodeGraphExecutionResult, WgpuContext, serialize_frontend_messages}; pub(crate) struct App { render_state: Option, @@ -91,7 +92,8 @@ impl App { } }); - let desktop_wrapper = DesktopWrapper::new(rand::rng().random()); + let resource_storage = MmapResourceStorage::new(dirs::app_resources_dir()).expect("Failed to initialize on-disk resource storage"); + let desktop_wrapper = DesktopWrapper::new(rand::rng().random(), Box::new(resource_storage)); Self { render_state: None, diff --git a/desktop/src/consts.rs b/desktop/src/consts.rs index 3ffa7d887f..e1326402f4 100644 --- a/desktop/src/consts.rs +++ b/desktop/src/consts.rs @@ -11,6 +11,7 @@ pub(crate) const APP_SOCKET_FILE_NAME: &str = "instance.sock"; pub(crate) const APP_STATE_FILE_NAME: &str = "state.ron"; pub(crate) const APP_PREFERENCES_FILE_NAME: &str = "preferences.ron"; pub(crate) const APP_DOCUMENTS_DIRECTORY_NAME: &str = "documents"; +pub(crate) const APP_RESOURCES_DIRECTORY_NAME: &str = "resources"; // CEF configuration constants pub(crate) const CEF_WINDOWLESS_FRAME_RATE: i32 = 60; diff --git a/desktop/src/dirs.rs b/desktop/src/dirs.rs index 9084cf3a6b..2b8fe849d9 100644 --- a/desktop/src/dirs.rs +++ b/desktop/src/dirs.rs @@ -2,7 +2,7 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; -use crate::consts::{APP_DIRECTORY_NAME, APP_DOCUMENTS_DIRECTORY_NAME}; +use crate::consts::{APP_DIRECTORY_NAME, APP_DOCUMENTS_DIRECTORY_NAME, APP_RESOURCES_DIRECTORY_NAME}; pub(crate) fn ensure_dir_exists(path: &PathBuf) { if !path.exists() { @@ -51,6 +51,12 @@ pub(crate) fn app_autosave_documents_dir() -> PathBuf { path } +pub(crate) fn app_resources_dir() -> PathBuf { + let path = app_data_dir().join(APP_RESOURCES_DIRECTORY_NAME); + ensure_dir_exists(&path); + path +} + /// Temporary directory that is automatically deleted when dropped. pub struct TempDir { path: PathBuf, diff --git a/desktop/wrapper/src/lib.rs b/desktop/wrapper/src/lib.rs index ad83ca4154..6865985217 100644 --- a/desktop/wrapper/src/lib.rs +++ b/desktop/wrapper/src/lib.rs @@ -1,9 +1,10 @@ -use graph_craft::application_io::PlatformApplicationIo; +use graph_craft::application_io::{PlatformApplicationIo, ResourceStorage}; use graphite_editor::application::{Editor, Environment, Host, Platform}; use graphite_editor::messages::prelude::{FrontendMessage, Message}; use message_dispatcher::DesktopWrapperMessageDispatcher; use messages::{DesktopFrontendMessage, DesktopWrapperMessage}; +pub use graph_craft::application_io::MmapResourceStorage; pub use graphite_editor::consts::{DOUBLE_CLICK_MILLISECONDS, FILE_EXTENSION}; pub use wgpu_executor::WgpuContext; pub use wgpu_executor::WgpuContextBuilder; @@ -22,7 +23,7 @@ pub struct DesktopWrapper { } impl DesktopWrapper { - pub fn new(uuid_random_seed: u64) -> Self { + pub fn new(uuid_random_seed: u64, resource_storage: Box) -> Self { #[cfg(target_os = "windows")] let host = Host::Windows; #[cfg(target_os = "macos")] @@ -32,13 +33,13 @@ impl DesktopWrapper { let env = Environment { platform: Platform::Desktop, host }; Self { - editor: Editor::new(env, uuid_random_seed), + editor: Editor::new(env, uuid_random_seed, resource_storage), } } - pub fn init(&self, wgpu_context: WgpuContext) { + pub fn init(&mut self, wgpu_context: WgpuContext) { let application_io = PlatformApplicationIo::new_with_context(wgpu_context); - futures::executor::block_on(graphite_editor::node_graph_executor::replace_application_io(application_io)); + futures::executor::block_on(self.editor.replace_application_io(application_io)); } pub fn dispatch(&mut self, message: DesktopWrapperMessage) -> Vec { diff --git a/editor/src/application.rs b/editor/src/application.rs index cc54caa8e3..7dac094686 100644 --- a/editor/src/application.rs +++ b/editor/src/application.rs @@ -1,5 +1,6 @@ use crate::dispatcher::Dispatcher; use crate::messages::prelude::*; +use graph_craft::application_io::{PlatformApplicationIo, ResourceStorage}; pub use graphene_std::uuid::*; use std::sync::OnceLock; @@ -8,11 +9,13 @@ pub struct Editor { } impl Editor { - pub fn new(environment: Environment, uuid_random_seed: u64) -> Self { + pub fn new(environment: Environment, uuid_random_seed: u64, resource_storage: Box) -> Self { ENVIRONMENT.set(environment).expect("Editor shoud only be initialized once"); graphene_std::uuid::set_uuid_seed(uuid_random_seed); - Self { dispatcher: Dispatcher::new() } + Self { + dispatcher: Dispatcher::new(resource_storage), + } } #[cfg(test)] @@ -20,11 +23,15 @@ impl Editor { let _ = ENVIRONMENT.set(*Editor::environment()); graphene_std::uuid::set_uuid_seed(0); - let (runtime, executor) = crate::node_graph_executor::NodeGraphExecutor::new_with_local_runtime(); + let (mut runtime, executor) = crate::node_graph_executor::NodeGraphExecutor::new_with_local_runtime(); let editor = Self { dispatcher: Dispatcher::with_executor(executor), }; + let mut application_io = PlatformApplicationIo::default(); + application_io.inject_resources(editor.dispatcher.message_handlers.resource_message_handler.resources()); + runtime.replace_application_io(application_io); + (editor, runtime) } @@ -37,6 +44,11 @@ impl Editor { pub fn poll_node_graph_evaluation(&mut self, responses: &mut VecDeque) -> Result<(), String> { self.dispatcher.poll_node_graph_evaluation(responses) } + + pub async fn replace_application_io(&mut self, mut application_io: PlatformApplicationIo) { + application_io.inject_resources(self.dispatcher.message_handlers.resource_message_handler.resources()); + crate::node_graph_executor::replace_application_io(application_io).await; + } } static ENVIRONMENT: OnceLock = OnceLock::new(); diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index 9b42502802..d89442da3f 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -6,6 +6,7 @@ use crate::messages::portfolio::utility_types::PanelType; use crate::messages::preferences::preferences_message_handler::PreferencesMessageContext; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::utility_functions::make_path_editable_is_allowed; +use graph_craft::application_io::ResourceStorage; #[derive(Debug, Default)] pub struct Dispatcher { @@ -31,16 +32,22 @@ pub struct DispatcherMessageHandlers { menu_bar_message_handler: MenuBarMessageHandler, pub(crate) portfolio_message_handler: PortfolioMessageHandler, preferences_message_handler: PreferencesMessageHandler, + pub(crate) resource_message_handler: ResourceMessageHandler, tool_message_handler: ToolMessageHandler, viewport_message_handler: ViewportMessageHandler, } impl DispatcherMessageHandlers { + pub fn with_resource_storage(resource_storage: Box) -> Self { + let mut s = Self::default(); + s.resource_message_handler = ResourceMessageHandler::new(resource_storage); + s + } + pub fn with_executor(executor: crate::node_graph_executor::NodeGraphExecutor) -> Self { - Self { - portfolio_message_handler: PortfolioMessageHandler::with_executor(executor), - ..Default::default() - } + let mut s = Self::default(); + s.portfolio_message_handler = PortfolioMessageHandler::with_executor(executor); + s } } @@ -78,15 +85,16 @@ const DEBUG_MESSAGE_BLOCK_LIST: &[MessageDiscriminant] = &[ const DEBUG_MESSAGE_ENDING_BLOCK_LIST: &[&str] = &["PointerMove", "PointerOutsideViewport", "Overlays", "Draw", "CurrentTime", "Time"]; impl Dispatcher { - pub fn new() -> Self { - Self::default() + pub fn new(resource_storage: Box) -> Self { + let mut s = Self::default(); + s.message_handlers.resource_message_handler = ResourceMessageHandler::new(resource_storage); + s } pub fn with_executor(executor: crate::node_graph_executor::NodeGraphExecutor) -> Self { - Self { - message_handlers: DispatcherMessageHandlers::with_executor(executor), - ..Default::default() - } + let mut s = Self::default(); + s.message_handlers.portfolio_message_handler = PortfolioMessageHandler::with_executor(executor); + s } // If the deepest queues (higher index in queues list) are now empty (after being popped from) then remove them @@ -218,6 +226,9 @@ impl Dispatcher { self.message_handlers.layout_message_handler.process_message(message, &mut queue, context); } + Message::Resource(message) => { + self.message_handlers.resource_message_handler.process_message(message, &mut queue, ResourceMessageContext {}); + } Message::Portfolio(message) => { self.message_handlers.portfolio_message_handler.process_message( message, @@ -230,6 +241,7 @@ impl Dispatcher { timing_information: self.message_handlers.animation_message_handler.timing_information(), animation: &self.message_handlers.animation_message_handler, viewport: &self.message_handlers.viewport_message_handler, + resources: &self.message_handlers.resource_message_handler, }, ); } diff --git a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs index 99fa880289..c47adad4cf 100644 --- a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs +++ b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs @@ -3,7 +3,7 @@ use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::utility_types::wires::GraphWireStyle; use crate::messages::preferences::SelectionMode; use crate::messages::prelude::*; -use graphene_std::render_node::{EditorPreferences, wgpu_available}; +use graph_craft::application_io::EditorPreferences; #[derive(ExtractField)] pub struct PreferencesDialogMessageContext<'a> { @@ -307,7 +307,7 @@ impl PreferencesDialogMessageHandler { // COMPATIBILITY // ============= { - let wgpu_available = wgpu_available().unwrap_or(false); + let wgpu_available = graph_craft::application_io::wgpu_available().unwrap_or(false); let is_desktop = cfg!(not(target_family = "wasm")); if wgpu_available || is_desktop { let header = vec![TextLabel::new("Compatibility").italic(true).widget_instance()]; diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index 7d9e619fbb..9196c4baf3 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; +use graph_craft::application_io::ResourceHash; + use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::utility_types::WorkspacePanelLayout; use crate::messages::prelude::*; @@ -10,6 +12,9 @@ pub struct DocumentInfo { pub id: DocumentId, pub name: String, #[serde(default)] + #[cfg_attr(feature = "wasm", tsify(type = "unknown"))] + pub resources: Option>, + #[serde(default)] pub path: Option, pub is_saved: bool, } diff --git a/editor/src/messages/message.rs b/editor/src/messages/message.rs index e9cab24e2f..baeabd8b04 100644 --- a/editor/src/messages/message.rs +++ b/editor/src/messages/message.rs @@ -36,6 +36,8 @@ pub enum Message { #[child] Preferences(PreferencesMessage), #[child] + Resource(ResourceMessage), + #[child] Tool(ToolMessage), #[child] Viewport(ViewportMessage), diff --git a/editor/src/messages/mod.rs b/editor/src/messages/mod.rs index a17aedffb9..81f0077239 100644 --- a/editor/src/messages/mod.rs +++ b/editor/src/messages/mod.rs @@ -17,5 +17,6 @@ pub mod message; pub mod portfolio; pub mod preferences; pub mod prelude; +pub mod resource; pub mod tool; pub mod viewport; diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 109e04c3f8..3e392e88d3 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -19,6 +19,7 @@ use crate::messages::portfolio::document::overlays::grid_overlays::{grid_overlay use crate::messages::portfolio::document::overlays::utility_types::{OverlaysType, OverlaysVisibilitySettings, Pivot}; use crate::messages::portfolio::document::properties_panel::properties_panel_message_handler::PropertiesPanelMessageContext; use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier}; +use crate::messages::portfolio::document::utility_types::embedded_resources::EmbeddedResources; use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, PTZ}; use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, NodeTemplate, OutputConnector}; use crate::messages::portfolio::utility_types::{CachedData, PanelType}; @@ -29,13 +30,13 @@ use crate::messages::tool::tool_messages::tool_prelude::Key; use crate::messages::tool::utility_types::ToolType; use crate::node_graph_executor::NodeGraphExecutor; use glam::{DAffine2, DVec2}; +use graph_craft::application_io::{ResourceHash, wgpu_available}; use graph_craft::descriptor; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput, NodeNetwork, OldNodeNetwork}; use graphene_std::math::quad::Quad; use graphene_std::path_bool_nodes::boolean_intersect; use graphene_std::raster::BlendMode; -use graphene_std::render_node::wgpu_available; use graphene_std::subpath::Subpath; use graphene_std::vector::PointId; use graphene_std::vector::click_target::{ClickTarget, ClickTargetType}; @@ -59,6 +60,7 @@ pub struct DocumentMessageContext<'a> { pub layers_panel_open: bool, pub properties_panel_open: bool, pub viewport: &'a ViewportMessageHandler, + pub resources: &'a ResourceMessageHandler, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, ExtractField)] @@ -107,6 +109,9 @@ pub struct DocumentMessageHandler { pub graph_view_overlay_open: bool, /// The current opacity of the faded node graph background that covers up the artwork. pub graph_fade_artwork_percentage: f64, + /// Resources embedded in the document. Must be None for autosaved documents. + #[serde(rename = "resources", skip_serializing_if = "Option::is_none")] + pub embedded_resources: Option, // ============================================= // Fields omitted from the saved document format @@ -169,6 +174,7 @@ impl Default for DocumentMessageHandler { graph_view_overlay_open: false, snapping_state: SnappingState::default(), graph_fade_artwork_percentage: 80., + embedded_resources: None, // ============================================= // Fields omitted from the saved document format // ============================================= @@ -200,6 +206,7 @@ impl MessageHandler> for DocumentMes data_panel_open, layers_panel_open, properties_panel_open, + resources, } = context; match message { @@ -916,15 +923,22 @@ impl MessageHandler> for DocumentMes if path.is_some() { responses.add(DocumentMessage::MarkAsSaved); } - let folder = self.path.as_ref().and_then(|path| path.parent()).map(|parent| parent.to_path_buf()); + let exported = resources.export(&Vec::from_iter(self.used_resources())); + let embedded = EmbeddedResources::from_iter(exported); + if !embedded.is_empty() { + self.embedded_resources = Some(embedded); + } + let content = self.serialize_document(); + self.embedded_resources = None; + responses.add(FrontendMessage::TriggerSaveDocument { document_id, name: format!("{}.{}", self.name.clone(), FILE_EXTENSION), path, folder, - content: self.serialize_document().into_bytes().into(), + content: content.into_bytes().into(), }); } DocumentMessage::SavedDocument { path } => { @@ -1306,11 +1320,10 @@ impl MessageHandler> for DocumentMes let layer_text_frames = text_frames .into_iter() .filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id)) - .filter_map(|(node_id, frame)| { - self.network_interface.is_layer(&node_id, &[]).then(|| { - let layer = LayerNodeIdentifier::new(node_id, &self.network_interface); - (layer, frame) - }) + .filter(|&(node_id, _)| self.network_interface.is_layer(&node_id, &[])) + .map(|(node_id, frame)| { + let layer = LayerNodeIdentifier::new(node_id, &self.network_interface); + (layer, frame) }) .collect(); self.network_interface.update_text_frames(layer_text_frames); @@ -2546,6 +2559,7 @@ impl DocumentMessageHandler { /// Helper method for NudgeSelectedLayers message. /// Handles keyboard nudging of selected layers with optional resize mode. + #[allow(clippy::too_many_arguments)] fn handle_nudge_selected_layers( &mut self, delta_x: f64, @@ -3444,6 +3458,14 @@ impl DocumentMessageHandler { pub fn graph_view_overlay_open(&self) -> bool { self.graph_view_overlay_open } + + pub fn used_resources(&self) -> HashSet { + let mut resources = HashSet::new(); + self.network_interface.collect_used_resources(&mut resources); + self.document_undo_history.iter().for_each(|interface| interface.collect_used_resources(&mut resources)); + self.document_redo_history.iter().for_each(|interface| interface.collect_used_resources(&mut resources)); + resources + } } /// Create a network interface with a single export diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 5ea5829208..5a09692211 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -298,13 +298,18 @@ impl<'a> ModifyInputsContext<'a> { let transform = resolve_proto_node_type(graphene_std::transform_nodes::transform::IDENTIFIER) .expect("Transform node does not exist") .default_node_template(); - let image_node = resolve_proto_node_type(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER) - .expect("Image node does not exist") - .node_template_input_override([Some(NodeInput::value(TaggedValue::None, false)), Some(NodeInput::value(TaggedValue::ImageData(image), false))]); - let image_id = NodeId::new(); - self.network_interface.insert_node(image_id, image_node, &[]); - self.network_interface.move_node_to_chain_start(&image_id, layer, &[], self.import); + let png_bytes: std::sync::Arc<[u8]> = image.to_png().into(); + let hash = graphene_std::application_io::ResourceHash::from(png_bytes.as_ref()); + self.responses.add(ResourceMessage::Write { data: png_bytes }); + + let resource_image = resolve_proto_node_type(graphene_std::platform_application_io::resource_image::IDENTIFIER) + .expect("Resource Image node does not exist") + .node_template_input_override([Some(NodeInput::value(TaggedValue::Resource(hash), false))]); + + let resource_image_id = NodeId::new(); + self.network_interface.insert_node(resource_image_id, resource_image, &[]); + self.network_interface.move_node_to_chain_start(&resource_image_id, layer, &[], self.import); let transform_id = NodeId::new(); self.network_interface.insert_node(transform_id, transform, &[]); diff --git a/editor/src/messages/portfolio/document/utility_types/embedded_resources.rs b/editor/src/messages/portfolio/document/utility_types/embedded_resources.rs new file mode 100644 index 0000000000..a15770cbea --- /dev/null +++ b/editor/src/messages/portfolio/document/utility_types/embedded_resources.rs @@ -0,0 +1,86 @@ +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use graph_craft::application_io::{Resource, ResourceHash}; +use std::collections::HashMap; +use std::fmt; + +#[derive(Clone, Default, Debug, PartialEq)] +pub struct EmbeddedResources { + resources: HashMap, +} + +impl EmbeddedResources { + pub fn is_empty(&self) -> bool { + self.resources.is_empty() + } +} + +impl FromIterator<(ResourceHash, Resource)> for EmbeddedResources { + fn from_iter>(iter: T) -> Self { + Self { + resources: iter.into_iter().collect(), + } + } +} + +impl IntoIterator for EmbeddedResources { + type Item = (ResourceHash, Resource); + type IntoIter = std::collections::hash_map::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.resources.into_iter() + } +} + +impl serde::Serialize for EmbeddedResources { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeMap; + + let human_readable = serializer.is_human_readable(); + let mut map = serializer.serialize_map(Some(self.resources.len()))?; + for (hash, resource) in &self.resources { + let bytes: &[u8] = resource.as_ref(); + if human_readable { + map.serialize_entry(hash, &BASE64.encode(bytes))?; + } else { + map.serialize_entry(hash, serde_bytes::Bytes::new(bytes))?; + } + } + map.end() + } +} + +impl<'de> serde::Deserialize<'de> for EmbeddedResources { + fn deserialize>(deserializer: D) -> Result { + struct EmbeddedResourcesVisitor { + human_readable: bool, + } + + impl<'de> serde::de::Visitor<'de> for EmbeddedResourcesVisitor { + type Value = EmbeddedResources; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map of ResourceHash to resource bytes") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut resources = HashMap::with_capacity(map.size_hint().unwrap_or(0)); + while let Some(hash) = map.next_key::()? { + let resource = if self.human_readable { + let encoded: String = map.next_value()?; + let bytes = BASE64.decode(&encoded).map_err(serde::de::Error::custom)?; + Resource::new(bytes) + } else { + let bytes: serde_bytes::ByteBuf = map.next_value()?; + Resource::new(bytes.into_vec()) + }; + resources.insert(hash, resource); + } + Ok(EmbeddedResources { resources }) + } + } + + let human_readable = deserializer.is_human_readable(); + deserializer.deserialize_map(EmbeddedResourcesVisitor { human_readable }) + } +} diff --git a/editor/src/messages/portfolio/document/utility_types/mod.rs b/editor/src/messages/portfolio/document/utility_types/mod.rs index 8bed0dbb85..93d3e8b552 100644 --- a/editor/src/messages/portfolio/document/utility_types/mod.rs +++ b/editor/src/messages/portfolio/document/utility_types/mod.rs @@ -1,5 +1,6 @@ pub mod clipboards; pub mod document_metadata; +pub mod embedded_resources; pub mod error; pub mod misc; pub mod network_interface; diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 0253f707f6..586c070b1c 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -20,6 +20,7 @@ use crate::messages::tool::tool_messages::tool_prelude::NumberInputMode; use deserialization::deserialize_node_persistent_metadata; use glam::{DAffine2, DVec2, IVec2}; use graph_craft::Type; +use graph_craft::application_io::ResourceHash; use graph_craft::document::value::TaggedValue; use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput, NodeNetwork, OldDocumentNodeImplementation, OldNodeNetwork}; use graphene_std::ContextDependencies; @@ -510,6 +511,10 @@ impl NodeNetworkInterface { }) } + pub fn collect_used_resources(&self, target: &mut HashSet) { + collect_network_resources(self.document_network(), target); + } + pub fn frontend_imports(&mut self, network_path: &[NodeId]) -> Vec> { match network_path.split_last() { Some((node_id, encapsulating_network_path)) => { @@ -6769,6 +6774,21 @@ pub enum TransactionStatus { Finished, } +fn collect_network_resources(network: &NodeNetwork, out: &mut HashSet) { + for node in network.nodes.values() { + for input in &node.inputs { + if let NodeInput::Value { tagged_value, .. } = input { + if let TaggedValue::Resource(hash) = &**tagged_value { + out.insert(*hash); + } + } + } + if let DocumentNodeImplementation::Network(nested) = &node.implementation { + collect_network_resources(nested, out); + } + } +} + #[cfg(test)] mod network_interface_tests { use crate::test_utils::test_prelude::*; diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index e188278989..629c6332d5 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -50,6 +50,7 @@ pub enum PortfolioMessage { }, DestroyAllDocuments, EditorPreferences, + GarbageCollectResources, FontCatalogLoaded { catalog: FontCatalog, }, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 8dd03294ec..8a84049ea6 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -26,6 +26,7 @@ use crate::messages::tool::utility_types::{HintData, ToolType}; use crate::messages::viewport::ToPhysical; use crate::node_graph_executor::{ExportConfig, NodeGraphExecutor}; use glam::{DAffine2, DVec2}; +use graph_craft::application_io::ResourceHash; use graph_craft::document::NodeId; use graphene_std::Color; use graphene_std::raster_types::Image; @@ -35,6 +36,7 @@ use graphene_std::text::Font; use graphene_std::vector::misc::HandleId; use graphene_std::vector::{PointId, SegmentId, Vector, VectorModificationType}; use std::path::PathBuf; +use std::sync::Arc; use std::vec; #[derive(ExtractField)] @@ -46,6 +48,7 @@ pub struct PortfolioMessageContext<'a> { pub reset_node_definitions_on_open: bool, pub timing_information: TimingInformation, pub viewport: &'a ViewportMessageHandler, + pub resources: &'a ResourceMessageHandler, } #[derive(Debug, Default, ExtractField)] @@ -74,6 +77,7 @@ impl MessageHandler> for Portfolio reset_node_definitions_on_open, timing_information, viewport, + resources, } = context; match message { @@ -90,6 +94,7 @@ impl MessageHandler> for Portfolio current_tool, preferences, viewport, + resources, data_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Data) && !self.workspace_panel_layout.focus_document, layers_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.workspace_panel_layout.focus_document, properties_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Properties) && !self.workspace_panel_layout.focus_document, @@ -103,7 +108,6 @@ impl MessageHandler> for Portfolio }; self.persistent_state.process_message(message, responses, context); } - // Messages PortfolioMessage::Init => { responses.add(PersistentStateMessage::ReadState); @@ -159,6 +163,7 @@ impl MessageHandler> for Portfolio current_tool, preferences, viewport, + resources, data_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Data) && !self.workspace_panel_layout.focus_document, layers_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.workspace_panel_layout.focus_document, properties_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Properties) && !self.workspace_panel_layout.focus_document, @@ -183,6 +188,7 @@ impl MessageHandler> for Portfolio responses.add(PortfolioMessage::AutoSaveDocument { document_id: *document_id }); } } + responses.add(PortfolioMessage::GarbageCollectResources); } PortfolioMessage::AutoSaveDocument { document_id } => { let Some(document) = self.document(document_id) else { return }; @@ -437,6 +443,23 @@ impl MessageHandler> for Portfolio } } PortfolioMessage::EditorPreferences => self.executor.update_editor_preferences(preferences.editor_preferences()), + PortfolioMessage::GarbageCollectResources => { + let mut used_resources = HashSet::new(); + for (id, info) in self.unloaded_documents.iter() { + if let Some(resources) = &info.resources { + used_resources.extend(resources.iter()); + } else { + responses.add(PersistentStateMessage::ReadDocument { document_id: *id }); + return; + } + } + for document in self.documents.values() { + used_resources.extend(document.used_resources()); + } + responses.add(ResourceMessage::GarbageCollect { + used: Vec::from_iter(used_resources).into_boxed_slice(), + }); + } PortfolioMessage::LoadDocumentResources { document_id } => { let catalog = &self.cached_data.font_catalog; @@ -755,6 +778,17 @@ impl MessageHandler> for Portfolio } }; + if let Some(resources) = document.embedded_resources.take() { + resources.into_iter().for_each(|(hash, resource)| { + let data: Arc<[u8]> = Arc::from(resource.as_ref()); + if ResourceHash::from(data.as_ref()) != hash { + log::error!("Resource hash mismatch for resource with hash {hash}"); + return; + } + responses.add(ResourceMessage::Write { data }); + }); + } + // Upgrade the document's nodes to be compatible with the latest version document_migration_upgrades(&mut document, reset_node_definitions_on_open); @@ -1869,6 +1903,7 @@ impl PortfolioMessageHandler { name: document.name.clone(), path: document.path.clone(), is_saved: document.is_saved(), + resources: Some(document.used_resources().into_iter().collect()), }) } else { self.unloaded_documents.get(&document_id).cloned() diff --git a/editor/src/messages/prelude.rs b/editor/src/messages/prelude.rs index 869d6d8d1e..878fedf500 100644 --- a/editor/src/messages/prelude.rs +++ b/editor/src/messages/prelude.rs @@ -31,6 +31,7 @@ pub use crate::messages::portfolio::document::{DocumentMessage, DocumentMessageC pub use crate::messages::portfolio::persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageDiscriminant, PersistentStateMessageHandler}; pub use crate::messages::portfolio::{PortfolioMessage, PortfolioMessageContext, PortfolioMessageDiscriminant, PortfolioMessageHandler}; pub use crate::messages::preferences::{PreferencesMessage, PreferencesMessageDiscriminant, PreferencesMessageHandler}; +pub use crate::messages::resource::{ResourceMessage, ResourceMessageContext, ResourceMessageDiscriminant, ResourceMessageHandler}; pub use crate::messages::tool::transform_layer::{TransformLayerMessage, TransformLayerMessageDiscriminant, TransformLayerMessageHandler}; pub use crate::messages::tool::{ToolMessage, ToolMessageContext, ToolMessageDiscriminant, ToolMessageHandler}; pub use crate::messages::viewport::{ViewportMessage, ViewportMessageDiscriminant, ViewportMessageHandler}; diff --git a/editor/src/messages/resource/mod.rs b/editor/src/messages/resource/mod.rs new file mode 100644 index 0000000000..c29c9f1cf9 --- /dev/null +++ b/editor/src/messages/resource/mod.rs @@ -0,0 +1,7 @@ +mod resource_message; +mod resource_message_handler; + +#[doc(inline)] +pub use resource_message::{ResourceMessage, ResourceMessageDiscriminant}; +#[doc(inline)] +pub use resource_message_handler::{ResourceMessageContext, ResourceMessageHandler, ResourcesHandle}; diff --git a/editor/src/messages/resource/resource_message.rs b/editor/src/messages/resource/resource_message.rs new file mode 100644 index 0000000000..13dfc5d029 --- /dev/null +++ b/editor/src/messages/resource/resource_message.rs @@ -0,0 +1,10 @@ +use crate::messages::prelude::*; +use graph_craft::application_io::ResourceHash; +use std::sync::Arc; + +#[impl_message(Message, Resource)] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum ResourceMessage { + Write { data: Arc<[u8]> }, + GarbageCollect { used: Box<[ResourceHash]> }, +} diff --git a/editor/src/messages/resource/resource_message_handler.rs b/editor/src/messages/resource/resource_message_handler.rs new file mode 100644 index 0000000000..7ffb9f6bd8 --- /dev/null +++ b/editor/src/messages/resource/resource_message_handler.rs @@ -0,0 +1,88 @@ +use crate::messages::prelude::*; +use graph_craft::application_io::{Resource, ResourceFuture, ResourceHash, ResourceStorage, Resources}; +use std::sync::{Arc, RwLock}; + +#[derive(Clone)] +pub struct ResourcesHandle { + inner: Arc>>, +} + +impl Resources for ResourcesHandle { + fn load(&self, hash: ResourceHash) -> ResourceFuture { + let guard = self.inner.read().unwrap(); + guard.load(hash) + } +} + +#[derive(ExtractField)] +pub struct ResourceMessageHandler { + storage: Option>>>, +} + +impl ResourceMessageHandler { + pub fn new(resource_storage: Box) -> Self { + Self { + storage: Some(Arc::new(RwLock::new(resource_storage))), + } + } + + pub fn export(&self, resources: &[ResourceHash]) -> Box<[(ResourceHash, Resource)]> { + let Some(storage) = &self.storage else { + log::error!("Attempted to export resources but storage is not initialized"); + return Box::new([]); + }; + let mut storage = storage.write().unwrap(); + resources.iter().filter_map(|hash| storage.read(hash).map(|resource| (*hash, resource))).collect() + } + + pub fn resources(&self) -> Box { + Box::new(ResourcesHandle { + inner: self.storage.clone().expect("Resource storage not initialized"), + }) + } +} + +impl std::fmt::Debug for ResourceMessageHandler { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResourceMessageHandler").finish_non_exhaustive() + } +} + +impl Default for ResourceMessageHandler { + #[cfg(not(test))] + fn default() -> Self { + Self { storage: None } + } + + #[cfg(test)] + fn default() -> Self { + Self { + storage: Some(Arc::new(RwLock::new(Box::new(graph_craft::application_io::HashMapResourceStorage::new())))), + } + } +} + +#[derive(ExtractField)] +pub struct ResourceMessageContext {} + +#[message_handler_data] +impl MessageHandler for ResourceMessageHandler { + fn process_message(&mut self, message: ResourceMessage, _responses: &mut VecDeque, _context: ResourceMessageContext) { + let Some(storage) = &self.storage else { + log::error!("Received resource message but storage is not initialized"); + return; + }; + let mut storage = storage.write().unwrap(); + + match message { + ResourceMessage::Write { data } => { + let _hash = storage.write(data.as_ref()); + } + ResourceMessage::GarbageCollect { used } => { + storage.garbage_collect(&used); + } + } + } + + advertise_actions!(ResourceMessageDiscriminant;); +} diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index abe6dca28d..07267b8ee1 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -97,7 +97,7 @@ pub struct ExportConfig { struct InternalNodeGraphUpdateSender(Sender); impl InternalNodeGraphUpdateSender { - fn send_generation_response(&self, response: CompilationResponse) { + fn send_compilation_response(&self, response: CompilationResponse) { self.0.send(NodeGraphUpdate::CompilationResponse(response)).expect("Failed to send response") } @@ -134,7 +134,11 @@ impl NodeRuntime { editor_preferences: Box::new(EditorPreferences::default()), node_graph_message_sender: Box::new(InternalNodeGraphUpdateSender(sender)), + #[cfg(not(test))] application_io: None, + + #[cfg(test)] + application_io: Some(PlatformApplicationIo::default().into()), } .into(), @@ -154,19 +158,6 @@ impl NodeRuntime { } pub async fn run(&mut self) -> Option { - if self.editor_api.application_io.is_none() { - self.editor_api = PlatformEditorApi { - #[cfg(all(not(test), target_family = "wasm"))] - application_io: Some(PlatformApplicationIo::new().await.into()), - #[cfg(any(test, not(target_family = "wasm")))] - application_io: Some(PlatformApplicationIo::new().await.into()), - font_cache: self.editor_api.font_cache.clone(), - node_graph_message_sender: Box::new(self.sender.clone()), - editor_preferences: Box::new(self.editor_preferences.clone()), - } - .into(); - } - let mut font = None; let mut preferences = None; let mut graph = None; @@ -247,7 +238,7 @@ impl NodeRuntime { self.update_thumbnails = true; - self.sender.send_generation_response(CompilationResponse { result, node_graph_errors }); + self.sender.send_compilation_response(CompilationResponse { result, node_graph_errors }); } GraphRuntimeRequest::ExecutionRequest(ExecutionRequest { execution_id, mut render_config, .. }) => { // We may want to render via the SVG pipeline even though raster was requested, if SVG Preview render mode is active or WebGPU/Vello is unavailable @@ -575,14 +566,20 @@ pub async fn replace_node_runtime(runtime: NodeRuntime) -> Option { let mut node_runtime = NODE_RUNTIME.lock(); node_runtime.replace(runtime) } -pub async fn replace_application_io(application_io: PlatformApplicationIo) { +pub(crate) async fn replace_application_io(application_io: PlatformApplicationIo) { let mut node_runtime = NODE_RUNTIME.lock(); if let Some(node_runtime) = &mut *node_runtime { - node_runtime.editor_api = PlatformEditorApi { - font_cache: node_runtime.editor_api.font_cache.clone(), + node_runtime.replace_application_io(application_io); + } +} + +impl NodeRuntime { + pub(crate) fn replace_application_io(&mut self, application_io: PlatformApplicationIo) { + self.editor_api = PlatformEditorApi { + font_cache: self.editor_api.font_cache.clone(), application_io: Some(application_io.into()), - node_graph_message_sender: Box::new(node_runtime.sender.clone()), - editor_preferences: Box::new(node_runtime.editor_preferences.clone()), + node_graph_message_sender: Box::new(self.sender.clone()), + editor_preferences: Box::new(self.editor_preferences.clone()), } .into(); } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 96cd92b9fd..406baa38d1 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -23,7 +23,7 @@ // Create the editor and subscriptions router const randomSeed = BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)); subscriptions = createSubscriptionsRouter(); - editor = EditorWrapper.create(operatingSystem(), randomSeed, (messageType: MessageName, messageData: FrontendMessage) => { + editor = await EditorWrapper.create(operatingSystem(), randomSeed, (messageType: MessageName, messageData: FrontendMessage) => { subscriptions?.handleFrontendMessage(messageType, messageData); }); diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 306f338af7..369f3b5891 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -11,6 +11,7 @@ use crate::helpers::poll_node_graph_evaluation; use crate::helpers::{auto_save_all_documents, calculate_hash, render_image_data_to_canvases, request_animation_frame, set_timeout, translate_key, wrapper}; use crate::{EDITOR_HAS_CRASHED, EDITOR_WRAPPER, Error, FRONTEND_READY, MESSAGE_BUFFER, PANIC_DIALOG_MESSAGE_CALLBACK}; #[cfg(not(feature = "native"))] +#[cfg(all(not(feature = "native"), target_family = "wasm"))] use editor::application::{Editor, Environment, Host, Platform}; use editor::consts::FILE_EXTENSION; use editor::messages::clipboard::utility_types::ClipboardContentRaw; @@ -22,6 +23,8 @@ use editor::messages::portfolio::document::utility_types::network_interface::Imp use editor::messages::portfolio::utility_types::{DockingSplitDirection, FontCatalog, FontCatalogFamily, PanelGroupId, PanelType}; use editor::messages::prelude::*; use editor::messages::tool::tool_messages::tool_prelude::WidgetId; +#[cfg(all(not(feature = "native"), target_family = "wasm"))] +use graph_craft::application_io::IndexedDbResourceStorage; use graph_craft::document::NodeId; use graphene_std::color::SRGBA8; use graphene_std::graphene_hash::CacheHashWrapper; @@ -69,22 +72,29 @@ impl EditorWrapper { // Editor wrapper machinery // ======================== - #[cfg(not(feature = "native"))] - pub fn create(platform: String, uuid_random_seed: u64, frontend_message_handler_callback: js_sys::Function) -> EditorWrapper { - let editor = Editor::new( - Environment { - platform: Platform::Web, - host: match platform.as_str() { - "Linux" => Host::Linux, - "Mac" => Host::Mac, - "Windows" => Host::Windows, - _ => unreachable!(), - }, - }, - uuid_random_seed, - ); - - if EDITOR.with(|wrapper| wrapper.lock().ok().map(|mut guard| *guard = Some(editor))).is_none() { + #[cfg(all(not(feature = "native"), target_family = "wasm"))] + pub async fn create(platform: String, uuid_random_seed: u64, frontend_message_handler_callback: js_sys::Function) -> EditorWrapper { + use graph_craft::application_io::PlatformApplicationIo; + + let host = match platform.as_str() { + "Linux" => Host::Linux, + "Mac" => Host::Mac, + "Windows" => Host::Windows, + _ => unreachable!(), + }; + + let storage: Box = match IndexedDbResourceStorage::load("graphite-resources").await { + Ok(storage) => Box::new(storage), + Err(error) => { + log::error!("Failed to open IndexedDB resource storage, falling back to in-memory: {error:?}"); + Box::new(graph_craft::application_io::HashMapResourceStorage::new()) + } + }; + + let mut editor = Editor::new(Environment { platform: Platform::Web, host }, uuid_random_seed, storage); + editor.replace_application_io(PlatformApplicationIo::new().await).await; + + if EDITOR.with(|slot| slot.lock().ok().map(|mut guard| *guard = Some(editor))).is_none() { log::error!("Attempted to initialize the editor more than once"); } diff --git a/node-graph/graph-craft/Cargo.toml b/node-graph/graph-craft/Cargo.toml index d49b0ad128..e0f1f794f5 100644 --- a/node-graph/graph-craft/Cargo.toml +++ b/node-graph/graph-craft/Cargo.toml @@ -50,12 +50,30 @@ wasm-bindgen = { workspace = true, optional = true } # Workspace dependencies [target.'cfg(target_family = "wasm")'.dependencies] -web-sys = { workspace = true, features = ["Navigator", "Gpu"] } +web-sys = { workspace = true, features = [ + "Navigator", + "Gpu", + "IdbFactory", + "IdbDatabase", + "IdbObjectStore", + "IdbObjectStoreParameters", + "IdbTransaction", + "IdbTransactionMode", + "IdbRequest", + "IdbOpenDbRequest", + "IdbVersionChangeEvent", + "DomException", + "DomStringList", + "Event", + "EventTarget", +] } js-sys = { workspace = true } wasm-bindgen = { workspace = true } +wasm-bindgen-futures = { workspace = true } [target.'cfg(not(target_family = "wasm"))'.dependencies] winit = { workspace = true } +mmap-io = { workspace = true } [dev-dependencies] # Workspace dependencies diff --git a/node-graph/graph-craft/src/application_io.rs b/node-graph/graph-craft/src/application_io.rs index b1ecf42669..5f03f0200b 100644 --- a/node-graph/graph-craft/src/application_io.rs +++ b/node-graph/graph-craft/src/application_io.rs @@ -1,14 +1,93 @@ use dyn_any::StaticType; +#[cfg(feature = "wgpu")] +use wgpu_executor::WgpuExecutor; -#[cfg(not(target_family = "wasm"))] -mod native; -#[cfg(target_family = "wasm")] -mod wasm; +pub mod resource; -#[cfg(not(target_family = "wasm"))] -pub type PlatformApplicationIo = native::NativeApplicationIo; +pub use graphene_application_io::{ApplicationIo, Resource, ResourceFuture, ResourceHash, ResourceStorage, Resources}; +pub use resource::HashMapResourceStorage; #[cfg(target_family = "wasm")] -pub type PlatformApplicationIo = wasm::WasmApplicationIo; +pub use resource::indexed_db::IndexedDbResourceStorage; +#[cfg(not(target_family = "wasm"))] +pub use resource::mmap::MmapResourceStorage; + +pub struct PlatformApplicationIo { + #[cfg(feature = "wgpu")] + pub(crate) gpu_executor: Option, + resources: Option>, +} + +impl PlatformApplicationIo { + pub async fn new() -> Self { + #[cfg(feature = "wgpu")] + let executor = WgpuExecutor::new().await; + + #[cfg(not(feature = "wgpu"))] + let wgpu_available = false; + #[cfg(feature = "wgpu")] + let wgpu_available = executor.is_some(); + set_wgpu_available(wgpu_available); + + Self { + #[cfg(feature = "wgpu")] + gpu_executor: executor, + resources: None, + } + } + + #[cfg(feature = "wgpu")] + pub fn new_with_context(context: wgpu_executor::WgpuContext) -> Self { + let executor = WgpuExecutor::with_context(context); + + let wgpu_available = executor.is_some(); + set_wgpu_available(wgpu_available); + + Self { + gpu_executor: executor, + resources: None, + } + } + + pub fn inject_resources(&mut self, resources: Box) { + self.resources = Some(resources); + } +} + +impl ApplicationIo for PlatformApplicationIo { + #[cfg(feature = "wgpu")] + type Executor = WgpuExecutor; + #[cfg(not(feature = "wgpu"))] + type Executor = (); + + #[cfg(feature = "wgpu")] + fn gpu_executor(&self) -> Option<&Self::Executor> { + self.gpu_executor.as_ref() + } + + fn load_resource(&self, hash: ResourceHash) -> graphene_application_io::ResourceFuture { + self.resources.as_ref().expect("Resource storage not initialized").load(hash) + } +} + +impl Default for PlatformApplicationIo { + fn default() -> Self { + Self { + #[cfg(feature = "wgpu")] + gpu_executor: None, + resources: None, + } + } +} + +impl std::fmt::Debug for PlatformApplicationIo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PlatformApplicationIo").finish_non_exhaustive() + } +} + +unsafe impl StaticType for PlatformApplicationIo { + type Static = PlatformApplicationIo; +} pub type PlatformEditorApi = graphene_application_io::EditorApi; diff --git a/node-graph/graph-craft/src/application_io/native.rs b/node-graph/graph-craft/src/application_io/native.rs deleted file mode 100644 index 8fe4866131..0000000000 --- a/node-graph/graph-craft/src/application_io/native.rs +++ /dev/null @@ -1,113 +0,0 @@ -use dyn_any::StaticType; -use graphene_application_io::{ApplicationError, ApplicationIo, ResourceFuture}; -use std::collections::HashMap; -use std::sync::Arc; -#[cfg(feature = "tokio")] -use tokio::io::AsyncReadExt; -#[cfg(target_family = "wasm")] -use wasm_bindgen::JsCast; -#[cfg(feature = "wgpu")] -use wgpu_executor::WgpuExecutor; - -#[derive(Debug, Default)] -pub struct NativeApplicationIo { - #[cfg(feature = "wgpu")] - pub(crate) gpu_executor: Option, - pub resources: HashMap>, -} - -impl NativeApplicationIo { - pub async fn new() -> Self { - #[cfg(feature = "wgpu")] - let executor = WgpuExecutor::new().await; - - #[cfg(not(feature = "wgpu"))] - let wgpu_available = false; - #[cfg(feature = "wgpu")] - let wgpu_available = executor.is_some(); - super::set_wgpu_available(wgpu_available); - - let mut io = Self { - #[cfg(feature = "wgpu")] - gpu_executor: executor, - resources: HashMap::new(), - }; - io.resources.insert("null".to_string(), Arc::from(include_bytes!("../null.png").to_vec())); - - io - } - - #[cfg(feature = "wgpu")] - pub fn new_with_context(context: wgpu_executor::WgpuContext) -> Self { - #[cfg(feature = "wgpu")] - let executor = WgpuExecutor::with_context(context); - - #[cfg(not(feature = "wgpu"))] - let wgpu_available = false; - #[cfg(feature = "wgpu")] - let wgpu_available = executor.is_some(); - super::set_wgpu_available(wgpu_available); - - let mut io = Self { - gpu_executor: executor, - resources: HashMap::new(), - }; - - io.resources.insert("null".to_string(), Arc::from(include_bytes!("../null.png").to_vec())); - - io - } -} - -impl ApplicationIo for NativeApplicationIo { - #[cfg(feature = "wgpu")] - type Executor = WgpuExecutor; - #[cfg(not(feature = "wgpu"))] - type Executor = (); - - #[cfg(feature = "wgpu")] - fn gpu_executor(&self) -> Option<&Self::Executor> { - self.gpu_executor.as_ref() - } - - fn load_resource(&self, url: impl AsRef) -> Result { - let url = url::Url::parse(url.as_ref()).map_err(|_| ApplicationError::InvalidUrl)?; - log::trace!("Loading resource: {url:?}"); - match url.scheme() { - #[cfg(feature = "tokio")] - "file" => { - let path = url.to_file_path().map_err(|_| ApplicationError::NotFound)?; - let path = path.to_str().ok_or(ApplicationError::NotFound)?; - let path = path.to_owned(); - Ok(Box::pin(async move { - let file = tokio::fs::File::open(path).await.map_err(|_| ApplicationError::NotFound)?; - let mut reader = tokio::io::BufReader::new(file); - let mut data = Vec::new(); - reader.read_to_end(&mut data).await.map_err(|_| ApplicationError::NotFound)?; - Ok(Arc::from(data)) - }) as ResourceFuture) - } - "http" | "https" => { - let url = url.to_string(); - Ok(Box::pin(async move { - let client = reqwest::Client::new(); - let response = client.get(url).send().await.map_err(|_| ApplicationError::NotFound)?; - let data = response.bytes().await.map_err(|_| ApplicationError::NotFound)?; - Ok(Arc::from(data.to_vec())) - }) as ResourceFuture) - } - "graphite" => { - let path = url.path(); - let path = path.to_owned(); - log::trace!("Loading local resource: {path}"); - let data = self.resources.get(&path).ok_or(ApplicationError::NotFound)?.clone(); - Ok(Box::pin(async move { Ok(data.clone()) }) as ResourceFuture) - } - _ => Err(ApplicationError::NotFound), - } - } -} - -unsafe impl StaticType for NativeApplicationIo { - type Static = NativeApplicationIo; -} diff --git a/node-graph/graph-craft/src/application_io/resource.rs b/node-graph/graph-craft/src/application_io/resource.rs new file mode 100644 index 0000000000..0601210888 --- /dev/null +++ b/node-graph/graph-craft/src/application_io/resource.rs @@ -0,0 +1,47 @@ +#[cfg(target_family = "wasm")] +pub mod indexed_db; +#[cfg(not(target_family = "wasm"))] +pub mod mmap; + +use graphene_application_io::{Resource, ResourceFuture, ResourceHash, ResourceStorage, Resources}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Default)] +pub struct HashMapResourceStorage { + resources: Mutex>, +} + +impl HashMapResourceStorage { + pub fn new() -> Self { + Self::default() + } +} + +impl Resources for HashMapResourceStorage { + fn load(&self, hash: ResourceHash) -> ResourceFuture { + let result = self.resources.lock().unwrap().get(&hash).cloned(); + Box::pin(async move { result }) + } +} + +impl ResourceStorage for HashMapResourceStorage { + fn read(&mut self, hash: &ResourceHash) -> Option { + self.resources.get_mut().unwrap().get(hash).cloned() + } + + fn write(&mut self, data: &[u8]) -> ResourceHash { + let hash = ResourceHash::from(data); + self.resources.get_mut().unwrap().insert(hash, Resource::new(Arc::<[u8]>::from(data))); + hash + } + + fn contains(&mut self, hash: &ResourceHash) -> bool { + self.resources.get_mut().unwrap().contains_key(hash) + } + + fn garbage_collect(&mut self, used: &[ResourceHash]) { + let used_set: std::collections::HashSet<&ResourceHash> = used.iter().collect(); + self.resources.get_mut().unwrap().retain(|hash, _| used_set.contains(hash)); + } +} diff --git a/node-graph/graph-craft/src/application_io/resource/indexed_db.rs b/node-graph/graph-craft/src/application_io/resource/indexed_db.rs new file mode 100644 index 0000000000..960e3e2584 --- /dev/null +++ b/node-graph/graph-craft/src/application_io/resource/indexed_db.rs @@ -0,0 +1,298 @@ +use graphene_application_io::{Resource, ResourceFuture, ResourceHash, ResourceStorage, Resources}; +use std::collections::{HashMap, HashSet}; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use wasm_bindgen::JsCast; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::prelude::JsValue; +use wasm_bindgen_futures::{JsFuture, spawn_local}; +use web_sys::{IdbDatabase, IdbFactory, IdbOpenDbRequest, IdbRequest, IdbTransactionMode}; + +const STORE_NAME: &str = "resources"; +const DATABASE_VERSION: u32 = 1; + +pub struct IndexedDbResourceStorage { + db: IdbDatabase, + store_name: Arc, + cache: Arc>>, + known_keys: Arc>>, +} + +impl IndexedDbResourceStorage { + pub async fn load(database_name: &str) -> Result { + let factory = indexed_db_factory()?; + let open_request = factory.open_with_u32(database_name, DATABASE_VERSION)?; + let store_name = STORE_NAME.to_string(); + + let store_name_for_upgrade = store_name.clone(); + let on_upgrade = Closure::once_into_js(move |event: web_sys::Event| { + let Some(target) = event.target() else { return }; + let Ok(request) = target.dyn_into::() else { return }; + let Ok(db_value) = request.result() else { return }; + let Ok(db) = db_value.dyn_into::() else { return }; + if db.object_store_names().contains(&store_name_for_upgrade) { + if let Err(error) = db.delete_object_store(&store_name_for_upgrade) { + log::error!("Failed to delete stale IndexedDB object store {store_name_for_upgrade:?}: {error:?}"); + } + } + if let Err(error) = db.create_object_store(&store_name_for_upgrade) { + log::error!("Failed to create IndexedDB object store {store_name_for_upgrade:?}: {error:?}"); + } + }); + open_request.set_onupgradeneeded(Some(on_upgrade.unchecked_ref())); + + let result = await_request(open_request.unchecked_ref::()).await?; + let db = result.dyn_into::()?; + + let known_keys = fetch_known_keys(&db, &store_name).await?; + + Ok(Self { + db, + store_name: Arc::new(store_name), + cache: Arc::new(Mutex::new(HashMap::new())), + known_keys: Arc::new(Mutex::new(known_keys)), + }) + } + + fn enqueue_delete(&self, hash: ResourceHash) { + let db = self.db.clone(); + let store_name = self.store_name.clone(); + + spawn_local(async move { + let transaction = match db.transaction_with_str_and_mode(&store_name, IdbTransactionMode::Readwrite) { + Ok(transaction) => transaction, + Err(error) => { + log::error!("Failed to open IndexedDB transaction: {error:?}"); + return; + } + }; + + let store = match transaction.object_store(&store_name) { + Ok(store) => store, + Err(error) => { + log::error!("Failed to access IndexedDB object store {store_name:?}: {error:?}"); + return; + } + }; + + let key = JsValue::from_str(&hash.to_hex()); + + let request = match store.delete(&key) { + Ok(request) => request, + Err(error) => { + log::error!("Failed to enqueue IndexedDB delete: {error:?}"); + return; + } + }; + + if let Err(error) = await_request(&request).await { + log::error!("IndexedDB delete failed: {error:?}"); + } + }); + } + + fn enqueue_put(&self, hash: ResourceHash, data: Vec) { + let db = self.db.clone(); + let store_name = self.store_name.clone(); + + spawn_local(async move { + let transaction = match db.transaction_with_str_and_mode(&store_name, IdbTransactionMode::Readwrite) { + Ok(transaction) => transaction, + Err(error) => { + log::error!("Failed to open IndexedDB transaction: {error:?}"); + return; + } + }; + + let store = match transaction.object_store(&store_name) { + Ok(store) => store, + Err(error) => { + log::error!("Failed to access IndexedDB object store {store_name:?}: {error:?}"); + return; + } + }; + + let key = JsValue::from_str(&hash.to_hex()); + let value = js_sys::Uint8Array::from(data.as_slice()); + + let request = match store.put_with_key(&value, &key) { + Ok(request) => request, + Err(error) => { + log::error!("Failed to enqueue IndexedDB put: {error:?}"); + return; + } + }; + + if let Err(error) = await_request(&request).await { + log::error!("IndexedDB put failed: {error:?}"); + } + }); + } +} + +impl Resources for IndexedDbResourceStorage { + fn load(&self, hash: ResourceHash) -> ResourceFuture { + let db = self.db.clone(); + let store_name = self.store_name.clone(); + let cache = self.cache.clone(); + let known_keys = self.known_keys.clone(); + + Box::pin(UnsafeSendFuture(async move { + if let Some(resource) = cache.lock().unwrap().get(&hash) { + return Some(resource.clone()); + } + if !known_keys.lock().unwrap().contains(&hash) { + return None; + } + + match fetch_one(&db, &store_name, hash).await { + Ok(Some(payload)) => { + let recomputed = ResourceHash::from(payload.as_slice()); + if recomputed != hash { + log::warn!("IndexedDB entry's hash does not match its payload: {hash} vs {recomputed}"); + known_keys.lock().unwrap().remove(&hash); + None + } else { + let resource = Resource::new(Arc::<[u8]>::from(payload)); + cache.lock().unwrap().insert(hash, resource.clone()); + Some(resource) + } + } + Ok(None) => { + known_keys.lock().unwrap().remove(&hash); + None + } + Err(error) => { + log::error!("IndexedDB fetch for {hash} failed: {error:?}"); + None + } + } + })) + } +} + +impl ResourceStorage for IndexedDbResourceStorage { + fn read(&mut self, hash: &ResourceHash) -> Option { + self.cache.lock().unwrap().get(hash).cloned() + } + + fn write(&mut self, data: &[u8]) -> ResourceHash { + let hash = ResourceHash::from(data); + self.cache.lock().unwrap().insert(hash, Resource::new(Arc::<[u8]>::from(data))); + self.known_keys.lock().unwrap().insert(hash); + self.enqueue_put(hash, data.to_vec()); + hash + } + + fn contains(&mut self, hash: &ResourceHash) -> bool { + self.cache.lock().unwrap().contains_key(hash) || self.known_keys.lock().unwrap().contains(hash) + } + + fn garbage_collect(&mut self, used: &[ResourceHash]) { + let used_set: std::collections::HashSet = used.iter().cloned().collect(); + + let to_delete: Vec = { + let mut known = self.known_keys.lock().unwrap(); + let to_delete: Vec = known.iter().filter(|h| !used_set.contains(h)).cloned().collect(); + for hash in &to_delete { + known.remove(hash); + } + to_delete + }; + self.cache.lock().unwrap().retain(|hash, _| used_set.contains(hash)); + + for hash in to_delete { + self.enqueue_delete(hash); + } + } +} + +async fn fetch_known_keys(db: &IdbDatabase, store_name: &str) -> Result, JsValue> { + let transaction = db.transaction_with_str(store_name)?; + let store = transaction.object_store(store_name)?; + let keys_request = store.get_all_keys()?; + let keys = await_request(&keys_request).await?; + let keys_array = keys.dyn_into::()?; + + let mut set = HashSet::with_capacity(keys_array.length() as usize); + for key in keys_array.iter() { + let Some(key_string) = key.as_string() else { + log::warn!("Skipping IndexedDB entry whose key is not a string"); + continue; + }; + match ResourceHash::try_from(key_string.as_str()) { + Ok(hash) => { + set.insert(hash); + } + Err(error) => log::warn!("Skipping IndexedDB entry whose key {key_string:?} is not a valid resource hash: {error}"), + } + } + Ok(set) +} + +async fn fetch_one(db: &IdbDatabase, store_name: &str, hash: ResourceHash) -> Result>, JsValue> { + let transaction = db.transaction_with_str(store_name)?; + let store = transaction.object_store(store_name)?; + let key = JsValue::from_str(&hash.to_hex()); + let request = store.get(&key)?; + let result = await_request(&request).await?; + if result.is_undefined() || result.is_null() { + return Ok(None); + } + let bytes = result.dyn_into::()?; + Ok(Some(bytes.to_vec())) +} + +fn indexed_db_factory() -> Result { + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no global window"))?; + window.indexed_db()?.ok_or_else(|| JsValue::from_str("indexedDB is unavailable")) +} + +async fn await_request(request: &IdbRequest) -> Result { + let promise = js_sys::Promise::new(&mut |resolve, reject| { + let reject_for_error = reject.clone(); + let request_for_success = request.clone(); + let on_success = Closure::once_into_js(move |_event: web_sys::Event| match request_for_success.result() { + Ok(value) => { + let _ = resolve.call1(&JsValue::NULL, &value); + } + Err(error) => { + let _ = reject.call1(&JsValue::NULL, &error); + } + }); + request.set_onsuccess(Some(on_success.unchecked_ref())); + + let request_for_error = request.clone(); + let on_error = Closure::once_into_js(move |_event: web_sys::Event| { + let error = request_for_error + .error() + .ok() + .flatten() + .map(JsValue::from) + .unwrap_or_else(|| JsValue::from_str("IndexedDB request error")); + let _ = reject_for_error.call1(&JsValue::NULL, &error); + }); + request.set_onerror(Some(on_error.unchecked_ref())); + }); + + JsFuture::from(promise).await +} + +// SAFETY: wasm is single-threaded; the non-`Send`/`Sync` JS handles are never observed across threads. +unsafe impl Send for IndexedDbResourceStorage {} +unsafe impl Sync for IndexedDbResourceStorage {} + +// SAFETY: Only constructed in wasm contexts where there is a single thread; the inner future's +// non-`Send` state is therefore never observed from another thread. +struct UnsafeSendFuture(F); +unsafe impl Send for UnsafeSendFuture {} +impl Future for UnsafeSendFuture { + type Output = F::Output; + + fn poll(self: Pin<&mut Self>, cx: &mut core::task::Context<'_>) -> core::task::Poll { + // SAFETY: `UnsafeSendFuture` only contains `F`, so projecting `Pin<&mut Self>` to + // `Pin<&mut F>` preserves the pinning guarantee. + unsafe { self.map_unchecked_mut(|s| &mut s.0) }.poll(cx) + } +} diff --git a/node-graph/graph-craft/src/application_io/resource/mmap.rs b/node-graph/graph-craft/src/application_io/resource/mmap.rs new file mode 100644 index 0000000000..ccd4df8d51 --- /dev/null +++ b/node-graph/graph-craft/src/application_io/resource/mmap.rs @@ -0,0 +1,160 @@ +use graphene_application_io::{Resource, ResourceFuture, ResourceHash, ResourceStorage, Resources}; +use mmap_io::mmap::{MemoryMappedFile, MmapMode}; +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::RwLock; + +pub struct MmapResourceStorage { + root: PathBuf, + cache: RwLock>, +} + +impl MmapResourceStorage { + pub fn new(root: impl Into) -> std::io::Result { + let root = root.into(); + fs::create_dir_all(&root)?; + Ok(Self { + root, + cache: RwLock::new(HashMap::new()), + }) + } + + fn path_for(&self, hash: &ResourceHash) -> PathBuf { + let hash = String::from(hash); + let mut path = self.root.clone(); + path.push(&hash[..2]); + path.push(&hash[2..]); + path + } + + fn open_mmap(path: &Path) -> Option { + match MemoryMappedFile::builder(path).mode(MmapMode::ReadOnly).huge_pages(true).open() { + Ok(file) => Some(file), + Err(error) => { + log::warn!("Failed to mmap {path:?} retrying without huge pages: {error}"); + + match MemoryMappedFile::open_ro(path) { + Ok(file) => Some(file), + Err(error) => { + log::error!("Failed to mmap {path:?}: {error}"); + None + } + } + } + } + } + + fn lookup(&self, hash: &ResourceHash) -> Option { + if let Some(resource) = self.cache.read().unwrap_or_else(|poisoned| poisoned.into_inner()).get(hash) { + return Some(resource.clone()); + } + + let path = self.path_for(hash); + let mmap = Self::open_mmap(&path)?; + let resource = Resource::new(MmappedBytes(mmap)); + + self.cache.write().unwrap_or_else(|poisoned| poisoned.into_inner()).insert(*hash, resource.clone()); + Some(resource) + } +} + +impl Resources for MmapResourceStorage { + fn load(&self, hash: ResourceHash) -> ResourceFuture { + let result = self.lookup(&hash); + Box::pin(async move { result }) + } +} + +impl ResourceStorage for MmapResourceStorage { + fn read(&mut self, hash: &ResourceHash) -> Option { + self.lookup(hash) + } + + fn write(&mut self, data: &[u8]) -> ResourceHash { + let hash = ResourceHash::from(data); + let path = self.path_for(&hash); + + if path.exists() { + return hash; + } + + let Some(parent) = path.parent() else { + log::error!("Resource path {path:?} has no parent directory"); + return hash; + }; + if let Err(error) = fs::create_dir_all(parent) { + log::error!("Failed to create resource subdirectory {parent:?}: {error}"); + return hash; + } + + let tmp = parent.join(format!( + "tmp.{}.{}.{}", + path.file_name().and_then(|n| n.to_str()).unwrap_or(""), + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0), + )); + + let write_result = (|| -> std::io::Result<()> { + let mut file = fs::File::create(&tmp)?; + file.write_all(data)?; + file.sync_all()?; + fs::rename(&tmp, &path) + })(); + + if let Err(error) = write_result { + log::error!("Failed to write resource to {path:?}: {error}"); + let _ = fs::remove_file(&tmp); + } + + hash + } + + fn contains(&mut self, hash: &ResourceHash) -> bool { + self.cache.get_mut().unwrap_or_else(|poisoned| poisoned.into_inner()).contains_key(hash) || self.path_for(hash).exists() + } + + fn garbage_collect(&mut self, used: &[ResourceHash]) { + let used_set: std::collections::HashSet = used.iter().cloned().collect(); + self.cache.get_mut().unwrap_or_else(|poisoned| poisoned.into_inner()).retain(|hash, _| used_set.contains(hash)); + + let Ok(top_entries) = fs::read_dir(&self.root) else { return }; + for top_entry in top_entries.flatten() { + let top_path = top_entry.path(); + if !top_path.is_dir() { + continue; + } + let Ok(entries) = fs::read_dir(&top_path) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(prefix) = top_path.file_name().and_then(|n| n.to_str()) else { continue }; + let Some(suffix) = path.file_name().and_then(|n| n.to_str()) else { continue }; + if suffix.starts_with("tmp.") { + continue; + } + let hex = format!("{prefix}{suffix}"); + let Ok(hash) = ResourceHash::try_from(hex.as_str()) else { continue }; + if !used_set.contains(&hash) + && let Err(error) = fs::remove_file(&path) + { + log::error!("Failed to remove unused resource {path:?}: {error}"); + } + } + } + } +} + +struct MmappedBytes(MemoryMappedFile); +impl AsRef<[u8]> for MmappedBytes { + fn as_ref(&self) -> &[u8] { + let len = self.0.len(); + match self.0.as_slice(0, len) { + Ok(slice) => slice, + Err(error) => { + log::error!("Failed to obtain mmap slice: {error}"); + &[] + } + } + } +} diff --git a/node-graph/graph-craft/src/application_io/wasm.rs b/node-graph/graph-craft/src/application_io/wasm.rs deleted file mode 100644 index 307be01ebc..0000000000 --- a/node-graph/graph-craft/src/application_io/wasm.rs +++ /dev/null @@ -1,105 +0,0 @@ -use dyn_any::StaticType; -use graphene_application_io::{ApplicationError, ApplicationIo, ResourceFuture}; -use std::collections::HashMap; -use std::sync::Arc; -#[cfg(feature = "tokio")] -use tokio::io::AsyncReadExt; -#[cfg(target_family = "wasm")] -use wasm_bindgen::JsCast; -#[cfg(feature = "wgpu")] -use wgpu_executor::WgpuExecutor; - -#[derive(Debug, Default)] -pub struct WasmApplicationIo { - #[cfg(feature = "wgpu")] - pub(crate) gpu_executor: Option, - pub resources: HashMap>, -} - -impl WasmApplicationIo { - pub async fn new() -> Self { - #[cfg(feature = "wgpu")] - let executor = if let Some(gpu) = web_sys::window().map(|w| w.navigator().gpu()) { - let request_adapter = || { - let request_adapter = js_sys::Reflect::get(&gpu, &wasm_bindgen::JsValue::from_str("requestAdapter")).ok()?; - let function = request_adapter.dyn_ref::()?; - function.call0(&gpu).ok() - }; - let result = request_adapter(); - match result { - None => None, - Some(_) => WgpuExecutor::new().await, - } - } else { - None - }; - - #[cfg(not(feature = "wgpu"))] - let wgpu_available = false; - #[cfg(feature = "wgpu")] - let wgpu_available = executor.is_some(); - super::set_wgpu_available(wgpu_available); - - let mut io = Self { - #[cfg(feature = "wgpu")] - gpu_executor: executor, - resources: HashMap::new(), - }; - io.resources.insert("null".to_string(), Arc::from(include_bytes!("../null.png").to_vec())); - - io - } -} - -impl ApplicationIo for WasmApplicationIo { - #[cfg(feature = "wgpu")] - type Executor = WgpuExecutor; - #[cfg(not(feature = "wgpu"))] - type Executor = (); - - #[cfg(feature = "wgpu")] - fn gpu_executor(&self) -> Option<&Self::Executor> { - self.gpu_executor.as_ref() - } - - fn load_resource(&self, url: impl AsRef) -> Result { - let url = url::Url::parse(url.as_ref()).map_err(|_| ApplicationError::InvalidUrl)?; - log::trace!("Loading resource: {url:?}"); - match url.scheme() { - #[cfg(feature = "tokio")] - "file" => { - let path = url.to_file_path().map_err(|_| ApplicationError::NotFound)?; - let path = path.to_str().ok_or(ApplicationError::NotFound)?; - let path = path.to_owned(); - Ok(Box::pin(async move { - let file = tokio::fs::File::open(path).await.map_err(|_| ApplicationError::NotFound)?; - let mut reader = tokio::io::BufReader::new(file); - let mut data = Vec::new(); - reader.read_to_end(&mut data).await.map_err(|_| ApplicationError::NotFound)?; - Ok(Arc::from(data)) - }) as ResourceFuture) - } - "http" | "https" => { - let url = url.to_string(); - Ok(Box::pin(async move { - let client = reqwest::Client::new(); - let response = client.get(url).send().await.map_err(|_| ApplicationError::NotFound)?; - let data = response.bytes().await.map_err(|_| ApplicationError::NotFound)?; - Ok(Arc::from(data.to_vec())) - }) as ResourceFuture) - } - "graphite" => { - let path = url.path(); - let path = path.to_owned(); - log::trace!("Loading local resource: {path}"); - let data = self.resources.get(&path).ok_or(ApplicationError::NotFound)?.clone(); - Ok(Box::pin(async move { Ok(data.clone()) }) as ResourceFuture) - } - _ => Err(ApplicationError::NotFound), - } - } -} - -unsafe impl StaticType for WasmApplicationIo { - type Static = WasmApplicationIo; -} diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 5b03f110b3..b5c58a38f2 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -396,6 +396,7 @@ tagged_value! { Footprint(Footprint), VectorModification(Box), ImageData(Image), + Resource(graphene_application_io::ResourceHash), // ========== // ENUM TYPES // ========== diff --git a/node-graph/graphene-cli/src/main.rs b/node-graph/graphene-cli/src/main.rs index a14188de3c..348b682fed 100644 --- a/node-graph/graphene-cli/src/main.rs +++ b/node-graph/graphene-cli/src/main.rs @@ -121,11 +121,7 @@ async fn main() -> Result<(), Box> { let document_string = std::fs::read_to_string(document_path).expect("Failed to read document"); log::info!("Creating GPU context"); - let mut application_io = block_on(PlatformApplicationIo::new()); - - 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 application_io = block_on(PlatformApplicationIo::new()); // Convert application_io to Arc first let application_io_arc = Arc::new(application_io); diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index d0cab8003c..5a43a5a873 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -129,6 +129,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => u64]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => BlendMode]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => ImageTexture]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::application_io::Resource]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::transform::ReferencePoint]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::BooleanOperation]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::vector::style::Fill]), diff --git a/node-graph/libraries/application-io/Cargo.toml b/node-graph/libraries/application-io/Cargo.toml index 4c6d129697..7aac1f98fc 100644 --- a/node-graph/libraries/application-io/Cargo.toml +++ b/node-graph/libraries/application-io/Cargo.toml @@ -20,6 +20,7 @@ vector-types = { workspace = true } text-nodes = { workspace = true } # Workspace dependencies +blake3 = { workspace = true } glam = { workspace = true } log = { workspace = true } diff --git a/node-graph/libraries/application-io/src/lib.rs b/node-graph/libraries/application-io/src/lib.rs index 918f106c47..d0e35f872c 100644 --- a/node-graph/libraries/application-io/src/lib.rs +++ b/node-graph/libraries/application-io/src/lib.rs @@ -2,15 +2,16 @@ use core_types::transform::Footprint; use dyn_any::{DynAny, StaticType, StaticTypeSized}; use glam::DVec2; use std::fmt::Debug; -use std::future::Future; use std::hash::{Hash, Hasher}; -use std::pin::Pin; use std::ptr::addr_of; use std::sync::Arc; use std::time::Duration; use text_nodes::FontCache; use vector_types::vector::style::RenderMode; +pub mod resource; +pub use resource::{Resource, ResourceFuture, ResourceHash, ResourceStorage, Resources}; + #[cfg(feature = "wgpu")] #[derive(Debug, Clone, Hash, PartialEq, Eq, DynAny)] pub struct ImageTexture(Arc); @@ -42,17 +43,12 @@ impl From for Arc { #[derive(Debug, Clone, Hash, PartialEq, Eq, DynAny)] pub struct ImageTexture; -#[cfg(target_family = "wasm")] -pub type ResourceFuture = Pin, ApplicationError>>>>; -#[cfg(not(target_family = "wasm"))] -pub type ResourceFuture = Pin, ApplicationError>> + Send>>; - pub trait ApplicationIo { type Executor; fn gpu_executor(&self) -> Option<&Self::Executor> { None } - fn load_resource(&self, url: impl AsRef) -> Result; + fn load_resource(&self, hash: ResourceHash) -> resource::ResourceFuture; } impl ApplicationIo for &T { @@ -62,17 +58,11 @@ impl ApplicationIo for &T { (**self).gpu_executor() } - fn load_resource<'a>(&self, url: impl AsRef) -> Result { - (**self).load_resource(url) + fn load_resource(&self, hash: ResourceHash) -> resource::ResourceFuture { + (**self).load_resource(hash) } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ApplicationError { - NotFound, - InvalidUrl, -} - #[derive(Debug, Clone)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum NodeGraphUpdateMessage {} @@ -139,7 +129,7 @@ impl GetEditorPreferences for DummyPreferences { pub struct EditorApi { /// Font data (for rendering text) made available to the graph through the `PlatformEditorApi`. pub font_cache: FontCache, - /// Gives access to APIs like a rendering surface (native window handle or HTML5 canvas) and WGPU (which becomes WebGPU on web). + /// Gives access to APIs like resources. pub application_io: Option>, pub node_graph_message_sender: Box, /// Editor preferences made available to the graph through the `PlatformEditorApi`. @@ -162,7 +152,7 @@ impl Default for EditorApi { impl Hash for EditorApi { fn hash(&self, state: &mut H) { self.font_cache.hash(state); - self.application_io.as_ref().map_or(0, |io| io.as_ref() as *const _ as usize).hash(state); + self.application_io.as_ref().map_or(0, |io| io as *const _ as usize).hash(state); (self.node_graph_message_sender.as_ref() as *const dyn NodeGraphUpdateSender).hash(state); (self.editor_preferences.as_ref() as *const dyn GetEditorPreferences).hash(state); } diff --git a/node-graph/libraries/application-io/src/resource.rs b/node-graph/libraries/application-io/src/resource.rs new file mode 100644 index 0000000000..9ad576a0d2 --- /dev/null +++ b/node-graph/libraries/application-io/src/resource.rs @@ -0,0 +1,213 @@ +use core_types::CacheHash; +use dyn_any::{DynAny, StaticType}; +use std::fmt; +use std::future::Future; +use std::hash::Hash; +use std::ops::Deref; +use std::pin::Pin; +use std::sync::Arc; + +pub trait Resources: Send + Sync { + fn load(&self, hash: ResourceHash) -> ResourceFuture; +} + +pub type ResourceFuture = Pin> + Send + 'static>>; + +pub trait ResourceStorage: Resources { + fn read(&mut self, hash: &ResourceHash) -> Option; + fn write(&mut self, data: &[u8]) -> ResourceHash; + fn contains(&mut self, hash: &ResourceHash) -> bool; + fn garbage_collect(&mut self, used: &[ResourceHash]); +} + +/// Blake3 content hash of a resource, represented as 32 bytes +#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, PartialOrd, Ord, DynAny)] +pub struct ResourceHash([u8; 32]); + +impl From<&[u8]> for ResourceHash { + fn from(data: &[u8]) -> Self { + Self(blake3::hash(data).into()) + } +} + +impl From<[u8; 32]> for ResourceHash { + fn from(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + +impl From<&ResourceHash> for [u8; 32] { + fn from(hash: &ResourceHash) -> Self { + hash.0 + } +} + +impl From<&ResourceHash> for String { + fn from(hash: &ResourceHash) -> Self { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(hash.0.len() * 2); + for byte in &hash.0 { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out + } +} + +impl fmt::Display for ResourceHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&String::from(self)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResourceHashParseError { + InvalidLength { found: usize }, + InvalidCharacter { byte: u8, position: usize }, +} + +impl fmt::Display for ResourceHashParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidLength { found } => write!(f, "resource hash must be 64 hex characters, got {found}"), + Self::InvalidCharacter { byte, position } => write!(f, "resource hash contains non-hex byte {byte:#04x} at position {position}"), + } + } +} + +impl std::error::Error for ResourceHashParseError {} + +impl TryFrom<&str> for ResourceHash { + type Error = ResourceHashParseError; + + fn try_from(value: &str) -> Result { + let bytes = value.as_bytes(); + if bytes.len() != 64 { + return Err(ResourceHashParseError::InvalidLength { found: bytes.len() }); + } + + let mut out = [0u8; 32]; + for (index, chunk) in bytes.chunks_exact(2).enumerate() { + let high = decode_hex_nibble(chunk[0], index * 2)?; + let low = decode_hex_nibble(chunk[1], index * 2 + 1)?; + out[index] = (high << 4) | low; + } + + Ok(Self(out)) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for ResourceHash { + fn serialize(&self, serializer: S) -> Result { + if serializer.is_human_readable() { + serializer.serialize_str(&String::from(self)) + } else { + serializer.serialize_bytes(&self.0) + } + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for ResourceHash { + fn deserialize>(deserializer: D) -> Result { + struct ResourceHashVisitor; + + impl<'de> serde::de::Visitor<'de> for ResourceHashVisitor { + type Value = ResourceHash; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a 64-character hex string or 32 raw bytes") + } + + fn visit_str(self, value: &str) -> Result { + ResourceHash::try_from(value).map_err(E::custom) + } + + fn visit_bytes(self, value: &[u8]) -> Result { + let bytes: [u8; 32] = value.try_into().map_err(|_| E::invalid_length(value.len(), &"32 bytes"))?; + Ok(ResourceHash(bytes)) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut bytes = [0u8; 32]; + for (i, slot) in bytes.iter_mut().enumerate() { + *slot = seq.next_element()?.ok_or_else(|| serde::de::Error::invalid_length(i, &"32 bytes"))?; + } + Ok(ResourceHash(bytes)) + } + } + + if deserializer.is_human_readable() { + deserializer.deserialize_str(ResourceHashVisitor) + } else { + deserializer.deserialize_bytes(ResourceHashVisitor) + } + } +} + +impl CacheHash for ResourceHash { + fn cache_hash(&self, state: &mut H) { + core::hash::Hash::hash(self, state); + } +} + +fn decode_hex_nibble(byte: u8, position: usize) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + b'A'..=b'F' => Ok(byte - b'A' + 10), + _ => Err(ResourceHashParseError::InvalidCharacter { byte, position }), + } +} + +#[derive(Clone)] +pub struct Resource { + inner: Arc + Send + Sync>, +} + +impl Resource { + pub fn new + Send + Sync + 'static>(data: T) -> Self { + Self { inner: Arc::new(data) } + } + + pub fn from_arc(inner: Arc + Send + Sync>) -> Self { + Self { inner } + } +} + +impl Deref for Resource { + type Target = [u8]; + + fn deref(&self) -> &[u8] { + (*self.inner).as_ref() + } +} + +impl AsRef<[u8]> for Resource { + fn as_ref(&self) -> &[u8] { + (*self.inner).as_ref() + } +} + +impl fmt::Debug for Resource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Resource").field("len", &self.len()).finish() + } +} + +impl PartialEq for Resource { + fn eq(&self, other: &Self) -> bool { + self.as_ref() == other.as_ref() + } +} + +impl CacheHash for Resource { + fn cache_hash(&self, state: &mut H) { + self.as_ref().hash(state); + } +} + +unsafe impl StaticType for Resource { + type Static = Resource; +} diff --git a/node-graph/nodes/gstd/src/platform_application_io.rs b/node-graph/nodes/gstd/src/platform_application_io.rs index 3bc062be2a..4f793cec7d 100644 --- a/node-graph/nodes/gstd/src/platform_application_io.rs +++ b/node-graph/nodes/gstd/src/platform_application_io.rs @@ -13,7 +13,6 @@ use core_types::{ATTR_EDITOR_MERGED_LAYERS, ATTR_TRANSFORM, WasmNotSend}; use core_types::{Color, Ctx}; pub use graph_craft::application_io::*; pub use graph_craft::document::value::RenderOutputType; -use graphene_application_io::ApplicationIo; #[cfg(target_family = "wasm")] pub use graphene_canvas_utils as canvas_utils; #[cfg(target_family = "wasm")] @@ -137,18 +136,24 @@ fn image_to_bytes(_: impl Ctx, image: List>) -> List { /// Loads binary from URLs and local asset paths. Returns a transparent placeholder if the resource fails to load, allowing rendering to continue. #[node_macro::node(category("Web Request"))] -async fn load_resource<'a: 'n>(_: impl Ctx, _primary: (), #[scope("editor-api")] editor_resources: &'a PlatformEditorApi, #[name("URL")] url: String) -> Arc<[u8]> { - let Some(api) = editor_resources.application_io.as_ref() else { - return Arc::from(include_bytes!("../../../graph-craft/src/null.png").to_vec()); - }; - let Ok(data) = api.load_resource(url) else { - return Arc::from(include_bytes!("../../../graph-craft/src/null.png").to_vec()); - }; - let Ok(data) = data.await else { - return Arc::from(include_bytes!("../../../graph-craft/src/null.png").to_vec()); +async fn load_resource<'a: 'n>(_: impl Ctx, _primary: (), #[scope("editor-api")] _editor: &'a PlatformEditorApi, #[name("URL")] url: String) -> Arc<[u8]> { + let placeholder = || -> Arc<[u8]> { Arc::from(Vec::::new()) }; + + let response = match reqwest::Client::new().get(&url).send().await { + Ok(response) => response, + Err(error) => { + log::error!("HTTP request for `{url}` failed: {error}"); + return placeholder(); + } }; - data + match response.bytes().await { + Ok(bytes) => Arc::from(bytes.to_vec()), + Err(error) => { + log::error!("Failed to read HTTP response for `{url}`: {error}"); + placeholder() + } + } } /// Converts raw binary data to a raster image. @@ -254,3 +259,34 @@ where .with_attribute(ATTR_EDITOR_MERGED_LAYERS, upstream_graphic_list), ) } + +#[node_macro::node(category(""))] +pub async fn resource<'a: 'n>(_: impl Ctx, _primary: (), #[scope("editor-api")] editor_api: &'a PlatformEditorApi, hash: ResourceHash) -> Resource { + let application_io = editor_api.application_io.as_ref().expect("ApplicationIo must be available when using resources"); + application_io.load_resource(hash).await.unwrap_or_else(|| { + panic!("Resource {hash} not found"); + }) +} + +#[node_macro::node(category(""))] +pub fn resource_image<'a: 'n>(_: impl Ctx, resource: Resource) -> List> { + let image_data = resource.as_ref(); + + let Some(image) = image::load_from_memory(image_data).ok() else { + return List::new(); + }; + let image = image.to_rgba32f(); + let image = Image { + data: image + .chunks(4) + .map(|pixel| { + let alpha = pixel[3]; + Color::from_gamma_srgb_channels(pixel[0] * alpha, pixel[1] * alpha, pixel[2] * alpha, alpha) + }) + .collect(), + width: image.width(), + height: image.height(), + ..Default::default() + }; + List::new_from_element(Raster::new_cpu(image)) +} diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index bdcd02ea22..121131a062 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -3,7 +3,7 @@ use core_types::transform::{Footprint, Transform}; use core_types::uuid::generate_uuid; use core_types::{CloneVarArgs, ExtractAll, ExtractVarArgs}; use core_types::{Color, Context, Ctx, ExtractFootprint, OwnedContextImpl, WasmNotSend}; -pub use graph_craft::application_io::*; +use graph_craft::application_io::PlatformEditorApi; use graph_craft::document::value::RenderOutput; pub use graph_craft::document::value::RenderOutputType; use graphene_application_io::{ApplicationIo, ExportFormat, RenderConfig}; diff --git a/node-graph/preprocessor/src/lib.rs b/node-graph/preprocessor/src/lib.rs index 1e17c0f53d..98fa0951b9 100644 --- a/node-graph/preprocessor/src/lib.rs +++ b/node-graph/preprocessor/src/lib.rs @@ -10,13 +10,61 @@ use graphene_std::*; use std::collections::{HashMap, HashSet}; pub fn expand_network(network: &mut NodeNetwork, substitutions: &HashMap) { + replace_resource_inputs(network); + expand_network_inner(network, substitutions); +} + +/// Replace every `TaggedValue::Resource(hash)` input with a reference to a freshly inserted `resource` proto node. +fn replace_resource_inputs(network: &mut NodeNetwork) { + let mut hash_to_node_id: HashMap = 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 { + replace_resource_inputs(nested); + continue; + } + + if matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if *identifier == platform_application_io::resource::IDENTIFIER) { + continue; + } + + for input in node.inputs.iter_mut() { + let NodeInput::Value { tagged_value, .. } = input else { continue }; + let TaggedValue::Resource(hash) = **tagged_value else { continue }; + + let resource_id = *hash_to_node_id.entry(hash).or_insert_with(|| { + let id = NodeId::new(); + let resource_node = DocumentNode { + inputs: vec![ + NodeInput::value(TaggedValue::None, false), + NodeInput::scope("editor-api"), + NodeInput::value(TaggedValue::Resource(hash), false), + ], + implementation: DocumentNodeImplementation::ProtoNode(platform_application_io::resource::IDENTIFIER), + ..Default::default() + }; + new_resource_nodes.push((id, resource_node)); + id + }); + + *input = NodeInput::node(resource_id, 0); + } + } + + for (id, node) in new_resource_nodes { + network.nodes.insert(id, node); + } +} + +fn expand_network_inner(network: &mut NodeNetwork, substitutions: &HashMap) { if network.generated { return; } for node in network.nodes.values_mut() { match &mut node.implementation { - DocumentNodeImplementation::Network(node_network) => expand_network(node_network, substitutions), + DocumentNodeImplementation::Network(node_network) => expand_network_inner(node_network, substitutions), DocumentNodeImplementation::ProtoNode(proto_node_identifier) => { if let Some(new_node) = substitutions.get(proto_node_identifier) { // Reconcile the document node's inputs with what the current node definition expects, From 1b08a3a89f80a7d195eceaa9549e058702f75ad2 Mon Sep 17 00:00:00 2001 From: Timon Date: Mon, 18 May 2026 15:42:59 +0000 Subject: [PATCH 2/9] Add migrations --- .../document/document_message_handler.rs | 17 ++++++------- .../document/graph_operation/utility_types.rs | 10 ++++---- .../utility_types/embedded_resources.rs | 6 +++++ .../messages/portfolio/document_migration.rs | 25 ++++++++++++++----- .../portfolio/portfolio_message_handler.rs | 21 ++++++++-------- .../src/application_io/resource/indexed_db.rs | 6 ++--- .../nodes/gstd/src/platform_application_io.rs | 6 ++--- node-graph/nodes/raster/src/std_nodes.rs | 5 ---- 8 files changed, 54 insertions(+), 42 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 3e392e88d3..31005752ae 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -109,9 +109,9 @@ pub struct DocumentMessageHandler { pub graph_view_overlay_open: bool, /// The current opacity of the faded node graph background that covers up the artwork. pub graph_fade_artwork_percentage: f64, - /// Resources embedded in the document. Must be None for autosaved documents. - #[serde(rename = "resources", skip_serializing_if = "Option::is_none")] - pub embedded_resources: Option, + /// Resources embedded in the document. Only propagated if saving to an external file and never for autosaved documents. + #[serde(rename = "resources", default, skip_serializing_if = "EmbeddedResources::is_empty")] + pub embedded_resources: EmbeddedResources, // ============================================= // Fields omitted from the saved document format @@ -174,7 +174,7 @@ impl Default for DocumentMessageHandler { graph_view_overlay_open: false, snapping_state: SnappingState::default(), graph_fade_artwork_percentage: 80., - embedded_resources: None, + embedded_resources: EmbeddedResources::default(), // ============================================= // Fields omitted from the saved document format // ============================================= @@ -926,12 +926,11 @@ impl MessageHandler> for DocumentMes let folder = self.path.as_ref().and_then(|path| path.parent()).map(|parent| parent.to_path_buf()); let exported = resources.export(&Vec::from_iter(self.used_resources())); - let embedded = EmbeddedResources::from_iter(exported); - if !embedded.is_empty() { - self.embedded_resources = Some(embedded); - } + self.embedded_resources = EmbeddedResources::from_iter(exported); let content = self.serialize_document(); - self.embedded_resources = None; + + // Clear embedded resources after serialization to free memory. + let _ = std::mem::take(&mut self.embedded_resources); responses.add(FrontendMessage::TriggerSaveDocument { document_id, diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 5a09692211..13df2bbfaa 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -303,13 +303,13 @@ impl<'a> ModifyInputsContext<'a> { let hash = graphene_std::application_io::ResourceHash::from(png_bytes.as_ref()); self.responses.add(ResourceMessage::Write { data: png_bytes }); - let resource_image = resolve_proto_node_type(graphene_std::platform_application_io::resource_image::IDENTIFIER) - .expect("Resource Image node does not exist") + let image_node = resolve_proto_node_type(graphene_std::platform_application_io::image::IDENTIFIER) + .expect("Image node does not exist") .node_template_input_override([Some(NodeInput::value(TaggedValue::Resource(hash), false))]); - let resource_image_id = NodeId::new(); - self.network_interface.insert_node(resource_image_id, resource_image, &[]); - self.network_interface.move_node_to_chain_start(&resource_image_id, layer, &[], self.import); + let image_node_id = NodeId::new(); + self.network_interface.insert_node(image_node_id, image_node, &[]); + self.network_interface.move_node_to_chain_start(&image_node_id, layer, &[], self.import); let transform_id = NodeId::new(); self.network_interface.insert_node(transform_id, transform, &[]); diff --git a/editor/src/messages/portfolio/document/utility_types/embedded_resources.rs b/editor/src/messages/portfolio/document/utility_types/embedded_resources.rs index a15770cbea..48eff998a6 100644 --- a/editor/src/messages/portfolio/document/utility_types/embedded_resources.rs +++ b/editor/src/messages/portfolio/document/utility_types/embedded_resources.rs @@ -13,6 +13,12 @@ impl EmbeddedResources { pub fn is_empty(&self) -> bool { self.resources.is_empty() } + + pub fn store(&mut self, resource: Resource) -> ResourceHash { + let hash = ResourceHash::from(resource.as_ref()); + self.resources.insert(hash, resource); + hash + } } impl FromIterator<(ResourceHash, Resource)> for EmbeddedResources { diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 7b31e35537..c847b6a449 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -549,12 +549,13 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ ], }, NodeReplacement { - node: graphene_std::raster_nodes::std_nodes::image::IDENTIFIER, + node: graphene_std::platform_application_io::image::IDENTIFIER, aliases: &[ "raster_nodes::std_nodes::ImageValueNode", "graphene_raster_nodes::std_nodes::ImageValueNode", "graphene_std::raster::ImageValueNode", "graphene_std::raster::ImageNode", + "graphene_std::raster_nodes::std_nodes::ImageNode", ], }, NodeReplacement { @@ -1588,12 +1589,24 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document.network_interface.set_input(&InputConnector::node(*node_id, 4), old_inputs[3].clone(), network_path); } - if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER) && inputs_count == 1 { - let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); - document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + // Upgrade `image` nodes that stored image data as `Image`` to `image` nodes that store a reference to the image data as a resource. + // Encodes the image data as PNG and stores it in the document's embedded resources and rewires the node to reference that resource. + if reference == DefinitionIdentifier::ProtoNode(graphene_std::platform_application_io::image::IDENTIFIER) && inputs_count == 2 { + let image = node.inputs.iter().find_map(|input| match input.as_value()? { + TaggedValue::ImageData(image) => Some(image.clone()), + _ => None, + }); + + if let Some(image) = image { + let hash = document.embedded_resources.store(graphene_std::application_io::Resource::new(image.to_png())); - // Insert a new empty input for the image - document.network_interface.add_import(TaggedValue::None, false, 0, "Empty", "", &[*node_id]); + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + let _ = document.network_interface.replace_inputs(node_id, network_path, &mut node_template); + document + .network_interface + .set_input(&InputConnector::node(*node_id, 0), NodeInput::value(TaggedValue::Resource(hash), false), network_path); + } } if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::std_nodes::noise_pattern::IDENTIFIER) && inputs_count == 15 { diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 8a84049ea6..86b947c733 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -778,20 +778,19 @@ impl MessageHandler> for Portfolio } }; - if let Some(resources) = document.embedded_resources.take() { - resources.into_iter().for_each(|(hash, resource)| { - let data: Arc<[u8]> = Arc::from(resource.as_ref()); - if ResourceHash::from(data.as_ref()) != hash { - log::error!("Resource hash mismatch for resource with hash {hash}"); - return; - } - responses.add(ResourceMessage::Write { data }); - }); - } - // Upgrade the document's nodes to be compatible with the latest version document_migration_upgrades(&mut document, reset_node_definitions_on_open); + // Load the document's embedded resources into the resource storage + std::mem::take(&mut document.embedded_resources).into_iter().for_each(|(hash, resource)| { + let data: Arc<[u8]> = Arc::from(resource.as_ref()); + if ResourceHash::from(data.as_ref()) != hash { + log::error!("Resource hash mismatch for resource with hash {hash}"); + return; + } + responses.add(ResourceMessage::Write { data }); + }); + // Ensure each node has the metadata for its inputs for (node_id, node, path) in document.network_interface.document_network().clone().recursive_nodes() { document.network_interface.validate_input_metadata(node_id, node, &path); diff --git a/node-graph/graph-craft/src/application_io/resource/indexed_db.rs b/node-graph/graph-craft/src/application_io/resource/indexed_db.rs index 960e3e2584..dd76f68574 100644 --- a/node-graph/graph-craft/src/application_io/resource/indexed_db.rs +++ b/node-graph/graph-craft/src/application_io/resource/indexed_db.rs @@ -76,7 +76,7 @@ impl IndexedDbResourceStorage { } }; - let key = JsValue::from_str(&hash.to_hex()); + let key = JsValue::from_str(&String::from(&hash)); let request = match store.delete(&key) { Ok(request) => request, @@ -113,7 +113,7 @@ impl IndexedDbResourceStorage { } }; - let key = JsValue::from_str(&hash.to_hex()); + let key = JsValue::from_str(&String::from(&hash)); let value = js_sys::Uint8Array::from(data.as_slice()); let request = match store.put_with_key(&value, &key) { @@ -234,7 +234,7 @@ async fn fetch_known_keys(db: &IdbDatabase, store_name: &str) -> Result Result>, JsValue> { let transaction = db.transaction_with_str(store_name)?; let store = transaction.object_store(store_name)?; - let key = JsValue::from_str(&hash.to_hex()); + let key = JsValue::from_str(&String::from(&hash)); let request = store.get(&key)?; let result = await_request(&request).await?; if result.is_undefined() || result.is_null() { diff --git a/node-graph/nodes/gstd/src/platform_application_io.rs b/node-graph/nodes/gstd/src/platform_application_io.rs index 4f793cec7d..0b3469481e 100644 --- a/node-graph/nodes/gstd/src/platform_application_io.rs +++ b/node-graph/nodes/gstd/src/platform_application_io.rs @@ -161,7 +161,7 @@ async fn load_resource<'a: 'n>(_: impl Ctx, _primary: (), #[scope("editor-api")] /// Works with standard image format (PNG, JPEG, WebP, etc.). Automatically converts the color space to linear sRGB for accurate compositing. #[node_macro::node(category("Web Request"))] fn decode_image(_: impl Ctx, data: Arc<[u8]>) -> List> { - let Some(image) = image::load_from_memory(data.as_ref()).ok() else { + let Some(image) = ::image::load_from_memory(data.as_ref()).ok() else { return List::new(); }; let image = image.to_rgba32f(); @@ -269,10 +269,10 @@ pub async fn resource<'a: 'n>(_: impl Ctx, _primary: (), #[scope("editor-api")] } #[node_macro::node(category(""))] -pub fn resource_image<'a: 'n>(_: impl Ctx, resource: Resource) -> List> { +pub fn image<'a: 'n>(_: impl Ctx, resource: Resource) -> List> { let image_data = resource.as_ref(); - let Some(image) = image::load_from_memory(image_data).ok() else { + let Some(image) = ::image::load_from_memory(image_data).ok() else { return List::new(); }; let image = image.to_rgba32f(); diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index eaef5df6fd..65034321d4 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -288,11 +288,6 @@ pub fn empty_image(_: impl Ctx, transform: DAffine2, color: List) -> List result_list } -#[node_macro::node(category(""))] -pub fn image(_: impl Ctx, _primary: (), image: Image) -> List> { - List::new_from_element(Raster::new_cpu(image)) -} - /// Generates customizable procedural noise patterns. #[node_macro::node(category("Raster: Pattern"))] #[allow(clippy::too_many_arguments)] From be364d90d8573637d8b992f682906234c5d4e279 Mon Sep 17 00:00:00 2001 From: Timon Date: Mon, 18 May 2026 17:31:04 +0000 Subject: [PATCH 3/9] Improve mmap resource storage impl --- .../graph-craft/src/application_io/resource/mmap.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/node-graph/graph-craft/src/application_io/resource/mmap.rs b/node-graph/graph-craft/src/application_io/resource/mmap.rs index ccd4df8d51..578640b289 100644 --- a/node-graph/graph-craft/src/application_io/resource/mmap.rs +++ b/node-graph/graph-craft/src/application_io/resource/mmap.rs @@ -97,15 +97,17 @@ impl ResourceStorage for MmapResourceStorage { )); let write_result = (|| -> std::io::Result<()> { - let mut file = fs::File::create(&tmp)?; + let mut file = fs::OpenOptions::new().write(true).create_new(true).open(&tmp)?; file.write_all(data)?; file.sync_all()?; fs::rename(&tmp, &path) })(); if let Err(error) = write_result { - log::error!("Failed to write resource to {path:?}: {error}"); let _ = fs::remove_file(&tmp); + if !path.exists() { + log::error!("Failed to write resource to {path:?}: {error}"); + } } hash @@ -141,6 +143,8 @@ impl ResourceStorage for MmapResourceStorage { log::error!("Failed to remove unused resource {path:?}: {error}"); } } + + let _ = fs::remove_dir(&top_path); } } } From d4a3e0c5878cf369af5bce9bbc6fb7a1ada8a58d Mon Sep 17 00:00:00 2001 From: Timon Date: Mon, 18 May 2026 19:04:30 +0000 Subject: [PATCH 4/9] Reimpl IndexedDBResourceStorage --- .../src/application_io/resource/indexed_db.rs | 528 ++++++++++-------- 1 file changed, 301 insertions(+), 227 deletions(-) diff --git a/node-graph/graph-craft/src/application_io/resource/indexed_db.rs b/node-graph/graph-craft/src/application_io/resource/indexed_db.rs index dd76f68574..7020a6b7ac 100644 --- a/node-graph/graph-craft/src/application_io/resource/indexed_db.rs +++ b/node-graph/graph-craft/src/application_io/resource/indexed_db.rs @@ -1,298 +1,372 @@ use graphene_application_io::{Resource, ResourceFuture, ResourceHash, ResourceStorage, Resources}; +use js_sys::Uint8Array; use std::collections::{HashMap, HashSet}; -use std::future::Future; -use std::pin::Pin; use std::sync::{Arc, Mutex}; +use std::task::{Poll, Waker}; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; -use wasm_bindgen::prelude::JsValue; -use wasm_bindgen_futures::{JsFuture, spawn_local}; -use web_sys::{IdbDatabase, IdbFactory, IdbOpenDbRequest, IdbRequest, IdbTransactionMode}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::spawn_local; +use web_sys::{IdbDatabase, IdbRequest, IdbTransactionMode}; const STORE_NAME: &str = "resources"; -const DATABASE_VERSION: u32 = 1; +const SCHEMA_VERSION: u32 = 1; + +struct Inner { + database: IdbDatabase, + cache: HashMap, + on_disk: HashSet, +} pub struct IndexedDbResourceStorage { - db: IdbDatabase, - store_name: Arc, - cache: Arc>>, - known_keys: Arc>>, + inner: Arc>, } impl IndexedDbResourceStorage { pub async fn load(database_name: &str) -> Result { - let factory = indexed_db_factory()?; - let open_request = factory.open_with_u32(database_name, DATABASE_VERSION)?; - let store_name = STORE_NAME.to_string(); - - let store_name_for_upgrade = store_name.clone(); - let on_upgrade = Closure::once_into_js(move |event: web_sys::Event| { - let Some(target) = event.target() else { return }; - let Ok(request) = target.dyn_into::() else { return }; - let Ok(db_value) = request.result() else { return }; - let Ok(db) = db_value.dyn_into::() else { return }; - if db.object_store_names().contains(&store_name_for_upgrade) { - if let Err(error) = db.delete_object_store(&store_name_for_upgrade) { - log::error!("Failed to delete stale IndexedDB object store {store_name_for_upgrade:?}: {error:?}"); - } - } - if let Err(error) = db.create_object_store(&store_name_for_upgrade) { - log::error!("Failed to create IndexedDB object store {store_name_for_upgrade:?}: {error:?}"); - } + let database = open_database(database_name).await?; + let on_disk = read_all_keys(&database).await.unwrap_or_else(|error| { + log::error!("Failed to enumerate existing resource keys: {error:?}"); + HashSet::new() }); - open_request.set_onupgradeneeded(Some(on_upgrade.unchecked_ref())); - - let result = await_request(open_request.unchecked_ref::()).await?; - let db = result.dyn_into::()?; - - let known_keys = fetch_known_keys(&db, &store_name).await?; Ok(Self { - db, - store_name: Arc::new(store_name), - cache: Arc::new(Mutex::new(HashMap::new())), - known_keys: Arc::new(Mutex::new(known_keys)), + inner: Arc::new(Mutex::new(Inner { + database, + cache: HashMap::new(), + on_disk, + })), }) } +} - fn enqueue_delete(&self, hash: ResourceHash) { - let db = self.db.clone(); - let store_name = self.store_name.clone(); - - spawn_local(async move { - let transaction = match db.transaction_with_str_and_mode(&store_name, IdbTransactionMode::Readwrite) { - Ok(transaction) => transaction, - Err(error) => { - log::error!("Failed to open IndexedDB transaction: {error:?}"); - return; - } - }; - - let store = match transaction.object_store(&store_name) { - Ok(store) => store, - Err(error) => { - log::error!("Failed to access IndexedDB object store {store_name:?}: {error:?}"); - return; - } - }; - - let key = JsValue::from_str(&String::from(&hash)); - - let request = match store.delete(&key) { - Ok(request) => request, - Err(error) => { - log::error!("Failed to enqueue IndexedDB delete: {error:?}"); - return; - } - }; +impl Resources for IndexedDbResourceStorage { + fn load(&self, hash: ResourceHash) -> ResourceFuture { + let inner = self.inner.clone(); - if let Err(error) = await_request(&request).await { - log::error!("IndexedDB delete failed: {error:?}"); + { + let guard = inner.lock().unwrap(); + if let Some(resource) = guard.cache.get(&hash) { + let resource = resource.clone(); + return Box::pin(async move { Some(resource) }); } - }); - } + if !guard.on_disk.contains(&hash) { + return Box::pin(async move { None }); + } + } - fn enqueue_put(&self, hash: ResourceHash, data: Vec) { - let db = self.db.clone(); - let store_name = self.store_name.clone(); + let (sender, receiver) = oneshot(); spawn_local(async move { - let transaction = match db.transaction_with_str_and_mode(&store_name, IdbTransactionMode::Readwrite) { - Ok(transaction) => transaction, - Err(error) => { - log::error!("Failed to open IndexedDB transaction: {error:?}"); - return; - } - }; - - let store = match transaction.object_store(&store_name) { - Ok(store) => store, - Err(error) => { - log::error!("Failed to access IndexedDB object store {store_name:?}: {error:?}"); - return; - } - }; - - let key = JsValue::from_str(&String::from(&hash)); - let value = js_sys::Uint8Array::from(data.as_slice()); - - let request = match store.put_with_key(&value, &key) { - Ok(request) => request, - Err(error) => { - log::error!("Failed to enqueue IndexedDB put: {error:?}"); - return; - } - }; - - if let Err(error) = await_request(&request).await { - log::error!("IndexedDB put failed: {error:?}"); - } + let result = fetch_and_verify(inner, hash).await; + sender.send(result); }); - } -} - -impl Resources for IndexedDbResourceStorage { - fn load(&self, hash: ResourceHash) -> ResourceFuture { - let db = self.db.clone(); - let store_name = self.store_name.clone(); - let cache = self.cache.clone(); - let known_keys = self.known_keys.clone(); - - Box::pin(UnsafeSendFuture(async move { - if let Some(resource) = cache.lock().unwrap().get(&hash) { - return Some(resource.clone()); - } - if !known_keys.lock().unwrap().contains(&hash) { - return None; - } - match fetch_one(&db, &store_name, hash).await { - Ok(Some(payload)) => { - let recomputed = ResourceHash::from(payload.as_slice()); - if recomputed != hash { - log::warn!("IndexedDB entry's hash does not match its payload: {hash} vs {recomputed}"); - known_keys.lock().unwrap().remove(&hash); - None - } else { - let resource = Resource::new(Arc::<[u8]>::from(payload)); - cache.lock().unwrap().insert(hash, resource.clone()); - Some(resource) - } - } - Ok(None) => { - known_keys.lock().unwrap().remove(&hash); - None - } - Err(error) => { - log::error!("IndexedDB fetch for {hash} failed: {error:?}"); - None - } - } - })) + Box::pin(receiver) } } impl ResourceStorage for IndexedDbResourceStorage { fn read(&mut self, hash: &ResourceHash) -> Option { - self.cache.lock().unwrap().get(hash).cloned() + self.inner.lock().unwrap().cache.get(hash).cloned() } fn write(&mut self, data: &[u8]) -> ResourceHash { let hash = ResourceHash::from(data); - self.cache.lock().unwrap().insert(hash, Resource::new(Arc::<[u8]>::from(data))); - self.known_keys.lock().unwrap().insert(hash); - self.enqueue_put(hash, data.to_vec()); + let resource = Resource::new(Arc::<[u8]>::from(data)); + + let mut guard = self.inner.lock().unwrap(); + + guard.cache.insert(hash, resource); + + if guard.on_disk.contains(&hash) { + return hash; + } + + if let Err(error) = put_bytes(&guard.database, &hash, data) { + log::error!("Failed to enqueue IDB put for {hash}: {error:?}"); + } else { + guard.on_disk.insert(hash); + } + hash } fn contains(&mut self, hash: &ResourceHash) -> bool { - self.cache.lock().unwrap().contains_key(hash) || self.known_keys.lock().unwrap().contains(hash) + let guard = self.inner.lock().unwrap(); + guard.cache.contains_key(hash) || guard.on_disk.contains(hash) } fn garbage_collect(&mut self, used: &[ResourceHash]) { - let used_set: std::collections::HashSet = used.iter().cloned().collect(); + let used_set: HashSet = used.iter().copied().collect(); + let mut guard = self.inner.lock().unwrap(); + + guard.cache.retain(|hash, _| used_set.contains(hash)); + + let to_delete: Vec = guard.on_disk.iter().copied().filter(|hash| !used_set.contains(hash)).collect(); - let to_delete: Vec = { - let mut known = self.known_keys.lock().unwrap(); - let to_delete: Vec = known.iter().filter(|h| !used_set.contains(h)).cloned().collect(); - for hash in &to_delete { - known.remove(hash); + if to_delete.is_empty() { + return; + } + + match delete_many(&guard.database, &to_delete) { + Ok(()) => { + for hash in &to_delete { + guard.on_disk.remove(hash); + } } - to_delete + Err(error) => log::error!("Failed to enqueue IDB delete batch ({} entries): {error:?}", to_delete.len()), + } + } +} + +// SAFETY: wasm is single-threaded; the non-`Send`/`Sync` JS handles are never observed across threads. +unsafe impl Send for IndexedDbResourceStorage {} +unsafe impl Sync for IndexedDbResourceStorage {} + +async fn open_database(database_name: &str) -> Result { + let factory = web_sys::window() + .ok_or_else(|| JsValue::from_str("no window"))? + .indexed_db()? + .ok_or_else(|| JsValue::from_str("IndexedDB unavailable"))?; + + let open_request = factory.open_with_u32(database_name, SCHEMA_VERSION)?; + + let upgrade_request = open_request.clone(); + let upgrade_handler = Closure::once_into_js(move |_event: web_sys::Event| { + let Some(target) = upgrade_request.result().ok().and_then(|value| value.dyn_into::().ok()) else { + log::error!("Upgrade event fired without a usable IdbDatabase result"); + return; }; - self.cache.lock().unwrap().retain(|hash, _| used_set.contains(hash)); - for hash in to_delete { - self.enqueue_delete(hash); + let names = target.object_store_names(); + let mut present = false; + for index in 0..names.length() { + if names.get(index).as_deref() == Some(STORE_NAME) { + present = true; + break; + } } - } + + if !present && let Err(error) = target.create_object_store(STORE_NAME) { + log::error!("Failed to create resource object store: {error:?}"); + } + }); + open_request.set_onupgradeneeded(Some(upgrade_handler.as_ref().unchecked_ref())); + + await_request(&open_request).await?; + + open_request.result()?.dyn_into::() } -async fn fetch_known_keys(db: &IdbDatabase, store_name: &str) -> Result, JsValue> { - let transaction = db.transaction_with_str(store_name)?; - let store = transaction.object_store(store_name)?; - let keys_request = store.get_all_keys()?; - let keys = await_request(&keys_request).await?; - let keys_array = keys.dyn_into::()?; - - let mut set = HashSet::with_capacity(keys_array.length() as usize); - for key in keys_array.iter() { - let Some(key_string) = key.as_string() else { - log::warn!("Skipping IndexedDB entry whose key is not a string"); +async fn read_all_keys(database: &IdbDatabase) -> Result, JsValue> { + let transaction = database.transaction_with_str(STORE_NAME)?; + let store = transaction.object_store(STORE_NAME)?; + let request = store.get_all_keys()?; + let value = await_request(&request).await?; + + let array: js_sys::Array = value.dyn_into()?; + let mut keys = HashSet::with_capacity(array.length() as usize); + + for index in 0..array.length() { + let Some(key) = array.get(index).as_string() else { + log::warn!("Skipping non-string IDB key at position {index}"); continue; }; - match ResourceHash::try_from(key_string.as_str()) { + match ResourceHash::try_from(key.as_str()) { Ok(hash) => { - set.insert(hash); + keys.insert(hash); } - Err(error) => log::warn!("Skipping IndexedDB entry whose key {key_string:?} is not a valid resource hash: {error}"), + Err(error) => log::warn!("Skipping unparseable IDB key {key:?}: {error}"), } } - Ok(set) + + Ok(keys) } -async fn fetch_one(db: &IdbDatabase, store_name: &str, hash: ResourceHash) -> Result>, JsValue> { - let transaction = db.transaction_with_str(store_name)?; - let store = transaction.object_store(store_name)?; - let key = JsValue::from_str(&String::from(&hash)); - let request = store.get(&key)?; - let result = await_request(&request).await?; - if result.is_undefined() || result.is_null() { - return Ok(None); - } - let bytes = result.dyn_into::()?; - Ok(Some(bytes.to_vec())) +fn put_bytes(database: &IdbDatabase, hash: &ResourceHash, data: &[u8]) -> Result<(), JsValue> { + let transaction = database.transaction_with_str_and_mode(STORE_NAME, IdbTransactionMode::Readwrite)?; + let store = transaction.object_store(STORE_NAME)?; + + let key = JsValue::from_str(&String::from(hash)); + let value: JsValue = Uint8Array::from(data).into(); + store.put_with_key(&value, &key)?; + Ok(()) } -fn indexed_db_factory() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("no global window"))?; - window.indexed_db()?.ok_or_else(|| JsValue::from_str("indexedDB is unavailable")) +fn delete_many(database: &IdbDatabase, hashes: &[ResourceHash]) -> Result<(), JsValue> { + let transaction = database.transaction_with_str_and_mode(STORE_NAME, IdbTransactionMode::Readwrite)?; + let store = transaction.object_store(STORE_NAME)?; + + for hash in hashes { + let key = JsValue::from_str(&String::from(hash)); + if let Err(error) = store.delete(&key) { + log::error!("Failed to enqueue IDB delete for {hash}: {error:?}"); + } + } + + Ok(()) } -async fn await_request(request: &IdbRequest) -> Result { - let promise = js_sys::Promise::new(&mut |resolve, reject| { - let reject_for_error = reject.clone(); - let request_for_success = request.clone(); - let on_success = Closure::once_into_js(move |_event: web_sys::Event| match request_for_success.result() { - Ok(value) => { - let _ = resolve.call1(&JsValue::NULL, &value); +async fn fetch_and_verify(inner: Arc>, hash: ResourceHash) -> Option { + let request = { + let guard = inner.lock().unwrap(); + let database = &guard.database; + + let transaction = match database.transaction_with_str(STORE_NAME) { + Ok(transaction) => transaction, + Err(error) => { + log::error!("Failed to open IDB readonly transaction for {hash}: {error:?}"); + return None; + } + }; + let store = match transaction.object_store(STORE_NAME) { + Ok(store) => store, + Err(error) => { + log::error!("Failed to access IDB object store for {hash}: {error:?}"); + return None; } + }; + let key = JsValue::from_str(&String::from(&hash)); + match store.get(&key) { + Ok(request) => request, Err(error) => { - let _ = reject.call1(&JsValue::NULL, &error); + log::error!("Failed to issue IDB get for {hash}: {error:?}"); + return None; } - }); - request.set_onsuccess(Some(on_success.unchecked_ref())); - - let request_for_error = request.clone(); - let on_error = Closure::once_into_js(move |_event: web_sys::Event| { - let error = request_for_error - .error() - .ok() - .flatten() - .map(JsValue::from) - .unwrap_or_else(|| JsValue::from_str("IndexedDB request error")); - let _ = reject_for_error.call1(&JsValue::NULL, &error); - }); - request.set_onerror(Some(on_error.unchecked_ref())); + } + }; + + let value = match await_request(&request).await { + Ok(value) => value, + Err(error) => { + log::error!("IDB get for {hash} failed: {error:?}"); + return None; + } + }; + + if value.is_undefined() || value.is_null() { + inner.lock().unwrap().on_disk.remove(&hash); + return None; + } + + let array = match value.dyn_into::() { + Ok(array) => array, + Err(value) => { + log::error!("IDB returned non-Uint8Array value for {hash}: {value:?}"); + return None; + } + }; + let bytes = array.to_vec(); + + let actual = ResourceHash::from(bytes.as_slice()); + if actual != hash { + log::error!("IDB content-integrity failure: key {hash} stores bytes that hash to {actual}, deleting"); + let mut guard = inner.lock().unwrap(); + guard.on_disk.remove(&hash); + if let Err(error) = delete_many(&guard.database, &[hash]) { + log::error!("Failed to delete corrupted entry {hash}: {error:?}"); + } + return None; + } + + let resource = Resource::new(Arc::<[u8]>::from(bytes.as_slice())); + inner.lock().unwrap().cache.insert(hash, resource.clone()); + Some(resource) +} + +async fn await_request(request: &IdbRequest) -> Result { + let state: Arc> = Arc::new(Mutex::new(RequestState::default())); + + let success_state = state.clone(); + let success_request = request.clone(); + let on_success = Closure::once_into_js(move |_event: web_sys::Event| { + let result = success_request.result(); + let mut guard = success_state.lock().unwrap(); + guard.outcome = Some(result); + if let Some(waker) = guard.waker.take() { + waker.wake(); + } }); - JsFuture::from(promise).await + let error_state = state.clone(); + let error_request = request.clone(); + let on_error = Closure::once_into_js(move |_event: web_sys::Event| { + let error = error_request.error().unwrap_or(None).map(JsValue::from).unwrap_or_else(|| JsValue::from_str("unknown IDB error")); + let mut guard = error_state.lock().unwrap(); + guard.outcome = Some(Err(error)); + if let Some(waker) = guard.waker.take() { + waker.wake(); + } + }); + + request.set_onsuccess(Some(on_success.as_ref().unchecked_ref())); + request.set_onerror(Some(on_error.as_ref().unchecked_ref())); + + RequestFuture { state }.await } -// SAFETY: wasm is single-threaded; the non-`Send`/`Sync` JS handles are never observed across threads. -unsafe impl Send for IndexedDbResourceStorage {} -unsafe impl Sync for IndexedDbResourceStorage {} +#[derive(Default)] +struct RequestState { + outcome: Option>, + waker: Option, +} + +struct RequestFuture { + state: Arc>, +} + +impl std::future::Future for RequestFuture { + type Output = Result; -// SAFETY: Only constructed in wasm contexts where there is a single thread; the inner future's -// non-`Send` state is therefore never observed from another thread. -struct UnsafeSendFuture(F); -unsafe impl Send for UnsafeSendFuture {} -impl Future for UnsafeSendFuture { - type Output = F::Output; - - fn poll(self: Pin<&mut Self>, cx: &mut core::task::Context<'_>) -> core::task::Poll { - // SAFETY: `UnsafeSendFuture` only contains `F`, so projecting `Pin<&mut Self>` to - // `Pin<&mut F>` preserves the pinning guarantee. - unsafe { self.map_unchecked_mut(|s| &mut s.0) }.poll(cx) + fn poll(self: std::pin::Pin<&mut Self>, context: &mut std::task::Context<'_>) -> Poll { + let mut guard = self.state.lock().unwrap(); + if let Some(outcome) = guard.outcome.take() { + return Poll::Ready(outcome); + } + guard.waker = Some(context.waker().clone()); + Poll::Pending + } +} + +fn oneshot() -> (OneshotSender, OneshotReceiver) { + let state = Arc::new(Mutex::new(OneshotState::default())); + (OneshotSender { state: state.clone() }, OneshotReceiver { state }) +} + +#[derive(Default)] +struct OneshotState { + value: Option>, + waker: Option, +} + +struct OneshotSender { + state: Arc>, +} + +impl OneshotSender { + fn send(self, value: Option) { + let mut guard = self.state.lock().unwrap(); + guard.value = Some(value); + if let Some(waker) = guard.waker.take() { + waker.wake(); + } + } +} + +struct OneshotReceiver { + state: Arc>, +} + +impl std::future::Future for OneshotReceiver { + type Output = Option; + + fn poll(self: std::pin::Pin<&mut Self>, context: &mut std::task::Context<'_>) -> Poll { + let mut guard = self.state.lock().unwrap(); + if let Some(value) = guard.value.take() { + return Poll::Ready(value); + } + guard.waker = Some(context.waker().clone()); + Poll::Pending } } From 2330a9d4e71eb7658806fa26ad88df8c99c0728d Mon Sep 17 00:00:00 2001 From: Timon Date: Mon, 18 May 2026 21:32:11 +0000 Subject: [PATCH 5/9] Fix migration --- editor/src/messages/portfolio/document_migration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index c847b6a449..e75588555e 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -555,7 +555,7 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ "graphene_raster_nodes::std_nodes::ImageValueNode", "graphene_std::raster::ImageValueNode", "graphene_std::raster::ImageNode", - "graphene_std::raster_nodes::std_nodes::ImageNode", + "raster_nodes::std_nodes::ImageNode", ], }, NodeReplacement { From 3920949be2528700fa691c264425f0818cf41ba0 Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 19 May 2026 16:32:01 +0000 Subject: [PATCH 6/9] WIP OPFS impl --- frontend/wrapper/src/editor_wrapper.rs | 6 +- node-graph/graph-craft/Cargo.toml | 10 + node-graph/graph-craft/src/application_io.rs | 4 +- .../src/application_io/resource.rs | 4 +- .../src/application_io/resource/indexed_db.rs | 372 ------------------ .../src/application_io/resource/opfs.rs | 352 +++++++++++++++++ 6 files changed, 369 insertions(+), 379 deletions(-) delete mode 100644 node-graph/graph-craft/src/application_io/resource/indexed_db.rs create mode 100644 node-graph/graph-craft/src/application_io/resource/opfs.rs diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 369f3b5891..cd975df50e 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -24,7 +24,7 @@ use editor::messages::portfolio::utility_types::{DockingSplitDirection, FontCata use editor::messages::prelude::*; use editor::messages::tool::tool_messages::tool_prelude::WidgetId; #[cfg(all(not(feature = "native"), target_family = "wasm"))] -use graph_craft::application_io::IndexedDbResourceStorage; +use graph_craft::application_io::OpfsResourceStorage; use graph_craft::document::NodeId; use graphene_std::color::SRGBA8; use graphene_std::graphene_hash::CacheHashWrapper; @@ -83,10 +83,10 @@ impl EditorWrapper { _ => unreachable!(), }; - let storage: Box = match IndexedDbResourceStorage::load("graphite-resources").await { + let storage: Box = match OpfsResourceStorage::load("resources").await { Ok(storage) => Box::new(storage), Err(error) => { - log::error!("Failed to open IndexedDB resource storage, falling back to in-memory: {error:?}"); + log::error!("Failed to open OPFS resource storage, falling back to in-memory: {error:?}"); Box::new(graph_craft::application_io::HashMapResourceStorage::new()) } }; diff --git a/node-graph/graph-craft/Cargo.toml b/node-graph/graph-craft/Cargo.toml index e0f1f794f5..73c8b588db 100644 --- a/node-graph/graph-craft/Cargo.toml +++ b/node-graph/graph-craft/Cargo.toml @@ -66,6 +66,16 @@ web-sys = { workspace = true, features = [ "DomStringList", "Event", "EventTarget", + "StorageManager", + "FileSystemDirectoryHandle", + "FileSystemFileHandle", + "FileSystemGetFileOptions", + "FileSystemGetDirectoryOptions", + "FileSystemWritableFileStream", + "FileSystemRemoveOptions", + "WritableStream", + "File", + "Blob", ] } js-sys = { workspace = true } wasm-bindgen = { workspace = true } diff --git a/node-graph/graph-craft/src/application_io.rs b/node-graph/graph-craft/src/application_io.rs index 5f03f0200b..1237b1ef0c 100644 --- a/node-graph/graph-craft/src/application_io.rs +++ b/node-graph/graph-craft/src/application_io.rs @@ -6,10 +6,10 @@ pub mod resource; pub use graphene_application_io::{ApplicationIo, Resource, ResourceFuture, ResourceHash, ResourceStorage, Resources}; pub use resource::HashMapResourceStorage; -#[cfg(target_family = "wasm")] -pub use resource::indexed_db::IndexedDbResourceStorage; #[cfg(not(target_family = "wasm"))] pub use resource::mmap::MmapResourceStorage; +#[cfg(target_family = "wasm")] +pub use resource::opfs::OpfsResourceStorage; pub struct PlatformApplicationIo { #[cfg(feature = "wgpu")] diff --git a/node-graph/graph-craft/src/application_io/resource.rs b/node-graph/graph-craft/src/application_io/resource.rs index 0601210888..d7e2d8d7ee 100644 --- a/node-graph/graph-craft/src/application_io/resource.rs +++ b/node-graph/graph-craft/src/application_io/resource.rs @@ -1,7 +1,7 @@ -#[cfg(target_family = "wasm")] -pub mod indexed_db; #[cfg(not(target_family = "wasm"))] pub mod mmap; +#[cfg(target_family = "wasm")] +pub mod opfs; use graphene_application_io::{Resource, ResourceFuture, ResourceHash, ResourceStorage, Resources}; use std::collections::HashMap; diff --git a/node-graph/graph-craft/src/application_io/resource/indexed_db.rs b/node-graph/graph-craft/src/application_io/resource/indexed_db.rs deleted file mode 100644 index 7020a6b7ac..0000000000 --- a/node-graph/graph-craft/src/application_io/resource/indexed_db.rs +++ /dev/null @@ -1,372 +0,0 @@ -use graphene_application_io::{Resource, ResourceFuture, ResourceHash, ResourceStorage, Resources}; -use js_sys::Uint8Array; -use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, Mutex}; -use std::task::{Poll, Waker}; -use wasm_bindgen::JsCast; -use wasm_bindgen::closure::Closure; -use wasm_bindgen::prelude::*; -use wasm_bindgen_futures::spawn_local; -use web_sys::{IdbDatabase, IdbRequest, IdbTransactionMode}; - -const STORE_NAME: &str = "resources"; -const SCHEMA_VERSION: u32 = 1; - -struct Inner { - database: IdbDatabase, - cache: HashMap, - on_disk: HashSet, -} - -pub struct IndexedDbResourceStorage { - inner: Arc>, -} - -impl IndexedDbResourceStorage { - pub async fn load(database_name: &str) -> Result { - let database = open_database(database_name).await?; - let on_disk = read_all_keys(&database).await.unwrap_or_else(|error| { - log::error!("Failed to enumerate existing resource keys: {error:?}"); - HashSet::new() - }); - - Ok(Self { - inner: Arc::new(Mutex::new(Inner { - database, - cache: HashMap::new(), - on_disk, - })), - }) - } -} - -impl Resources for IndexedDbResourceStorage { - fn load(&self, hash: ResourceHash) -> ResourceFuture { - let inner = self.inner.clone(); - - { - let guard = inner.lock().unwrap(); - if let Some(resource) = guard.cache.get(&hash) { - let resource = resource.clone(); - return Box::pin(async move { Some(resource) }); - } - if !guard.on_disk.contains(&hash) { - return Box::pin(async move { None }); - } - } - - let (sender, receiver) = oneshot(); - - spawn_local(async move { - let result = fetch_and_verify(inner, hash).await; - sender.send(result); - }); - - Box::pin(receiver) - } -} - -impl ResourceStorage for IndexedDbResourceStorage { - fn read(&mut self, hash: &ResourceHash) -> Option { - self.inner.lock().unwrap().cache.get(hash).cloned() - } - - fn write(&mut self, data: &[u8]) -> ResourceHash { - let hash = ResourceHash::from(data); - let resource = Resource::new(Arc::<[u8]>::from(data)); - - let mut guard = self.inner.lock().unwrap(); - - guard.cache.insert(hash, resource); - - if guard.on_disk.contains(&hash) { - return hash; - } - - if let Err(error) = put_bytes(&guard.database, &hash, data) { - log::error!("Failed to enqueue IDB put for {hash}: {error:?}"); - } else { - guard.on_disk.insert(hash); - } - - hash - } - - fn contains(&mut self, hash: &ResourceHash) -> bool { - let guard = self.inner.lock().unwrap(); - guard.cache.contains_key(hash) || guard.on_disk.contains(hash) - } - - fn garbage_collect(&mut self, used: &[ResourceHash]) { - let used_set: HashSet = used.iter().copied().collect(); - let mut guard = self.inner.lock().unwrap(); - - guard.cache.retain(|hash, _| used_set.contains(hash)); - - let to_delete: Vec = guard.on_disk.iter().copied().filter(|hash| !used_set.contains(hash)).collect(); - - if to_delete.is_empty() { - return; - } - - match delete_many(&guard.database, &to_delete) { - Ok(()) => { - for hash in &to_delete { - guard.on_disk.remove(hash); - } - } - Err(error) => log::error!("Failed to enqueue IDB delete batch ({} entries): {error:?}", to_delete.len()), - } - } -} - -// SAFETY: wasm is single-threaded; the non-`Send`/`Sync` JS handles are never observed across threads. -unsafe impl Send for IndexedDbResourceStorage {} -unsafe impl Sync for IndexedDbResourceStorage {} - -async fn open_database(database_name: &str) -> Result { - let factory = web_sys::window() - .ok_or_else(|| JsValue::from_str("no window"))? - .indexed_db()? - .ok_or_else(|| JsValue::from_str("IndexedDB unavailable"))?; - - let open_request = factory.open_with_u32(database_name, SCHEMA_VERSION)?; - - let upgrade_request = open_request.clone(); - let upgrade_handler = Closure::once_into_js(move |_event: web_sys::Event| { - let Some(target) = upgrade_request.result().ok().and_then(|value| value.dyn_into::().ok()) else { - log::error!("Upgrade event fired without a usable IdbDatabase result"); - return; - }; - - let names = target.object_store_names(); - let mut present = false; - for index in 0..names.length() { - if names.get(index).as_deref() == Some(STORE_NAME) { - present = true; - break; - } - } - - if !present && let Err(error) = target.create_object_store(STORE_NAME) { - log::error!("Failed to create resource object store: {error:?}"); - } - }); - open_request.set_onupgradeneeded(Some(upgrade_handler.as_ref().unchecked_ref())); - - await_request(&open_request).await?; - - open_request.result()?.dyn_into::() -} - -async fn read_all_keys(database: &IdbDatabase) -> Result, JsValue> { - let transaction = database.transaction_with_str(STORE_NAME)?; - let store = transaction.object_store(STORE_NAME)?; - let request = store.get_all_keys()?; - let value = await_request(&request).await?; - - let array: js_sys::Array = value.dyn_into()?; - let mut keys = HashSet::with_capacity(array.length() as usize); - - for index in 0..array.length() { - let Some(key) = array.get(index).as_string() else { - log::warn!("Skipping non-string IDB key at position {index}"); - continue; - }; - match ResourceHash::try_from(key.as_str()) { - Ok(hash) => { - keys.insert(hash); - } - Err(error) => log::warn!("Skipping unparseable IDB key {key:?}: {error}"), - } - } - - Ok(keys) -} - -fn put_bytes(database: &IdbDatabase, hash: &ResourceHash, data: &[u8]) -> Result<(), JsValue> { - let transaction = database.transaction_with_str_and_mode(STORE_NAME, IdbTransactionMode::Readwrite)?; - let store = transaction.object_store(STORE_NAME)?; - - let key = JsValue::from_str(&String::from(hash)); - let value: JsValue = Uint8Array::from(data).into(); - store.put_with_key(&value, &key)?; - Ok(()) -} - -fn delete_many(database: &IdbDatabase, hashes: &[ResourceHash]) -> Result<(), JsValue> { - let transaction = database.transaction_with_str_and_mode(STORE_NAME, IdbTransactionMode::Readwrite)?; - let store = transaction.object_store(STORE_NAME)?; - - for hash in hashes { - let key = JsValue::from_str(&String::from(hash)); - if let Err(error) = store.delete(&key) { - log::error!("Failed to enqueue IDB delete for {hash}: {error:?}"); - } - } - - Ok(()) -} - -async fn fetch_and_verify(inner: Arc>, hash: ResourceHash) -> Option { - let request = { - let guard = inner.lock().unwrap(); - let database = &guard.database; - - let transaction = match database.transaction_with_str(STORE_NAME) { - Ok(transaction) => transaction, - Err(error) => { - log::error!("Failed to open IDB readonly transaction for {hash}: {error:?}"); - return None; - } - }; - let store = match transaction.object_store(STORE_NAME) { - Ok(store) => store, - Err(error) => { - log::error!("Failed to access IDB object store for {hash}: {error:?}"); - return None; - } - }; - let key = JsValue::from_str(&String::from(&hash)); - match store.get(&key) { - Ok(request) => request, - Err(error) => { - log::error!("Failed to issue IDB get for {hash}: {error:?}"); - return None; - } - } - }; - - let value = match await_request(&request).await { - Ok(value) => value, - Err(error) => { - log::error!("IDB get for {hash} failed: {error:?}"); - return None; - } - }; - - if value.is_undefined() || value.is_null() { - inner.lock().unwrap().on_disk.remove(&hash); - return None; - } - - let array = match value.dyn_into::() { - Ok(array) => array, - Err(value) => { - log::error!("IDB returned non-Uint8Array value for {hash}: {value:?}"); - return None; - } - }; - let bytes = array.to_vec(); - - let actual = ResourceHash::from(bytes.as_slice()); - if actual != hash { - log::error!("IDB content-integrity failure: key {hash} stores bytes that hash to {actual}, deleting"); - let mut guard = inner.lock().unwrap(); - guard.on_disk.remove(&hash); - if let Err(error) = delete_many(&guard.database, &[hash]) { - log::error!("Failed to delete corrupted entry {hash}: {error:?}"); - } - return None; - } - - let resource = Resource::new(Arc::<[u8]>::from(bytes.as_slice())); - inner.lock().unwrap().cache.insert(hash, resource.clone()); - Some(resource) -} - -async fn await_request(request: &IdbRequest) -> Result { - let state: Arc> = Arc::new(Mutex::new(RequestState::default())); - - let success_state = state.clone(); - let success_request = request.clone(); - let on_success = Closure::once_into_js(move |_event: web_sys::Event| { - let result = success_request.result(); - let mut guard = success_state.lock().unwrap(); - guard.outcome = Some(result); - if let Some(waker) = guard.waker.take() { - waker.wake(); - } - }); - - let error_state = state.clone(); - let error_request = request.clone(); - let on_error = Closure::once_into_js(move |_event: web_sys::Event| { - let error = error_request.error().unwrap_or(None).map(JsValue::from).unwrap_or_else(|| JsValue::from_str("unknown IDB error")); - let mut guard = error_state.lock().unwrap(); - guard.outcome = Some(Err(error)); - if let Some(waker) = guard.waker.take() { - waker.wake(); - } - }); - - request.set_onsuccess(Some(on_success.as_ref().unchecked_ref())); - request.set_onerror(Some(on_error.as_ref().unchecked_ref())); - - RequestFuture { state }.await -} - -#[derive(Default)] -struct RequestState { - outcome: Option>, - waker: Option, -} - -struct RequestFuture { - state: Arc>, -} - -impl std::future::Future for RequestFuture { - type Output = Result; - - fn poll(self: std::pin::Pin<&mut Self>, context: &mut std::task::Context<'_>) -> Poll { - let mut guard = self.state.lock().unwrap(); - if let Some(outcome) = guard.outcome.take() { - return Poll::Ready(outcome); - } - guard.waker = Some(context.waker().clone()); - Poll::Pending - } -} - -fn oneshot() -> (OneshotSender, OneshotReceiver) { - let state = Arc::new(Mutex::new(OneshotState::default())); - (OneshotSender { state: state.clone() }, OneshotReceiver { state }) -} - -#[derive(Default)] -struct OneshotState { - value: Option>, - waker: Option, -} - -struct OneshotSender { - state: Arc>, -} - -impl OneshotSender { - fn send(self, value: Option) { - let mut guard = self.state.lock().unwrap(); - guard.value = Some(value); - if let Some(waker) = guard.waker.take() { - waker.wake(); - } - } -} - -struct OneshotReceiver { - state: Arc>, -} - -impl std::future::Future for OneshotReceiver { - type Output = Option; - - fn poll(self: std::pin::Pin<&mut Self>, context: &mut std::task::Context<'_>) -> Poll { - let mut guard = self.state.lock().unwrap(); - if let Some(value) = guard.value.take() { - return Poll::Ready(value); - } - guard.waker = Some(context.waker().clone()); - Poll::Pending - } -} diff --git a/node-graph/graph-craft/src/application_io/resource/opfs.rs b/node-graph/graph-craft/src/application_io/resource/opfs.rs new file mode 100644 index 0000000000..a6268e6248 --- /dev/null +++ b/node-graph/graph-craft/src/application_io/resource/opfs.rs @@ -0,0 +1,352 @@ +use graphene_application_io::{Resource, ResourceFuture, ResourceHash, ResourceStorage, Resources}; +use js_sys::Uint8Array; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Waker}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::{JsFuture, spawn_local}; +use web_sys::{DomException, FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetDirectoryOptions, FileSystemGetFileOptions, FileSystemWritableFileStream}; + +enum Mutation { + Write { hash: ResourceHash, bytes: Arc<[u8]> }, + Delete { hash: ResourceHash }, +} + +struct Inner { + directory: FileSystemDirectoryHandle, + cache: HashMap, + on_disk: HashSet, + queue: VecDeque, + worker_active: bool, +} + +pub struct OpfsResourceStorage { + inner: Arc>, +} + +// SAFETY: This is only compiled for browser wasm, where JS handles remain on the main thread. +unsafe impl Send for OpfsResourceStorage {} +unsafe impl Sync for OpfsResourceStorage {} + +impl OpfsResourceStorage { + pub async fn load(directory_name: &str) -> Result { + let root = open_root_directory(directory_name).await?; + + let on_disk = read_all_keys(&root).await.unwrap_or_else(|error| { + log::error!("Failed to enumerate existing OPFS resource keys: {error:?}"); + HashSet::new() + }); + + Ok(Self { + inner: Arc::new(Mutex::new(Inner { + directory: root, + cache: HashMap::new(), + on_disk, + queue: VecDeque::new(), + worker_active: false, + })), + }) + } +} + +impl Resources for OpfsResourceStorage { + fn load(&self, hash: ResourceHash) -> ResourceFuture { + let inner = self.inner.clone(); + + { + let guard = inner.lock().unwrap(); + if let Some(resource) = guard.cache.get(&hash) { + let resource = resource.clone(); + return Box::pin(async move { Some(resource) }); + } + if !guard.on_disk.contains(&hash) { + return Box::pin(async move { None }); + } + } + + let (sender, receiver) = oneshot(); + spawn_local(async move { + let result = fetch_and_verify(inner, hash).await; + sender.send(result); + }); + + Box::pin(receiver) + } +} + +impl ResourceStorage for OpfsResourceStorage { + fn read(&mut self, hash: &ResourceHash) -> Option { + self.inner.lock().unwrap().cache.get(hash).cloned() + } + + fn write(&mut self, data: &[u8]) -> ResourceHash { + let hash = ResourceHash::from(data); + let bytes: Arc<[u8]> = Arc::from(data); + let resource = Resource::new(bytes.clone()); + + let mut guard = self.inner.lock().unwrap(); + guard.cache.insert(hash, resource); + + let needs_persist = !guard.on_disk.contains(&hash) || queue_has_delete_for(&guard.queue, &hash); + + if needs_persist { + guard.on_disk.insert(hash); + guard.queue.push_back(Mutation::Write { hash, bytes }); + kick_worker(&self.inner, &mut guard); + } + + hash + } + + fn contains(&mut self, hash: &ResourceHash) -> bool { + let guard = self.inner.lock().unwrap(); + guard.cache.contains_key(hash) || guard.on_disk.contains(hash) + } + + fn garbage_collect(&mut self, used: &[ResourceHash]) { + let used_set: HashSet = used.iter().copied().collect(); + let mut guard = self.inner.lock().unwrap(); + + guard.cache.retain(|hash, _| used_set.contains(hash)); + + let to_delete: Vec = guard.on_disk.iter().copied().filter(|hash| !used_set.contains(hash)).collect(); + + if to_delete.is_empty() { + return; + } + + for hash in &to_delete { + guard.on_disk.remove(hash); + guard.queue.push_back(Mutation::Delete { hash: *hash }); + } + + kick_worker(&self.inner, &mut guard); + } +} + +fn queue_has_delete_for(queue: &VecDeque, hash: &ResourceHash) -> bool { + queue.iter().any(|mutation| matches!(mutation, Mutation::Delete { hash: other } if other == hash)) +} + +fn kick_worker(inner: &Arc>, guard: &mut Inner) { + if guard.worker_active { + return; + } + guard.worker_active = true; + let inner = inner.clone(); + spawn_local(drain_queue(inner)); +} + +async fn drain_queue(inner: Arc>) { + loop { + let (op, directory) = { + let mut guard = inner.lock().unwrap(); + match guard.queue.pop_front() { + Some(op) => (op, guard.directory.clone()), + None => { + guard.worker_active = false; + return; + } + } + }; + + match op { + Mutation::Write { hash, bytes } => { + if let Err(error) = write_file(&directory, &hash, &bytes).await { + log::error!("OPFS write for {hash} failed: {error:?}"); + inner.lock().unwrap().on_disk.remove(&hash); + } + } + Mutation::Delete { hash } => { + if let Err(error) = delete_file(&directory, &hash).await { + log::error!("OPFS delete for {hash} failed: {error:?}"); + } + } + } + } +} + +async fn open_root_directory(directory_name: &str) -> Result { + let storage = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?.navigator().storage(); + + let opfs_root: FileSystemDirectoryHandle = JsFuture::from(storage.get_directory()).await?.dyn_into()?; + + let create = FileSystemGetDirectoryOptions::new(); + create.set_create(true); + + JsFuture::from(opfs_root.get_directory_handle_with_options(directory_name, &create)).await?.dyn_into() +} + +async fn read_all_keys(directory: &FileSystemDirectoryHandle) -> Result, JsValue> { + let iterator = directory.keys(); + let mut keys = HashSet::new(); + + loop { + let next: js_sys::IteratorNext = JsFuture::from(iterator.next_iterator()?).await?.unchecked_into(); + if next.done() { + break; + } + + let Some(name) = next.value().as_string() else { + log::warn!("Skipping non-string OPFS entry name"); + continue; + }; + + match ResourceHash::try_from(name.as_str()) { + Ok(hash) => { + keys.insert(hash); + } + Err(error) => log::warn!("Skipping unparseable OPFS entry {name:?}: {error}"), + } + } + + Ok(keys) +} + +fn file_name(hash: &ResourceHash) -> String { + String::from(hash) +} + +async fn write_file(directory: &FileSystemDirectoryHandle, hash: &ResourceHash, bytes: &[u8]) -> Result<(), JsValue> { + let create = FileSystemGetFileOptions::new(); + create.set_create(true); + + let name = file_name(hash); + let handle: FileSystemFileHandle = JsFuture::from(directory.get_file_handle_with_options(&name, &create)).await?.dyn_into()?; + + let writable: FileSystemWritableFileStream = JsFuture::from(handle.create_writable()).await?.dyn_into()?; + + let view = Uint8Array::new_with_length(bytes.len() as u32); + view.copy_from(bytes); + + let write_result = JsFuture::from(writable.write_with_js_u8_array(&view)?).await; + let close_result = JsFuture::from(writable.close()).await; + + write_result?; + close_result?; + Ok(()) +} + +async fn delete_file(directory: &FileSystemDirectoryHandle, hash: &ResourceHash) -> Result<(), JsValue> { + let name = file_name(hash); + match JsFuture::from(directory.remove_entry(&name)).await { + Ok(_) => Ok(()), + Err(error) => { + if is_not_found(&error) { + Ok(()) + } else { + Err(error) + } + } + } +} + +fn is_not_found(error: &JsValue) -> bool { + error.dyn_ref::().is_some_and(|error| error.name() == "NotFoundError") +} + +async fn fetch_and_verify(inner: Arc>, hash: ResourceHash) -> Option { + let directory = inner.lock().unwrap().directory.clone(); + let name = file_name(&hash); + + let handle = match JsFuture::from(directory.get_file_handle(&name)).await { + Ok(value) => match value.dyn_into::() { + Ok(handle) => handle, + Err(value) => { + log::error!("OPFS get_file_handle returned non-FileSystemFileHandle for {hash}: {value:?}"); + return None; + } + }, + Err(error) => { + if is_not_found(&error) { + inner.lock().unwrap().on_disk.remove(&hash); + return None; + } + log::error!("OPFS get_file_handle for {hash} failed: {error:?}"); + return None; + } + }; + + let file = match JsFuture::from(handle.get_file()).await { + Ok(value) => value, + Err(error) => { + log::error!("OPFS getFile for {hash} failed: {error:?}"); + return None; + } + }; + let blob: web_sys::Blob = match file.dyn_into() { + Ok(blob) => blob, + Err(value) => { + log::error!("OPFS getFile returned non-Blob for {hash}: {value:?}"); + return None; + } + }; + + let buffer = match JsFuture::from(blob.array_buffer()).await { + Ok(buffer) => buffer, + Err(error) => { + log::error!("Reading OPFS array buffer for {hash} failed: {error:?}"); + return None; + } + }; + let array = Uint8Array::new(&buffer); + let bytes: Arc<[u8]> = Arc::from(array.to_vec()); + + let actual = ResourceHash::from(bytes.as_ref()); + if actual != hash { + log::error!("OPFS content-integrity failure: file {hash} hashes to {actual}, removing"); + let mut guard = inner.lock().unwrap(); + guard.on_disk.remove(&hash); + guard.queue.push_back(Mutation::Delete { hash }); + kick_worker(&inner, &mut guard); + return None; + } + + let resource = Resource::new(bytes); + inner.lock().unwrap().cache.insert(hash, resource.clone()); + Some(resource) +} + +fn oneshot() -> (OneshotSender, OneshotReceiver) { + let state = Arc::new(Mutex::new(OneshotState::default())); + (OneshotSender { state: state.clone() }, OneshotReceiver { state }) +} + +#[derive(Default)] +struct OneshotState { + value: Option>, + waker: Option, +} + +struct OneshotSender { + state: Arc>, +} + +impl OneshotSender { + fn send(self, value: Option) { + let mut guard = self.state.lock().unwrap(); + guard.value = Some(value); + if let Some(waker) = guard.waker.take() { + waker.wake(); + } + } +} + +struct OneshotReceiver { + state: Arc>, +} + +impl std::future::Future for OneshotReceiver { + type Output = Option; + + fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll { + let mut guard = self.state.lock().unwrap(); + if let Some(value) = guard.value.take() { + return Poll::Ready(value); + } + guard.waker = Some(context.waker().clone()); + Poll::Pending + } +} From 9860fd635a2197d4488500bd14a02d885b115c32 Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 19 May 2026 18:26:08 +0000 Subject: [PATCH 7/9] V2 --- node-graph/graph-craft/Cargo.toml | 1 + .../src/application_io/resource/opfs.rs | 188 +++++++++--------- 2 files changed, 91 insertions(+), 98 deletions(-) diff --git a/node-graph/graph-craft/Cargo.toml b/node-graph/graph-craft/Cargo.toml index 73c8b588db..e3282b8601 100644 --- a/node-graph/graph-craft/Cargo.toml +++ b/node-graph/graph-craft/Cargo.toml @@ -66,6 +66,7 @@ web-sys = { workspace = true, features = [ "DomStringList", "Event", "EventTarget", + "Window", "StorageManager", "FileSystemDirectoryHandle", "FileSystemFileHandle", diff --git a/node-graph/graph-craft/src/application_io/resource/opfs.rs b/node-graph/graph-craft/src/application_io/resource/opfs.rs index a6268e6248..5cc19e313d 100644 --- a/node-graph/graph-craft/src/application_io/resource/opfs.rs +++ b/node-graph/graph-craft/src/application_io/resource/opfs.rs @@ -7,7 +7,7 @@ use std::task::{Context, Poll, Waker}; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::{JsFuture, spawn_local}; -use web_sys::{DomException, FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetDirectoryOptions, FileSystemGetFileOptions, FileSystemWritableFileStream}; +use web_sys::{Blob, DomException, FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetDirectoryOptions, FileSystemGetFileOptions, FileSystemWritableFileStream, WritableStream}; enum Mutation { Write { hash: ResourceHash, bytes: Arc<[u8]> }, @@ -32,16 +32,15 @@ unsafe impl Sync for OpfsResourceStorage {} impl OpfsResourceStorage { pub async fn load(directory_name: &str) -> Result { - let root = open_root_directory(directory_name).await?; - - let on_disk = read_all_keys(&root).await.unwrap_or_else(|error| { + let directory = open_resource_directory(directory_name).await?; + let on_disk = enumerate_hashes(&directory).await.unwrap_or_else(|error| { log::error!("Failed to enumerate existing OPFS resource keys: {error:?}"); HashSet::new() }); Ok(Self { inner: Arc::new(Mutex::new(Inner { - directory: root, + directory, cache: HashMap::new(), on_disk, queue: VecDeque::new(), @@ -68,8 +67,7 @@ impl Resources for OpfsResourceStorage { let (sender, receiver) = oneshot(); spawn_local(async move { - let result = fetch_and_verify(inner, hash).await; - sender.send(result); + sender.send(read_from_opfs(inner, hash).await); }); Box::pin(receiver) @@ -83,15 +81,17 @@ impl ResourceStorage for OpfsResourceStorage { fn write(&mut self, data: &[u8]) -> ResourceHash { let hash = ResourceHash::from(data); - let bytes: Arc<[u8]> = Arc::from(data); - let resource = Resource::new(bytes.clone()); - let mut guard = self.inner.lock().unwrap(); - guard.cache.insert(hash, resource); - let needs_persist = !guard.on_disk.contains(&hash) || queue_has_delete_for(&guard.queue, &hash); + let mut bytes = None; + if !guard.cache.contains_key(&hash) { + let resource_bytes = Arc::<[u8]>::from(data); + guard.cache.insert(hash, Resource::new(resource_bytes.clone())); + bytes = Some(resource_bytes); + } - if needs_persist { + if !guard.on_disk.contains(&hash) { + let bytes = bytes.unwrap_or_else(|| Arc::<[u8]>::from(data)); guard.on_disk.insert(hash); guard.queue.push_back(Mutation::Write { hash, bytes }); kick_worker(&self.inner, &mut guard); @@ -106,34 +106,28 @@ impl ResourceStorage for OpfsResourceStorage { } fn garbage_collect(&mut self, used: &[ResourceHash]) { - let used_set: HashSet = used.iter().copied().collect(); + let used: HashSet = used.iter().copied().collect(); let mut guard = self.inner.lock().unwrap(); - guard.cache.retain(|hash, _| used_set.contains(hash)); + guard.cache.retain(|hash, _| used.contains(hash)); - let to_delete: Vec = guard.on_disk.iter().copied().filter(|hash| !used_set.contains(hash)).collect(); - - if to_delete.is_empty() { - return; + let unused: Vec = guard.on_disk.iter().copied().filter(|hash| !used.contains(hash)).collect(); + for hash in unused { + guard.on_disk.remove(&hash); + guard.queue.push_back(Mutation::Delete { hash }); } - for hash in &to_delete { - guard.on_disk.remove(hash); - guard.queue.push_back(Mutation::Delete { hash: *hash }); + if !guard.queue.is_empty() { + kick_worker(&self.inner, &mut guard); } - - kick_worker(&self.inner, &mut guard); } } -fn queue_has_delete_for(queue: &VecDeque, hash: &ResourceHash) -> bool { - queue.iter().any(|mutation| matches!(mutation, Mutation::Delete { hash: other } if other == hash)) -} - fn kick_worker(inner: &Arc>, guard: &mut Inner) { if guard.worker_active { return; } + guard.worker_active = true; let inner = inner.clone(); spawn_local(drain_queue(inner)); @@ -141,22 +135,19 @@ fn kick_worker(inner: &Arc>, guard: &mut Inner) { async fn drain_queue(inner: Arc>) { loop { - let (op, directory) = { + let (directory, mutation) = { let mut guard = inner.lock().unwrap(); - match guard.queue.pop_front() { - Some(op) => (op, guard.directory.clone()), - None => { - guard.worker_active = false; - return; - } - } + let Some(mutation) = guard.queue.pop_front() else { + guard.worker_active = false; + return; + }; + (guard.directory.clone(), mutation) }; - match op { + match mutation { Mutation::Write { hash, bytes } => { if let Err(error) = write_file(&directory, &hash, &bytes).await { log::error!("OPFS write for {hash} failed: {error:?}"); - inner.lock().unwrap().on_disk.remove(&hash); } } Mutation::Delete { hash } => { @@ -168,64 +159,58 @@ async fn drain_queue(inner: Arc>) { } } -async fn open_root_directory(directory_name: &str) -> Result { +async fn open_resource_directory(directory_name: &str) -> Result { let storage = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?.navigator().storage(); + let root: FileSystemDirectoryHandle = JsFuture::from(storage.get_directory()).await?.dyn_into()?; - let opfs_root: FileSystemDirectoryHandle = JsFuture::from(storage.get_directory()).await?.dyn_into()?; - - let create = FileSystemGetDirectoryOptions::new(); - create.set_create(true); + let options = FileSystemGetDirectoryOptions::new(); + options.set_create(true); - JsFuture::from(opfs_root.get_directory_handle_with_options(directory_name, &create)).await?.dyn_into() + JsFuture::from(root.get_directory_handle_with_options(directory_name, &options)).await?.dyn_into() } -async fn read_all_keys(directory: &FileSystemDirectoryHandle) -> Result, JsValue> { +async fn enumerate_hashes(directory: &FileSystemDirectoryHandle) -> Result, JsValue> { let iterator = directory.keys(); - let mut keys = HashSet::new(); + let mut hashes = HashSet::new(); loop { - let next: js_sys::IteratorNext = JsFuture::from(iterator.next_iterator()?).await?.unchecked_into(); + let next: js_sys::IteratorNext = JsFuture::from(iterator.next()?).await?.unchecked_into(); if next.done() { break; } let Some(name) = next.value().as_string() else { - log::warn!("Skipping non-string OPFS entry name"); + log::warn!("Skipping non-string OPFS resource entry"); continue; }; match ResourceHash::try_from(name.as_str()) { Ok(hash) => { - keys.insert(hash); + hashes.insert(hash); } - Err(error) => log::warn!("Skipping unparseable OPFS entry {name:?}: {error}"), + Err(error) => log::warn!("Skipping non-resource OPFS entry {name:?}: {error}"), } } - Ok(keys) -} - -fn file_name(hash: &ResourceHash) -> String { - String::from(hash) + Ok(hashes) } async fn write_file(directory: &FileSystemDirectoryHandle, hash: &ResourceHash, bytes: &[u8]) -> Result<(), JsValue> { - let create = FileSystemGetFileOptions::new(); - create.set_create(true); + let options = FileSystemGetFileOptions::new(); + options.set_create(true); let name = file_name(hash); - let handle: FileSystemFileHandle = JsFuture::from(directory.get_file_handle_with_options(&name, &create)).await?.dyn_into()?; - + let handle: FileSystemFileHandle = JsFuture::from(directory.get_file_handle_with_options(&name, &options)).await?.dyn_into()?; let writable: FileSystemWritableFileStream = JsFuture::from(handle.create_writable()).await?.dyn_into()?; + let stream: WritableStream = writable.clone().unchecked_into(); + let bytes = Uint8Array::from(bytes); - let view = Uint8Array::new_with_length(bytes.len() as u32); - view.copy_from(bytes); - - let write_result = JsFuture::from(writable.write_with_js_u8_array(&view)?).await; - let close_result = JsFuture::from(writable.close()).await; + if let Err(error) = JsFuture::from(writable.write_with_js_u8_array(&bytes)?).await { + let _ = JsFuture::from(stream.abort()).await; + return Err(error); + } - write_result?; - close_result?; + JsFuture::from(stream.close()).await?; Ok(()) } @@ -233,38 +218,35 @@ async fn delete_file(directory: &FileSystemDirectoryHandle, hash: &ResourceHash) let name = file_name(hash); match JsFuture::from(directory.remove_entry(&name)).await { Ok(_) => Ok(()), - Err(error) => { - if is_not_found(&error) { - Ok(()) - } else { - Err(error) - } - } + Err(error) if is_not_found(&error) => Ok(()), + Err(error) => Err(error), } } -fn is_not_found(error: &JsValue) -> bool { - error.dyn_ref::().is_some_and(|error| error.name() == "NotFoundError") -} +async fn read_from_opfs(inner: Arc>, hash: ResourceHash) -> Option { + let directory = { + let guard = inner.lock().unwrap(); + if let Some(resource) = guard.cache.get(&hash) { + return Some(resource.clone()); + } + if !guard.on_disk.contains(&hash) { + return None; + } + guard.directory.clone() + }; -async fn fetch_and_verify(inner: Arc>, hash: ResourceHash) -> Option { - let directory = inner.lock().unwrap().directory.clone(); let name = file_name(&hash); - - let handle = match JsFuture::from(directory.get_file_handle(&name)).await { - Ok(value) => match value.dyn_into::() { + let handle: FileSystemFileHandle = match JsFuture::from(directory.get_file_handle(&name)).await { + Ok(value) => match value.dyn_into() { Ok(handle) => handle, Err(value) => { - log::error!("OPFS get_file_handle returned non-FileSystemFileHandle for {hash}: {value:?}"); + log::error!("OPFS returned non-file handle for {hash}: {value:?}"); return None; } }, + Err(error) if is_not_found(&error) => return None, Err(error) => { - if is_not_found(&error) { - inner.lock().unwrap().on_disk.remove(&hash); - return None; - } - log::error!("OPFS get_file_handle for {hash} failed: {error:?}"); + log::error!("OPFS getFileHandle for {hash} failed: {error:?}"); return None; } }; @@ -276,39 +258,49 @@ async fn fetch_and_verify(inner: Arc>, hash: ResourceHash) -> Optio return None; } }; - let blob: web_sys::Blob = match file.dyn_into() { + let blob: Blob = match file.dyn_into() { Ok(blob) => blob, Err(value) => { log::error!("OPFS getFile returned non-Blob for {hash}: {value:?}"); return None; } }; - let buffer = match JsFuture::from(blob.array_buffer()).await { Ok(buffer) => buffer, Err(error) => { - log::error!("Reading OPFS array buffer for {hash} failed: {error:?}"); + log::error!("OPFS arrayBuffer for {hash} failed: {error:?}"); return None; } }; - let array = Uint8Array::new(&buffer); - let bytes: Arc<[u8]> = Arc::from(array.to_vec()); + let bytes: Arc<[u8]> = Uint8Array::new(&buffer).to_vec().into(); let actual = ResourceHash::from(bytes.as_ref()); if actual != hash { - log::error!("OPFS content-integrity failure: file {hash} hashes to {actual}, removing"); - let mut guard = inner.lock().unwrap(); - guard.on_disk.remove(&hash); - guard.queue.push_back(Mutation::Delete { hash }); - kick_worker(&inner, &mut guard); + log::error!("OPFS content-integrity failure: file {hash} hashes to {actual}"); return None; } let resource = Resource::new(bytes); - inner.lock().unwrap().cache.insert(hash, resource.clone()); + let mut guard = inner.lock().unwrap(); + if let Some(resource) = guard.cache.get(&hash) { + return Some(resource.clone()); + } + if !guard.on_disk.contains(&hash) { + return None; + } + + guard.cache.insert(hash, resource.clone()); Some(resource) } +fn file_name(hash: &ResourceHash) -> String { + String::from(hash) +} + +fn is_not_found(error: &JsValue) -> bool { + error.dyn_ref::().is_some_and(|error| error.name() == "NotFoundError") +} + fn oneshot() -> (OneshotSender, OneshotReceiver) { let state = Arc::new(Mutex::new(OneshotState::default())); (OneshotSender { state: state.clone() }, OneshotReceiver { state }) From 363d1b0d792e8f6de502e7605a813e3565365d54 Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 19 May 2026 20:24:59 +0000 Subject: [PATCH 8/9] Maybe bad idea --- .../src/messages/frontend/frontend_message.rs | 9 ++++- editor/src/messages/frontend/mod.rs | 1 + editor/src/messages/frontend/utility_types.rs | 31 +++++++++++++++ .../document/document_message_handler.rs | 39 ++++++++++++------- editor/src/messages/prelude.rs | 2 +- .../resource/resource_message_handler.rs | 11 +----- frontend/wrapper/src/editor_wrapper.rs | 8 ++++ 7 files changed, 76 insertions(+), 25 deletions(-) diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index c7b4489fbb..52ec511d1d 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -1,7 +1,7 @@ use super::IconName; use super::utility_types::{MouseCursorIcon, PersistedState}; use crate::messages::app_window::app_window_message_handler::AppWindowPlatform; -use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage}; +use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, FrontendMessageFuture}; use crate::messages::input_mapper::utility_types::misc::ActionShortcut; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::node_graph::utility_types::{ @@ -27,6 +27,13 @@ use crate::messages::portfolio::document::overlays::utility_types::OverlayContex #[derive(derivative::Derivative, Clone, serde::Serialize, serde::Deserialize)] #[derivative(Debug, PartialEq)] pub enum FrontendMessage { + Await { + #[serde(skip, default)] + #[derivative(Debug = "ignore", PartialEq = "ignore")] + #[cfg_attr(feature = "wasm", tsify(type = "unknown"))] + future: FrontendMessageFuture, + }, + // Display prefix: make the frontend show something, like a dialog DisplayDialog { title: String, diff --git a/editor/src/messages/frontend/mod.rs b/editor/src/messages/frontend/mod.rs index 8ab9dfb30c..03ffecbc2b 100644 --- a/editor/src/messages/frontend/mod.rs +++ b/editor/src/messages/frontend/mod.rs @@ -4,6 +4,7 @@ pub mod utility_types; #[doc(inline)] pub use frontend_message::{FrontendMessage, FrontendMessageDiscriminant}; +pub use utility_types::FrontendMessageFuture; // TODO: Make this an enum with the actual icon names, somehow derived from or tied to the frontend icon set. // TODO: Then remove `#[widget_builder(string)]` from all icon fields. diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index 9196c4baf3..32a4ccf40b 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -1,4 +1,7 @@ +use std::future::{Future, IntoFuture}; use std::path::PathBuf; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; use graph_craft::application_io::ResourceHash; @@ -82,3 +85,31 @@ pub struct EyedropperPreviewImage { pub width: u32, pub height: u32, } + +#[derive(Clone, Default)] +pub struct FrontendMessageFuture { + inner: Arc>>, +} + +impl FrontendMessageFuture { + pub fn new(future: impl Future + Send + 'static) -> Self { + Self { + inner: Arc::new(Mutex::new(Some(Box::pin(future)))), + } + } +} + +type InnerFrontendMessageFuture = Pin + Send + 'static>>; + +impl IntoFuture for FrontendMessageFuture { + type Output = FrontendMessage; + type IntoFuture = InnerFrontendMessageFuture; + + fn into_future(self) -> Self::IntoFuture { + self.inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + .expect("FrontendMessageFuture can only be awaited once") + } +} diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 31005752ae..2b37bcc583 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -925,19 +925,32 @@ impl MessageHandler> for DocumentMes } let folder = self.path.as_ref().and_then(|path| path.parent()).map(|parent| parent.to_path_buf()); - let exported = resources.export(&Vec::from_iter(self.used_resources())); - self.embedded_resources = EmbeddedResources::from_iter(exported); - let content = self.serialize_document(); - - // Clear embedded resources after serialization to free memory. - let _ = std::mem::take(&mut self.embedded_resources); - - responses.add(FrontendMessage::TriggerSaveDocument { - document_id, - name: format!("{}.{}", self.name.clone(), FILE_EXTENSION), - path, - folder, - content: content.into_bytes().into(), + let resource_hashes = Vec::from_iter(self.used_resources()).into_boxed_slice(); + let resources = resources.resources(); + let mut document = self.clone(); + let name = format!("{}.{}", self.name.clone(), FILE_EXTENSION); + + responses.add(FrontendMessage::Await { + future: FrontendMessageFuture::new(async move { + let loads = resource_hashes + .into_iter() + .map(|hash| { + let resource = resources.load(hash); + async move { resource.await.map(|resource| (hash, resource)) } + }) + .collect::>(); + + document.embedded_resources = EmbeddedResources::from_iter(futures::future::join_all(loads).await.into_iter().flatten()); + let content = document.serialize_document(); + + FrontendMessage::TriggerSaveDocument { + document_id, + name, + path, + folder, + content: content.into_bytes().into(), + } + }), }); } DocumentMessage::SavedDocument { path } => { diff --git a/editor/src/messages/prelude.rs b/editor/src/messages/prelude.rs index 878fedf500..36ac6314e8 100644 --- a/editor/src/messages/prelude.rs +++ b/editor/src/messages/prelude.rs @@ -15,7 +15,7 @@ pub use crate::messages::dialog::export_dialog::{ExportDialogMessage, ExportDial pub use crate::messages::dialog::new_document_dialog::{NewDocumentDialogMessage, NewDocumentDialogMessageDiscriminant, NewDocumentDialogMessageHandler}; pub use crate::messages::dialog::preferences_dialog::{PreferencesDialogMessage, PreferencesDialogMessageContext, PreferencesDialogMessageDiscriminant, PreferencesDialogMessageHandler}; pub use crate::messages::dialog::{DialogMessage, DialogMessageContext, DialogMessageDiscriminant, DialogMessageHandler}; -pub use crate::messages::frontend::{FrontendMessage, FrontendMessageDiscriminant}; +pub use crate::messages::frontend::{FrontendMessage, FrontendMessageDiscriminant, FrontendMessageFuture}; pub use crate::messages::input_mapper::key_mapping::{KeyMappingMessage, KeyMappingMessageContext, KeyMappingMessageDiscriminant, KeyMappingMessageHandler}; pub use crate::messages::input_mapper::{InputMapperMessage, InputMapperMessageContext, InputMapperMessageDiscriminant, InputMapperMessageHandler}; pub use crate::messages::input_preprocessor::{InputPreprocessorMessage, InputPreprocessorMessageContext, InputPreprocessorMessageDiscriminant, InputPreprocessorMessageHandler}; diff --git a/editor/src/messages/resource/resource_message_handler.rs b/editor/src/messages/resource/resource_message_handler.rs index 7ffb9f6bd8..92c70377af 100644 --- a/editor/src/messages/resource/resource_message_handler.rs +++ b/editor/src/messages/resource/resource_message_handler.rs @@ -1,5 +1,5 @@ use crate::messages::prelude::*; -use graph_craft::application_io::{Resource, ResourceFuture, ResourceHash, ResourceStorage, Resources}; +use graph_craft::application_io::{ResourceFuture, ResourceHash, ResourceStorage, Resources}; use std::sync::{Arc, RwLock}; #[derive(Clone)] @@ -26,15 +26,6 @@ impl ResourceMessageHandler { } } - pub fn export(&self, resources: &[ResourceHash]) -> Box<[(ResourceHash, Resource)]> { - let Some(storage) = &self.storage else { - log::error!("Attempted to export resources but storage is not initialized"); - return Box::new([]); - }; - let mut storage = storage.write().unwrap(); - resources.iter().filter_map(|hash| storage.read(hash).map(|resource| (*hash, resource))).collect() - } - pub fn resources(&self) -> Box { Box::new(ResourcesHandle { inner: self.storage.clone().expect("Resource storage not initialized"), diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index cd975df50e..4330d1f113 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -143,6 +143,14 @@ impl EditorWrapper { // Sends a FrontendMessage to JavaScript pub(crate) fn send_frontend_message_to_js(&self, message: FrontendMessage) { + if let FrontendMessage::Await { future } = message { + let wrapper = self.clone(); + wasm_bindgen_futures::spawn_local(async move { + wrapper.send_frontend_message_to_js(future.await); + }); + return; + } + if let FrontendMessage::UpdateImageData { ref image_data } = message { let new_hash = calculate_hash(&CacheHashWrapper(image_data)); let prev_hash = IMAGE_DATA_HASH.load(Ordering::Relaxed); From fa1f4ee27f35a1a067b0a2b6c537461b2ab0109c Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 19 May 2026 20:53:43 +0000 Subject: [PATCH 9/9] bad idea desktop --- desktop/wrapper/src/intercept_frontend_message.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 2504746f8a..f32ea1ccc3 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -7,6 +7,10 @@ use super::messages::{DesktopFrontendMessage, FileFilter, OpenFileDialogContext, pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageDispatcher, message: FrontendMessage) -> Option { match message { + FrontendMessage::Await { future } => { + let message = futures::executor::block_on(async move { future.await }); + return intercept_frontend_message(dispatcher, message); + } FrontendMessage::RenderOverlays { context } => { dispatcher.respond(DesktopFrontendMessage::UpdateOverlays(context.take_scene())); }