diff --git a/Cargo.lock b/Cargo.lock index 0efdb6f..10326af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,6 +71,18 @@ dependencies = [ "winit", ] +[[package]] +name = "accessory" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850bb534b9dc04744fbbb71d30ad6d25a7e4cf6dc33e223c81ef3a92ebab4e0b" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.51", +] + [[package]] name = "addr2line" version = "0.21.0" @@ -2117,6 +2129,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.0", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -2129,6 +2154,18 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" +[[package]] +name = "delegate-display" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a85201f233142ac819bbf6226e36d0b5e129a47bd325084674261c82d4cd66" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.51", +] + [[package]] name = "derive_more" version = "0.99.17" @@ -2351,6 +2388,18 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fancy_constructor" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f71f317e4af73b2f8f608fac190c52eac4b1879d2145df1db2fe48881ca69435" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.51", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -2959,7 +3008,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.5.4", + "socket2 0.5.6", "tokio", "tower-service", "tracing", @@ -3007,6 +3056,23 @@ dependencies = [ "tiff", ] +[[package]] +name = "indexed_db_futures" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc2083760572ee02385ab8b7c02c20925d2dd1f97a1a25a8737a238608f1152" +dependencies = [ + "accessory", + "cfg-if", + "delegate-display", + "fancy_constructor", + "js-sys", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3194,9 +3260,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.65" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" dependencies = [ "wasm-bindgen", ] @@ -3275,9 +3341,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.148" +version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] name = "libloading" @@ -3368,6 +3434,53 @@ dependencies = [ "libc", ] +[[package]] +name = "macroific" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05c00ac596022625d01047c421a0d97d7f09a18e429187b341c201cb631b9dd" +dependencies = [ + "macroific_attr_parse", + "macroific_core", + "macroific_macro", +] + +[[package]] +name = "macroific_attr_parse" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd94d5da95b30ae6e10621ad02340909346ad91661f3f8c0f2b62345e46a2f67" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.51", +] + +[[package]] +name = "macroific_core" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13198c120864097a565ccb3ff947672d969932b7975ebd4085732c9f09435e55" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.51", +] + +[[package]] +name = "macroific_macro" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c9853143cbed7f1e41dc39fee95f9b361bec65c8dc2a01bf609be01b61f5ae" +dependencies = [ + "macroific_attr_parse", + "macroific_core", + "proc-macro2", + "quote", + "syn 2.0.51", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3465,9 +3578,9 @@ checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff" [[package]] name = "mio" -version = "0.8.8" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "log", @@ -3711,9 +3824,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" +checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" dependencies = [ "autocfg", "libm", @@ -4674,12 +4787,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.4" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e" +checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -4845,12 +4958,15 @@ dependencies = [ "bytemuck", "clap", "console_error_panic_hook", + "dashmap", "futures", "futures-intrusive", "futures-lite 2.2.0", "image", + "indexed_db_futures", "itertools 0.12.1", "js-sys", + "once_cell", "parking_lot", "reqwest", "serde", @@ -4861,6 +4977,7 @@ dependencies = [ "studio-bvh", "uuid", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", "wgpu", ] @@ -4986,9 +5103,9 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.49" +version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4" +checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" dependencies = [ "thiserror-impl", ] @@ -5015,9 +5132,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "1.0.49" +version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc" +checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" dependencies = [ "proc-macro2", "quote", @@ -5062,16 +5179,16 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.33.0" +version = "1.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f38200e3ef7995e5ef13baec2f432a6da0aa9ac495b2c0e8f3b7eec2c92d653" +checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" dependencies = [ "backtrace", "bytes", "libc", "mio", "pin-project-lite", - "socket2 0.5.4", + "socket2 0.5.6", "windows-sys 0.48.0", ] @@ -5303,12 +5420,13 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "uuid" -version = "1.4.1" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" +checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" dependencies = [ "getrandom", "serde", + "wasm-bindgen", ] [[package]] @@ -5458,9 +5576,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.65" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db499c5f66323272151db0e666cd34f78617522fb0c1604d31a27c50c206a85" +checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/package-lock.json b/package-lock.json index 27871e5..b4002e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@vitest/ui": "~0.32.0", "chalk": "^5.3.0", "glob": "^10.3.10", + "idb": "8.0.0", "jsdom": "~22.1.0", "nx": "16.9.1", "prettier": "^2.6.2", @@ -7025,6 +7026,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz", + "integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==", + "dev": true + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/package.json b/package.json index 55de6be..6962616 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "vite-plugin-wasm": "^3.2.2", "vite-plugin-web-components-hmr": "^0.1.3", "vitest": "~0.32.0", - "which": "^4.0.0" + "which": "^4.0.0", + "idb": "8.0.0" }, "dependencies": { "@fortawesome/fontawesome-pro": "^6.4.2", diff --git a/studio-frontend/src/app/app.element.ts b/studio-frontend/src/app/app.element.ts index 11ed178..17c336d 100644 --- a/studio-frontend/src/app/app.element.ts +++ b/studio-frontend/src/app/app.element.ts @@ -11,11 +11,14 @@ import { DialogElement } from "@storyteller/studio-ui/dialog"; import { SceneHierarchyElement, StorytellerApi, + StorytellerLocalApi, + StorytellerRemoteApi, StudioURLParams, + mockApi, } from "@storyteller/studio-web"; import { match } from "@storyteller/utility"; import { LitElement, TemplateResult, html, nothing } from "lit"; -import { customElement, state } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; import { createRef, ref } from "lit/directives/ref.js"; import { styleMap } from "lit/directives/style-map.js"; @@ -40,7 +43,6 @@ import "./help.element"; import "./temp-video-preview.element"; import "./app.element.scss"; -import { mock_save_load } from "./util"; @customElement("app-root") @provide({ @@ -58,7 +60,7 @@ export class AppElement extends LitElement { @state() currentSidebar : string = 'hierarchy'; - private api = new StorytellerApi(); + @property({ attribute: false }) api: StorytellerApi = new StorytellerApi(mockApi ? new StorytellerLocalApi() : new StorytellerRemoteApi()); #addToSceneButtonRef = createRef(); #videoStylePreviewBtnRef = createRef(); @@ -76,43 +78,15 @@ export class AppElement extends LitElement { async onSceneSaved({ detail: scene }: SceneSavedEvent): Promise { try { const file = new File([scene], "scene.scn.ron", { type: "text/ron" }); - if (mock_save_load()) { - /* - // NB(brandon, 2024-03-22): Hey Lee-Orr, I'm commenting this out since the file - picker isn't defined and the Netlify builds are breaking. Feel free to uncomment - this for your testing. We probably just need a local definition file so the - builds are happy. + + const token = await this.api.createScene(file); - let handle = await window.showSaveFilePicker({ - suggestedName: "scene", - types: [{ - description: "Scene Files", - accept: { - "text/ron": [".scn.ron"] - } - }] - }); - let writable = await handle.createWritable(); - await writable.write(file); - await writable.close(); - let token = handle.name; - - this.dispatchEvent(new CustomEvent("scene-uploaded", { - detail: token, - bubbles: true, - composed: true, - })); - */ - - } else { - const token = await this.api.createScene(file); - - this.dispatchEvent(new CustomEvent("scene-uploaded", { - detail: token, - bubbles: true, - composed: true, - })); - } + this.dispatchEvent(new CustomEvent("scene-uploaded", { + detail: token, + bubbles: true, + composed: true, + })); + } catch (err) { console.error(err); diff --git a/studio-web/package.json b/studio-web/package.json index 2b55d35..363b3c4 100644 --- a/studio-web/package.json +++ b/studio-web/package.json @@ -4,7 +4,8 @@ "dependencies": { "@storyteller/framework": "0.0.1", "@storyteller/studio": "0.0.1", - "lit": "^2.8.0" + "lit": "^2.8.0", + "idb": "8.0.0" }, "main": "./index.js", "module": "./index.mjs", diff --git a/studio-web/src/lib/storyteller-api.ts b/studio-web/src/lib/storyteller-api.ts index 2df7959..980bb3e 100644 --- a/studio-web/src/lib/storyteller-api.ts +++ b/studio-web/src/lib/storyteller-api.ts @@ -1,7 +1,24 @@ import axios, { AxiosResponse } from "axios"; import { v4 as uuid } from "uuid"; +import { DBSchema, IDBPDatabase, openDB } from "idb"; +import * as studio from "@storyteller/studio"; -export class StorytellerApi { +export const mockApi = window.location.search.includes("mock-api"); + +interface IStorytellerApi { + createScene(file: File): Promise + session(): Promise + listMediaFiles(options: ListMediaFilesOptions): Promise + uploadMediaFile( + file: File, + { subtype, mediaClass }: UploadMediaFileOptions, + ): Promise + renameMediaFile(token: MediaFileToken, name: string): Promise + convertFbxToGltf(token: MediaFileToken): Promise + mediaFile(token: MediaFileToken): Promise +} + +export class StorytellerRemoteApi implements IStorytellerApi { #api = axios.create({ baseURL: "https://api.fakeyou.com", withCredentials: true, @@ -14,8 +31,8 @@ export class StorytellerApi { uuid_idempotency_token: uuid(), file, }, { - headers: { Accept: "text/plain" }, - }, + headers: { Accept: "text/plain" }, + }, ); this.#validateResponse(response); @@ -105,10 +122,352 @@ export class StorytellerApi { } } +interface LocalDbSchema extends DBSchema { + 'media-files': { + key: MediaFileToken, + value: MediaFile, + indexes: { + 'media-type': MediaFileType, + 'media-class': MediaFileClass + } + }, + 'file-blobs': { + key: string, + value: Uint8Array + } +} + +export class StorytellerLocalApi implements IStorytellerApi { + database?: IDBPDatabase; + dateFormat: Intl.DateTimeFormat = new Intl.DateTimeFormat("en-us") + + constructor() { + document.addEventListener("request-file", async (e: studio.RequestFileEvent) => { + const token = e.detail; + console.log("Loading Asset: ", token); + try { + let file = await this.mediaFile(token); + console.log("Got Asset File: ", token, file); + let path = file.public_bucket_path; + let blob = await this.mediaBlob(path); + console.log("Got Asset Blob", token); + studio.requestedFileReceived(token, blob); + console.log("Set File to Bevy", token) + } catch (e) { + console.error("Error Loading File", token, e); + studio.requestedFileFailed(token, e); + } + }) + } + + + async getDatabase(): Promise> { + if (this.database) { + return this.database; + } + this.database = await openDB('storyteller-local', 1, { + upgrade(db) { + const media_files = db.createObjectStore('media-files'); + media_files.createIndex('media-class', 'media_class'); + media_files.createIndex('media-type', 'media_type'); + + db.createObjectStore('file-blobs'); + } + }); + return this.database + } + + async createScene(file: File): Promise { + let token = uuid(); + let original_file_name = file.name; + let filename = token + "-" + file.name; + + let media_file: MediaFile = { + token: token, + media_type: MediaFileType.ScnRon, + maybe_media_subtype: MediaFileSubtype.StorytellerScene, + media_class: MediaFileClass.Scene, + public_bucket_path: filename, + creator_set_visibility: Visibility.Public, + is_emulated_media_file: false, + maybe_title: filename, + maybe_original_filename: original_file_name, + stats: { + positive_rating_count: 0, + bookmark_count: 0 + }, + created_at: (new Date()).toLocaleString("en-us"), + updated_at: (new Date()).toLocaleString("en-us"), + }; + + + let buffer = new Uint8Array(await file.arrayBuffer(), 0); + + let db = await this.getDatabase(); + let transaction = db.transaction(['media-files', 'file-blobs'], 'readwrite'); + + await Promise.all([ + transaction.objectStore('media-files').add(media_file, token), + transaction.objectStore('file-blobs').add(buffer, filename), + transaction.done, + ]); + + return token; + } + + async session(): Promise { + return { + success: true, + logged_in: true, + user: { + username: "local_user", + + can_access_studio: true, + can_approve_w21_templates: true, + can_ban_users: true, + can_delete_other_users_tts_models: true, + can_delete_other_users_tts_results: true, + can_delete_other_users_w2l_results: true, + can_delete_other_users_w2l_templates: true, + can_delete_own_account: true, + can_delete_own_tts_models: true, + can_delete_own_tts_results: true, + can_delete_own_w2l_results: true, + can_delete_own_w2l_templates: true, + can_delete_users: true, + can_edit_other_users_profiles: true, + can_edit_other_users_tts_models: true, + can_edit_other_users_w2l_templates: true, + can_upload_tts_models: true, + can_upload_w2l_templates: true, + can_use_tts: true, + can_use_w2l: true, + core_info: { + default_avatar: { + color_index: 0, + image_index: 0 + }, + display_name: "Local User", + gravatar_hash: "", + user_token: "", + username: "local_user" + }, + display_name: "Local User", + email_gravatar_hash: "", + fakeyou_plan: FakeYouPlan.Pro, + maybe_feature_flags: {}, + storyteller_stream_plan: StorytellerStreamPlan.Pro, + user_token: "user_token" + } + } + } + + async listMediaFiles({ filterMediaClasses, filterMediaType }: ListMediaFilesOptions): Promise { + let db = await this.getDatabase(); + + if (!filterMediaClasses && !filterMediaType) { + let mediaFiles = await db.getAll('media-files'); + let media: ListMediaFilesResult = { + pagination: { + current: 0, + total_page_count: 1 + }, + results: mediaFiles.map((mediaFile) => { + let listItem: MediaFileListItem = { + created_at: mediaFile.created_at, + creator_set_visibility: Visibility.Public, + media_type: mediaFile.media_type, + origin: { + origin_category: MediaFileOriginCategory.Upload, + product_category: MediaFileOriginProductCategory.Unknown, + }, + origin_category: MediaFileOriginCategory.Upload, + origin_product_category: MediaFileOriginProductCategory.Unknown, + public_bucket_path: mediaFile.public_bucket_path, + token: mediaFile.token, + updated_at: mediaFile.created_at + }; + return listItem + }), + success: true + }; + return media; + } + + let media: Record = {}; + + let filteredResults = (await Promise.all([(filterMediaClasses ?? []).map((filter) => + db.getAllFromIndex('media-files', 'media-class', filter) + ), (filterMediaType ?? []).map((filter) => + db.getAllFromIndex('media-files', 'media-type', filter) + )].flat())).flat(); + + for (const mediaFile of filteredResults) { + if (media[mediaFile.token]) { + continue; + } + media[mediaFile.token] = { + created_at: mediaFile.created_at, + creator_set_visibility: Visibility.Public, + media_type: mediaFile.media_type, + origin: { + origin_category: MediaFileOriginCategory.Upload, + product_category: MediaFileOriginProductCategory.Unknown, + }, + origin_category: MediaFileOriginCategory.Upload, + origin_product_category: MediaFileOriginProductCategory.Unknown, + public_bucket_path: mediaFile.public_bucket_path, + token: mediaFile.token, + updated_at: mediaFile.created_at + }; + } + + let result: ListMediaFilesResult = { + pagination: { + current: 0, + total_page_count: 1 + }, + results: Object.keys(media).map((key) => media[key]), + success: true + }; + return result; + } + + async uploadMediaFile(file: File, { subtype, mediaClass }: UploadMediaFileOptions): Promise { + let token = uuid(); + let original_file_name = file.name; + let filename = token + "-" + file.name; + + let media_type: MediaFileType | false = false; + + if (mediaClass === MediaFileClass.Audio) { + media_type = MediaFileType.Audio; + } else if (mediaClass === MediaFileClass.Video) { + media_type = MediaFileType.Video; + } else if (mediaClass === MediaFileClass.Image) { + media_type = MediaFileType.Image; + } else if (mediaClass === MediaFileClass.Scene) { + if (subtype === MediaFileSubtype.StorytellerScene) { + media_type = MediaFileType.ScnRon; + } + } else if (mediaClass === MediaFileClass.Animation) { + media_type = MediaFileType.Mocap + } else if (file.name.endsWith("gltf")) { + media_type = MediaFileType.Gltf; + } else if (file.name.endsWith("glb")) { + media_type = MediaFileType.Glb; + } else if (file.name.endsWith("bvh")) { + media_type = MediaFileType.Bvh; + } else if (file.name.endsWith("fbx")) { + media_type = MediaFileType.Fbx; + } + + if (!media_type) { + throw new Error("Couldn't determine media file type"); + } + + let media_file: MediaFile = { + token: token, + media_type, + media_class: mediaClass, + public_bucket_path: filename, + creator_set_visibility: Visibility.Public, + is_emulated_media_file: false, + maybe_title: original_file_name, + maybe_original_filename: original_file_name, + maybe_media_subtype: subtype, + stats: { + positive_rating_count: 0, + bookmark_count: 0 + }, + created_at: (new Date()).toLocaleString("en-us"), + updated_at: (new Date()).toLocaleString("en-us"), + }; + + let buffer = new Uint8Array(await file.arrayBuffer(), 0); + + let db = await this.getDatabase(); + + let transaction = db.transaction(['media-files', 'file-blobs'], 'readwrite'); + await Promise.all([ + transaction.objectStore('media-files').add(media_file, token), + transaction.objectStore('file-blobs').add(buffer, filename), + transaction.done, + ]); + + return token; + } + + async renameMediaFile(token: string, name: string): Promise { + let db = await this.getDatabase(); + + let transaction = db.transaction('media-files', 'readwrite'); + if (transaction.store !== undefined) { + let store = transaction.store; + let item = await store.get(token); + if (!item) { throw new Error("No Such File"); } + item.maybe_title = name; + await store.put(item, token); + } + await transaction.done; + } + + async convertFbxToGltf(token: string): Promise { + console.log("not implemented"); + } + + async mediaFile(token: string): Promise { + let db = await this.getDatabase(); + let file = await db.get('media-files', token); + if (!file) { + throw new Error("No Such File"); + } + return file; + } + + async mediaBlob(path: string): Promise { + let db = await this.getDatabase(); + let file = await db.get('file-blobs', path); + if (!file) { + throw new Error("No Such File"); + } + return file; + } +} + +export class StorytellerApi implements IStorytellerApi { + createScene(file: File): Promise { + return this.apiBase.createScene(file) + } + session(): Promise { + return this.apiBase.session() + } + listMediaFiles(options: ListMediaFilesOptions): Promise { + return this.apiBase.listMediaFiles(options) + } + uploadMediaFile(file: File, { subtype, mediaClass }: UploadMediaFileOptions): Promise { + return this.apiBase.uploadMediaFile(file, { subtype, mediaClass }) + } + renameMediaFile(token: string, name: string): Promise { + return this.apiBase.renameMediaFile(token, name) + } + convertFbxToGltf(token: string): Promise { + return this.apiBase.convertFbxToGltf(token) + } + mediaFile(token: string): Promise { + return this.apiBase.mediaFile(token) + } + apiBase: IStorytellerApi; + + constructor(apiBase: IStorytellerApi) { + this.apiBase = apiBase + } +} + export class ApiError extends Error { detail: T; - constructor (detail: T) { + constructor(detail: T) { super("API Error"); this.detail = detail; } diff --git a/studio/Cargo.toml b/studio/Cargo.toml index b5d9bc2..8edffb7 100644 --- a/studio/Cargo.toml +++ b/studio/Cargo.toml @@ -38,6 +38,7 @@ web-sys = { version = "0.3.65", optional = true, features = [ "Document", "HtmlElement", "Window", + "Location" ] } clap = { version = "4.4.18", features = ["derive"] } futures-intrusive = "0.5.0" @@ -52,6 +53,10 @@ serde_repr = "0.1.18" bevy_tweening = "0.9" async-compat = "0.2" uuid = { version = "1", features = ["v4", "serde"]} +indexed_db_futures = { version = "0.4", optional = true} +wasm-bindgen-futures = { version = "0.4", optional = true} +dashmap = "5" +once_cell = "1" [dependencies.reqwest] version = "0.11.24" @@ -67,6 +72,8 @@ wasm = [ "dep:js-sys", "dep:serde-wasm-bindgen", "dep:web-sys", + "dep:indexed_db_futures", + "dep:wasm-bindgen-futures", "bevy_rapier3d/wasm-bindgen", ] desktop = [] diff --git a/studio/src/remote/reader.rs b/studio/src/remote/reader.rs index c732b85..bab1a6e 100644 --- a/studio/src/remote/reader.rs +++ b/studio/src/remote/reader.rs @@ -6,6 +6,7 @@ use bevy::{ prelude::*, utils::BoxedFuture, }; + use reqwest::StatusCode; use super::response::GetMediaFileSuccessResponse; @@ -78,6 +79,29 @@ impl RemoteAssetReader { let reader: Box = Box::new(VecReader::new(bytes)); Ok(reader) } + + #[cfg(all(feature = "wasm", target_family = "wasm"))] + async fn load_local<'a>(&'a self, path: &'a Path) -> Result>, AssetReaderError> { + let token = path + .ext_file_prefix() + .ok_or_else(|| { + AssetReaderError::Io(std::io::Error::new( + ErrorKind::Other, + format!("Failed to parse token from '{path:?}'"), + )) + })? + .to_str() + .unwrap(); + + let bytes = wasm::LoadLocalFromIdb::new(token); + info!("Sent request to bevy"); + let bytes = bytes + .await + .map_err(|e| std::io::Error::new(ErrorKind::Other, format!("{e:?}")))?; + + let reader: Box = Box::new(VecReader::new(bytes)); + Ok(reader) + } } impl AssetReader for RemoteAssetReader { @@ -85,15 +109,25 @@ impl AssetReader for RemoteAssetReader { &'a self, path: &'a Path, ) -> BoxedFuture<'a, Result>, AssetReaderError>> { - let unboxed = async move { self.load(path).await }; - if cfg!(not(feature = "wasm")) { + #[cfg(not(all(feature = "wasm", target_family = "wasm")))] + { info!("wasm mode not detected"); // NB: Headless mode (server rendering) is running without a Tokio runtime. // The reqwest HTTP client requires async-compat to function at runtime. - Box::pin(Compat::new(unboxed)) - } else { + Box::pin(Compat::new(async move { self.load(path).await })) + } + + #[cfg(all(feature = "wasm", target_family = "wasm"))] + { info!("wasm mode"); - Box::pin(unboxed) + if let Some(window) = web_sys::window() { + if let Ok(search) = window.location().search() { + if search.contains("mock-api") { + return Box::pin(async move { self.load_local(path).await }); + } + } + } + Box::pin(async move { self.load(path).await }) } } @@ -128,3 +162,129 @@ impl IntoError for reqwest::Error { AssetReaderError::Io(std::io::Error::new(ErrorKind::Other, format!("{self}"))) } } + +#[cfg(all(feature = "wasm", target_family = "wasm"))] +mod wasm { + use std::task::Waker; + + use bevy::log::info; + use dashmap::DashMap; + use futures::Future; + use js_sys::Uint8Array; + use once_cell::sync::Lazy; + use wasm_bindgen::{prelude::*, JsValue}; + use web_sys::{CustomEvent, CustomEventInit}; + + #[wasm_bindgen(typescript_custom_section)] + const TIMELINE_TYPES: &str = r#" + export interface RequestFileEvent extends CustomEvent { + type: "request-file"; + detail: string; + } + + declare global { + export interface GlobalEventHandlersEventMap { + "request-file": RequestFileEvent; + } + } + "#; + + // DashMap + Lazy allow us to create static, global hashmaps that work well concurrently + static FILE_WAKERS: Lazy> = Lazy::new(DashMap::new); + static FILE_RESPONSES: Lazy, String>>> = Lazy::new(DashMap::new); + + /// Notifies us that the requested file couldn't be received + #[wasm_bindgen(js_name = requestedFileFailed)] + pub fn requested_file_failed(token: String, error: JsValue) { + FILE_RESPONSES.insert(token.clone(), Err(format!("Error: {error:?}"))); + let waker = FILE_WAKERS.remove(&token); + if let Some((_, waker)) = waker { + waker.wake(); + } + } + + /// Provides us with the requested file's contents + #[wasm_bindgen(js_name = requestedFileReceived)] + pub fn requested_file_received(token: String, file: Uint8Array) { + info!("Received File From Mock API {token}"); + FILE_RESPONSES.insert(token.clone(), Ok(file.to_vec())); + let waker = FILE_WAKERS.remove(&token); + if let Some((_, waker)) = waker { + waker.wake(); + } + } + + pub enum LoadLocalFromIdb { + RequestingFile { token: String }, + CheckingForFile { token: String }, + } + + impl LoadLocalFromIdb { + pub fn new(file: impl ToString) -> Self { + Self::RequestingFile { + token: file.to_string(), + } + } + } + + // We are implementing Future ourselves here because all the existing IndexedDb rust libraries + // rely on JsValue, which is not Send - preventing us from using it in a BoxedFuture. + // Instead, we ask the JS front end to grab the data from our mock API, and return it to us. + impl Future for LoadLocalFromIdb { + type Output = Result, String>; + + fn poll( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + match self.as_ref().get_ref() { + LoadLocalFromIdb::RequestingFile { token } => { + info!("Requesting file {token}"); + let Some(window) = web_sys::window() else { + return std::task::Poll::Ready(Err( + "Couldn't find browser window".to_string() + )); + }; + let event = CustomEvent::new_with_event_init_dict( + "request-file", + CustomEventInit::new() + .bubbles(true) + .cancelable(true) + .detail(&JsValue::from_str(token)), + ) + .map_err(|err| format!("{err:?}")); + let event = match event { + Ok(e) => e, + Err(err) => { + return std::task::Poll::Ready(Err(err)); + } + }; + FILE_WAKERS.insert(token.clone(), cx.waker().clone()); + if let Err(err) = window + .document() + .ok_or(JsValue::null()) + .and_then(|document| document.dispatch_event(&event)) + { + return std::task::Poll::Ready(Err(format!( + "Error Dispatching Message: {err:?}" + ))); + } + self.set(LoadLocalFromIdb::CheckingForFile { + token: token.clone(), + }); + std::task::Poll::Pending + } + LoadLocalFromIdb::CheckingForFile { token } => { + info!("Checking on file {token}"); + match FILE_RESPONSES.remove(token) { + Some((_, value)) => { + info!("Found {token}"); + std::task::Poll::Ready(value) + } + None => std::task::Poll::Pending, + } + } + } + } + } +} diff --git a/studio/src/ui/inspector.rs b/studio/src/ui/inspector.rs index 7cac5f5..046ad09 100644 --- a/studio/src/ui/inspector.rs +++ b/studio/src/ui/inspector.rs @@ -588,7 +588,7 @@ fn serialize_standart_materal( ev_inspect.clear(); - if let Some((target, handle)) = r_selection + if let Some((_target, handle)) = r_selection .as_ref() .and_then(|selection| find_handle_in_self_or_children(***selection, &q_children, &q_m)) .and_then(|target| q_m.get(target).ok().map(|h| (target, h.clone())))