diff --git a/Cargo.toml b/Cargo.toml index 88b8c80b..a85cccc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ dcl = { path="crates/dcl" } dcl_component = { path="crates/dcl_component" } restricted_actions = { path="crates/restricted_actions" } wallet = { path="crates/wallet" } +nft = { path="crates/nft" } bevy = { version = "0.12", features=["jpeg"] } bevy_console = { git = "https://github.com/msklosak/bevy-console", branch = "bevy_0.12_update" } @@ -72,6 +73,7 @@ av = { workspace = true } restricted_actions = { workspace = true } wallet = { workspace = true } dcl = { workspace = true } +nft = { workspace = true } bevy = { workspace = true } bevy_console = { workspace = true } diff --git a/crates/dcl_component/build.rs b/crates/dcl_component/build.rs index 05f91110..f0a01ed0 100644 --- a/crates/dcl_component/build.rs +++ b/crates/dcl_component/build.rs @@ -37,6 +37,7 @@ fn main() -> Result<()> { "video_event", "visibility_component", "avatar_modifier_area", + "nft_shape", ]; let mut sources = components diff --git a/crates/dcl_component/src/lib.rs b/crates/dcl_component/src/lib.rs index 6236a64b..f383b26f 100644 --- a/crates/dcl_component/src/lib.rs +++ b/crates/dcl_component/src/lib.rs @@ -62,6 +62,7 @@ impl SceneComponentId { pub const AUDIO_STREAM: SceneComponentId = SceneComponentId(1021); pub const TEXT_SHAPE: SceneComponentId = SceneComponentId(1030); + pub const NFT_SHAPE: SceneComponentId = SceneComponentId(1040); pub const GLTF_CONTAINER: SceneComponentId = SceneComponentId(1041); pub const ANIMATOR: SceneComponentId = SceneComponentId(1042); diff --git a/crates/dcl_component/src/proto_components.rs b/crates/dcl_component/src/proto_components.rs index f0c7e2b3..1acbdb75 100644 --- a/crates/dcl_component/src/proto_components.rs +++ b/crates/dcl_component/src/proto_components.rs @@ -93,6 +93,7 @@ impl DclProtoComponent for sdk::components::PbAudioStream {} impl DclProtoComponent for sdk::components::PbVideoEvent {} impl DclProtoComponent for sdk::components::PbVisibilityComponent {} impl DclProtoComponent for sdk::components::PbAvatarModifierArea {} +impl DclProtoComponent for sdk::components::PbNftShape {} // VECTOR3 conversions impl Copy for common::Vector3 {} diff --git a/crates/ipfs/Cargo.toml b/crates/ipfs/Cargo.toml index d2f077b5..e2e529bb 100644 --- a/crates/ipfs/Cargo.toml +++ b/crates/ipfs/Cargo.toml @@ -20,7 +20,7 @@ clap = { workspace = true } urn = { workspace = true } async-std = { workspace = true } isahc = { workspace = true } - urlencoding = { workspace = true } + url = "2.4.0" downcast-rs = "1.2" diff --git a/crates/nft/Cargo.toml b/crates/nft/Cargo.toml new file mode 100644 index 00000000..17809bff --- /dev/null +++ b/crates/nft/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "nft" +version = "0.1.0" +edition = "2021" + +[lib] + +[dependencies] +common = { workspace = true } +console = { workspace = true } +dcl = { workspace = true } +dcl_component = { workspace = true } +scene_runner = { workspace = true } +ipfs = { workspace = true } + +bevy = { workspace = true } +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +urn = { workspace = true } +isahc = { workspace = true } +once_cell = { workspace = true } +async-std = { workspace = true } +urlencoding = { workspace = true } diff --git a/crates/nft/src/asset_source.rs b/crates/nft/src/asset_source.rs new file mode 100644 index 00000000..4c1feb09 --- /dev/null +++ b/crates/nft/src/asset_source.rs @@ -0,0 +1,209 @@ +use std::{io::ErrorKind, str::FromStr, time::Duration}; + +use async_std::io::{Cursor, ReadExt}; +use bevy::{ + asset::{ + io::{AssetReader, AssetReaderError, AssetSourceBuilder, Reader}, + AssetApp, AssetLoader, + }, + prelude::*, +}; +use isahc::{config::Configurable, http::StatusCode, AsyncReadResponseExt, RequestExt}; +use serde::Deserialize; + +pub struct NftReaderPlugin; + +impl Plugin for NftReaderPlugin { + fn build(&self, app: &mut App) { + app.register_asset_source( + "nft", + AssetSourceBuilder::default().with_reader(|| Box::::default()), + ); + } +} + +#[derive(Default)] +pub struct NftReader; + +impl AssetReader for NftReader { + fn read<'a>( + &'a self, + path: &'a std::path::Path, + ) -> bevy::utils::BoxedFuture< + 'a, + Result>, bevy::asset::io::AssetReaderError>, + > { + let path = path.to_owned(); + Box::pin(async move { + println!("getting nft raw data"); + + let path = path.to_string_lossy(); + let Some(encoded_urn) = path.split('.').next() else { + return Err(AssetReaderError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + path, + ))); + }; + let urn = urlencoding::decode(encoded_urn).map_err(|e| { + AssetReaderError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, e)) + })?; + let urn = urn::Urn::from_str(&urn).map_err(|e| { + AssetReaderError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, e)) + })?; + + if urn.nid() != "decentraland" { + return Err(AssetReaderError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "nid must be `decentraland`", + ))); + } + + let mut parts = urn.nss().split(':'); + let (Some(chain), Some(_standard), Some(address), Some(token)) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return Err(AssetReaderError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "nss must be `chain:standard:contract_address:token`", + ))); + }; + + if chain != "ethereum" { + return Err(AssetReaderError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "unsupported chain `{chain}`", + ))); + } + + let remote = format!( + "https://opensea.decentraland.org/api/v1/asset/{}/{}", + address, token + ); + + let token = path; + + let mut attempt = 0; + let data = loop { + attempt += 1; + + let request = isahc::Request::get(&remote) + .connect_timeout(Duration::from_secs(5 * attempt)) + .timeout(Duration::from_secs(30 * attempt)) + .body(()) + .map_err(|e| { + AssetReaderError::Io(std::io::Error::new( + ErrorKind::Other, + format!("[{token:?}]: {e}"), + )) + })?; + + let response = request.send_async().await; + + debug!("[{token:?}]: attempt {attempt}: request: {remote}, response: {response:?}"); + + let mut response = match response { + Err(e) if e.is_timeout() && attempt <= 3 => continue, + Err(e) => { + return Err(AssetReaderError::Io(std::io::Error::new( + ErrorKind::Other, + format!("[{token:?}]: {e}"), + ))) + } + Ok(response) if !matches!(response.status(), StatusCode::OK) => { + return Err(AssetReaderError::Io(std::io::Error::new( + ErrorKind::Other, + format!( + "[{token:?}]: server responded with status {} requesting `{}`", + response.status(), + remote, + ), + ))) + } + Ok(response) => response, + }; + + let data = response.bytes().await; + + match data { + Ok(data) => break data, + Err(e) => { + if matches!(e.kind(), std::io::ErrorKind::TimedOut) && attempt <= 3 { + continue; + } + return Err(AssetReaderError::Io(std::io::Error::new( + ErrorKind::Other, + format!("[{token:?}] {e}"), + ))); + } + } + }; + + println!("GOT nft RAW DATA!"); + + let reader: Box = Box::new(Cursor::new(data)); + Ok(reader) + }) + } + + fn read_meta<'a>( + &'a self, + path: &'a std::path::Path, + ) -> bevy::utils::BoxedFuture< + 'a, + Result>, bevy::asset::io::AssetReaderError>, + > { + Box::pin(async { Err(AssetReaderError::NotFound(path.to_owned())) }) + } + + fn read_directory<'a>( + &'a self, + _: &'a std::path::Path, + ) -> bevy::utils::BoxedFuture< + 'a, + Result, bevy::asset::io::AssetReaderError>, + > { + panic!() + } + + fn is_directory<'a>( + &'a self, + _: &'a std::path::Path, + ) -> bevy::utils::BoxedFuture<'a, Result> { + Box::pin(async { Ok(false) }) + } +} + +#[derive(Asset, TypePath, Deserialize)] +pub struct Nft { + pub image_url: String, +} + +pub struct NftLoader; + +impl AssetLoader for NftLoader { + type Asset = Nft; + type Settings = (); + type Error = std::io::Error; + + fn load<'a>( + &'a self, + reader: &'a mut Reader, + _: &'a Self::Settings, + _: &'a mut bevy::asset::LoadContext, + ) -> bevy::utils::BoxedFuture<'a, Result> { + Box::pin(async move { + println!("loading nft"); + let mut bytes = Vec::default(); + reader + .read_to_end(&mut bytes) + .await + .map_err(|e| std::io::Error::new(e.kind(), e))?; + serde_json::from_reader(bytes.as_slice()) + .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e)) + }) + } + + fn extensions(&self) -> &[&str] { + &["nft"] + } +} diff --git a/crates/nft/src/lib.rs b/crates/nft/src/lib.rs new file mode 100644 index 00000000..6b95182d --- /dev/null +++ b/crates/nft/src/lib.rs @@ -0,0 +1,239 @@ +pub mod asset_source; + +use std::{f32::consts::FRAC_PI_2, path::PathBuf}; + +use asset_source::{Nft, NftLoader}; +use bevy::{ + gltf::Gltf, + prelude::*, + utils::{HashMap, HashSet}, +}; +use common::{sets::SceneSets, util::TryPushChildrenEx}; +use dcl::interface::ComponentPosition; +use dcl_component::{ + proto_components::sdk::components::{NftFrameType, PbNftShape}, + SceneComponentId, +}; +use ipfs::ipfs_path::IpfsPath; +use once_cell::sync::Lazy; +use scene_runner::update_world::AddCrdtInterfaceExt; + +pub struct NftShapePlugin; + +impl Plugin for NftShapePlugin { + fn build(&self, app: &mut App) { + app.register_asset_loader(NftLoader); + app.init_asset::(); + app.add_crdt_lww_component::( + SceneComponentId::NFT_SHAPE, + ComponentPosition::EntityOnly, + ); + app.add_systems( + Update, + (update_nft_shapes, load_frame, load_nft, resize_nft).in_set(SceneSets::PostLoop), + ); + } +} + +#[derive(Component)] +pub struct NftShape(pub PbNftShape); + +impl From for NftShape { + fn from(value: PbNftShape) -> Self { + Self(value) + } +} + +#[derive(Component)] +pub struct NftShapeMarker; + +#[derive(Component)] +pub struct RetryNftShape; + +fn update_nft_shapes( + mut commands: Commands, + query: Query<(Entity, &NftShape), Changed>, + existing: Query<(Entity, &Parent), With>, + mut removed: RemovedComponents, + asset_server: Res, +) { + // remove changed and deleted nodes + let old_parents = query + .iter() + .map(|(e, ..)| e) + .chain(removed.read()) + .collect::>(); + for (ent, par) in existing.iter() { + if old_parents.contains(&par.get()) { + commands.entity(ent).despawn_recursive(); + } + } + + // add new nodes + for (ent, nft_shape) in query.iter() { + // spawn parent + let nft_ent = commands + .spawn(( + SpatialBundle { + transform: Transform::from_scale(Vec3::new(0.5, 0.5, 1.0)), + ..Default::default() + }, + NftShapeMarker, + )) + .with_children(|c| { + // spawn frame + c.spawn((SpatialBundle::default(), FrameLoading(nft_shape.0.style()))); + + // spawn content + c.spawn(( + SpatialBundle::default(), + NftLoading(asset_server.load(format!( + "nft://{}.nft", + urlencoding::encode(&nft_shape.0.urn) + ))), + )); + }) + .id(); + + commands.entity(ent).try_push_children(&[nft_ent]); + } +} + +#[derive(Component)] +pub struct FrameLoading(NftFrameType); + +fn load_frame( + mut commands: Commands, + q: Query<(Entity, &FrameLoading)>, + asset_server: Res, + mut gltf_handles: Local>>, + gltfs: Res>, + mut scene_spawner: ResMut, +) { + for (ent, frame) in q.iter() { + // get frame + let h_gltf = gltf_handles + .entry(frame.0) + .or_insert_with(|| asset_server.load(*NFTSHAPE_LOOKUP.get(&frame.0).unwrap())); + let Some(gltf) = gltfs.get(h_gltf.id()) else { + println!("waiting for frame"); + continue; + }; + + // \o/ + let transform = if frame.0 == NftFrameType::NftClassic { + Transform::IDENTITY + } else { + Transform::from_rotation(Quat::from_rotation_x(-FRAC_PI_2)) + }; + + let child = commands + .spawn(SpatialBundle { + transform, + ..Default::default() + }) + .id(); + + scene_spawner.spawn_as_child(gltf.default_scene.as_ref().unwrap(), child); + commands + .entity(ent) + .remove::() + .try_push_children(&[child]); + } +} + +#[derive(Component)] +pub struct NftLoading(Handle); + +fn load_nft( + mut commands: Commands, + q: Query<(Entity, &NftLoading)>, + asset_server: Res, + mut meshes: ResMut>, + mut materials: ResMut>, + mut mesh: Local>>, + nfts: Res>, +) { + for (ent, nft) in q.iter() { + let Some(nft) = nfts.get(nft.0.id()) else { + println!("waiting for nft"); + continue; + }; + + // get image + let url = nft.image_url.replace("auto=format", "format=png"); + let ipfs_path = IpfsPath::new_from_url(&url, "png"); + let h_image = asset_server.load(PathBuf::from(&ipfs_path)); + + commands + .entity(ent) + .try_insert(( + PbrBundle { + transform: Transform::from_translation(Vec3::Z * 0.03), + mesh: mesh + .get_or_insert_with(|| meshes.add(shape::Quad::default().into())) + .clone(), + material: materials.add(StandardMaterial { + base_color_texture: Some(h_image.clone()), + ..Default::default() + }), + ..Default::default() + }, + NftResize(h_image), + )) + .remove::(); + } +} + +#[derive(Component)] +pub struct NftResize(Handle); + +fn resize_nft( + mut commands: Commands, + q: Query<(Entity, &Parent, &NftResize)>, + images: Res>, + mut transforms: Query<&mut Transform, With>, +) { + for (ent, parent, resize) in q.iter() { + if let Some(image) = images.get(resize.0.id()) { + let max_dim = image.width().max(image.height()) as f32 * 2.0; + let w = image.width() as f32 / max_dim; + let h = image.height() as f32 / max_dim; + + if let Ok(mut transform) = transforms.get_mut(parent.get()) { + transform.scale = Vec3::new(w, h, 1.0); + } + + commands.entity(ent).remove::(); + } + } +} + +static NFTSHAPE_LOOKUP: Lazy> = Lazy::new(|| { + use NftFrameType::*; + HashMap::from_iter([ + (NftClassic, "nft_shapes/Classic.glb"), + (NftBaroqueOrnament, "nft_shapes/Baroque_Ornament.glb"), + (NftDiamondOrnament, "nft_shapes/Diamond_Ornament.glb"), + (NftMinimalWide, "nft_shapes/Minimal_Wide.glb"), + (NftMinimalGrey, "nft_shapes/Minimal_Grey.glb"), + (NftBlocky, "nft_shapes/Blocky.glb"), + (NftGoldEdges, "nft_shapes/Gold_Edges.glb"), + (NftGoldCarved, "nft_shapes/Gold_Carved.glb"), + (NftGoldWide, "nft_shapes/Gold_Wide.glb"), + (NftGoldRounded, "nft_shapes/Gold_Rounded.glb"), + (NftMetalMedium, "nft_shapes/Metal_Medium.glb"), + (NftMetalWide, "nft_shapes/Metal_Wide.glb"), + (NftMetalSlim, "nft_shapes/Metal_Slim.glb"), + (NftMetalRounded, "nft_shapes/Metal_Rounded.glb"), + (NftPins, "nft_shapes/Pins.glb"), + (NftMinimalBlack, "nft_shapes/Minimal_Black.glb"), + (NftMinimalWhite, "nft_shapes/Minimal_White.glb"), + (NftTape, "nft_shapes/Tape.glb"), + (NftWoodSlim, "nft_shapes/Wood_Slim.glb"), + (NftWoodWide, "nft_shapes/Wood_Wide.glb"), + (NftWoodTwigs, "nft_shapes/Wood_Twigs.glb"), + (NftCanvas, "nft_shapes/Canvas.glb"), + (NftNone, "nft_shapes/Classic.glb"), + ]) +}); diff --git a/src/main.rs b/src/main.rs index b28e30d6..47586843 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,7 @@ use comms::CommsPlugin; use console::{ConsolePlugin, DoAddConsoleCommand}; use input_manager::InputManagerPlugin; use ipfs::IpfsIoPlugin; +use nft::{asset_source::NftReaderPlugin, NftShapePlugin}; use system_ui::SystemUiPlugin; use ui_core::UiCorePlugin; use user_input::UserInputPlugin; @@ -211,7 +212,8 @@ fn main() { .add_before::(IpfsIoPlugin { starting_realm: Some(final_config.server.clone()), cache_root: Default::default(), - }), + }) + .add_before::(NftReaderPlugin), ); if final_config.graphics.log_fps { @@ -237,7 +239,8 @@ fn main() { .add_plugins(ConsolePlugin { add_egui: true }) .add_plugins(VisualsPlugin { no_fog }) .add_plugins(WalletPlugin) - .add_plugins(CommsPlugin); + .add_plugins(CommsPlugin) + .add_plugins(NftShapePlugin); if !no_avatar { app.add_plugins(AvatarPlugin);