From ccabda65a34ec8426eeaa6e979f60fdc91ebd282 Mon Sep 17 00:00:00 2001 From: Andrew Prifer <2991360+AndrewPrifer@users.noreply.github.com> Date: Wed, 4 Jan 2023 20:34:27 +0100 Subject: [PATCH] Move studio-related asset storage code out of core (#369) * Move studio asset storage code out of core * Require blob-compare conditinally Co-authored-by: Aria --- .../core/src/projects/DefaultAssetStorage.ts | 191 ------------------ theatre/core/src/projects/Project.ts | 26 +-- .../src/projects => studio/src}/IDBStorage.ts | 0 theatre/studio/src/Studio.ts | 179 ++++++++++++++++ 4 files changed, 188 insertions(+), 208 deletions(-) delete mode 100644 theatre/core/src/projects/DefaultAssetStorage.ts rename theatre/{core/src/projects => studio/src}/IDBStorage.ts (100%) diff --git a/theatre/core/src/projects/DefaultAssetStorage.ts b/theatre/core/src/projects/DefaultAssetStorage.ts deleted file mode 100644 index a145e17..0000000 --- a/theatre/core/src/projects/DefaultAssetStorage.ts +++ /dev/null @@ -1,191 +0,0 @@ -import {createStore} from './IDBStorage' -import type Project from './Project' -import type {IAssetStorageConfig} from './Project' -// @ts-ignore -import blobCompare from 'blob-compare' -import {notify} from '@theatre/core/coreExports' -import {getAllPossibleAssetIDs} from '@theatre/shared/utils/assets' - -export const createDefaultAssetStorageConfig = ({ - project, - baseUrl = '', -}: { - project: Project - baseUrl?: string -}): IAssetStorageConfig => { - return { - coreAssetStorage: { - getAssetUrl: (assetId: string) => `${baseUrl}/${assetId}`, - }, - createStudioAssetStorage: async () => { - // in SSR we bail out and return a dummy asset manager - if (typeof window === 'undefined') { - return { - getAssetUrl: () => '', - createAsset: () => Promise.resolve(null), - } - } - - // Check for support. - if (!('indexedDB' in window)) { - console.log("This browser doesn't support IndexedDB.") - - return { - getAssetUrl: (assetId: string) => { - throw new Error( - `IndexedDB is required by the default asset manager, but it's not supported by this browser. To use assets, please provide your own asset manager to the project config.`, - ) - }, - createAsset: (asset: Blob) => { - throw new Error( - `IndexedDB is required by the default asset manager, but it's not supported by this browser. To use assets, please provide your own asset manager to the project config.`, - ) - }, - } - } - - const idb = createStore(`${project.address.projectId}-assets`) - - // get all possible asset ids referenced by either static props or keyframes - const possibleAssetIDs = getAllPossibleAssetIDs(project) - - // Clean up assets not referenced by the project. We can only do this at the start because otherwise - // we'd break undo/redo. - const idbKeys = await idb.keys() - await Promise.all( - idbKeys.map(async (key) => { - if (!possibleAssetIDs.includes(key)) { - await idb.del(key) - } - }), - ) - - // Clean up idb entries exported to disk - await Promise.all( - idbKeys.map(async (key) => { - const assetUrl = `${baseUrl}/${key}` - - try { - const response = await fetch(assetUrl, {method: 'HEAD'}) - if (response.ok) { - await idb.del(key) - } - } catch (e) { - notify.error( - 'Failed to access assets', - `Failed to access assets at ${ - project.config.assets?.baseUrl ?? '/' - }. This is likely due to a CORS issue.`, - ) - } - }), - ) - - // A map for caching the assets outside of the db. We also need this to be able to retrieve idb asset urls synchronously. - const assetsMap = new Map(await idb.entries()) - - // A map for caching the object urls created from idb assets. - const urlCache = new Map() - - /** Gets idb aset url from asset blob */ - const getUrlForAsset = (asset: Blob) => { - if (urlCache.has(asset)) { - return urlCache.get(asset)! - } else { - const url = URL.createObjectURL(asset) - urlCache.set(asset, url) - return url - } - } - - /** Gets idb asset url from id */ - const getUrlForId = (assetId: string) => { - const asset = assetsMap.get(assetId) - if (!asset) { - throw new Error(`Asset with id ${assetId} not found`) - } - return getUrlForAsset(asset) - } - - return { - getAssetUrl: (assetId: string) => { - return assetsMap.has(assetId) - ? getUrlForId(assetId) - : `${baseUrl}/${assetId}` - }, - createAsset: async (asset: File) => { - const existingIDs = getAllPossibleAssetIDs(project) - - let sameSame = false - - if (existingIDs.includes(asset.name)) { - let existingAsset: Blob | undefined - try { - existingAsset = - assetsMap.get(asset.name) ?? - (await fetch(`${baseUrl}/${asset.name}`).then((r) => - r.ok ? r.blob() : undefined, - )) - } catch (e) { - notify.error( - 'Failed to access assets', - `Failed to access assets at ${ - project.config.assets?.baseUrl ?? '/' - }. This is likely due to a CORS issue.`, - ) - - return Promise.resolve(null) - } - - if (existingAsset) { - // @ts-ignore - sameSame = await blobCompare.isEqual(asset, existingAsset) - - // if same same, we do nothing - if (sameSame) { - return asset.name - // if different, we ask the user to pls rename - } else { - /** Initiates rename using a dialog. Returns a boolean indicating if the rename was succesful. */ - const renameAsset = (text: string): boolean => { - const newAssetName = prompt(text, asset.name) - - if (newAssetName === null) { - // asset creation canceled - return false - } else if (newAssetName === '') { - return renameAsset( - 'Asset name cannot be empty. Please choose a different file name for this asset.', - ) - } else if (existingIDs.includes(newAssetName)) { - console.log(existingIDs) - return renameAsset( - 'An asset with this name already exists. Please choose a different file name for this asset.', - ) - } - - // rename asset - asset = new File([asset], newAssetName, {type: asset.type}) - return true - } - - // rename asset returns false if the user cancels the rename - const success = renameAsset( - 'An asset with this name already exists. Please choose a different file name for this asset.', - ) - - if (!success) { - return null - } - } - } - } - - assetsMap.set(asset.name, asset) - await idb.set(asset.name, asset) - return asset.name - }, - } - }, - } -} diff --git a/theatre/core/src/projects/Project.ts b/theatre/core/src/projects/Project.ts index a133922..c583e88 100644 --- a/theatre/core/src/projects/Project.ts +++ b/theatre/core/src/projects/Project.ts @@ -24,7 +24,6 @@ import type { ITheatreLoggingConfig, } from '@theatre/shared/logger' import {_coreLogger} from '@theatre/core/_coreLogger' -import {createDefaultAssetStorageConfig} from './DefaultAssetStorage' type ICoreAssetStorage = { /** Returns a URL for the provided asset ID */ @@ -41,8 +40,6 @@ export type IAssetStorageConfig = { * An object containing the core asset storage methods. */ coreAssetStorage: ICoreAssetStorage - /** A function that returns a promise to an object containing asset storage methods to be used by studio. */ - createStudioAssetStorage: () => Promise } type IAssetConf = { @@ -77,7 +74,7 @@ export default class Project { readonly address: ProjectAddress private readonly _studioReadyDeferred: Deferred - private readonly _defaultAssetStorageReadyDeferred: Deferred + private readonly _assetStorageReadyDeferred: Deferred private readonly _readyPromise: Promise private _sheetTemplates = new Atom<{ @@ -85,7 +82,6 @@ export default class Project { }>({}) sheetTemplatesP = this._sheetTemplates.pointer private _studio: Studio | undefined - private _defaultAssetStorageConfig: IAssetStorageConfig assetStorage: IStudioAssetStorage type: 'Theatre_Project' = 'Theatre_Project' @@ -117,13 +113,9 @@ export default class Project { }, }) - this._defaultAssetStorageConfig = createDefaultAssetStorageConfig({ - project: this, - baseUrl: config.assets?.baseUrl, - }) - this._defaultAssetStorageReadyDeferred = defer() + this._assetStorageReadyDeferred = defer() this.assetStorage = { - ...this._defaultAssetStorageConfig.coreAssetStorage, + getAssetUrl: (assetId: string) => `${config.assets?.baseUrl}/${assetId}`, // Until the asset storage is ready, we'll throw an error when the user tries to use it createAsset: () => { @@ -149,7 +141,7 @@ export default class Project { this._readyPromise = Promise.all([ this._studioReadyDeferred.promise, - this._defaultAssetStorageReadyDeferred.promise, + this._assetStorageReadyDeferred.promise, // hide the array from the user, i.e. make it Promise instead of Promise<[undefined, undefined]> ]).then(() => {}) @@ -159,7 +151,7 @@ export default class Project { // let's give it one tick to attach itself if (!this._studio) { this._studioReadyDeferred.resolve(undefined) - this._defaultAssetStorageReadyDeferred.resolve(undefined) + this._assetStorageReadyDeferred.resolve(undefined) this._logger._trace('ready deferred resolved with no state') } }, 0) @@ -218,11 +210,11 @@ export default class Project { ) // asset storage has to be initialized after the pointers are set - this._defaultAssetStorageConfig - .createStudioAssetStorage() + studio + .createAssetStorage(this, this.config.assets?.baseUrl) .then((assetStorage) => { this.assetStorage = assetStorage - this._defaultAssetStorageReadyDeferred.resolve(undefined) + this._assetStorageReadyDeferred.resolve(undefined) }) this._studioReadyDeferred.resolve(undefined) @@ -240,7 +232,7 @@ export default class Project { isReady() { return ( this._studioReadyDeferred.status === 'resolved' && - this._defaultAssetStorageReadyDeferred.status === 'resolved' + this._assetStorageReadyDeferred.status === 'resolved' ) } diff --git a/theatre/core/src/projects/IDBStorage.ts b/theatre/studio/src/IDBStorage.ts similarity index 100% rename from theatre/core/src/projects/IDBStorage.ts rename to theatre/studio/src/IDBStorage.ts diff --git a/theatre/studio/src/Studio.ts b/theatre/studio/src/Studio.ts index f183441..05ff8c0 100644 --- a/theatre/studio/src/Studio.ts +++ b/theatre/studio/src/Studio.ts @@ -23,12 +23,19 @@ import {defer} from '@theatre/shared/utils/defer' import type {ProjectId} from '@theatre/shared/utils/ids' import checkForUpdates from './checkForUpdates' import shallowEqual from 'shallowequal' +import {createStore} from './IDBStorage' +import {getAllPossibleAssetIDs} from '@theatre/shared/utils/assets' +import {notify} from './notify' export type CoreExports = typeof _coreExports const UIConstructorModule = typeof window !== 'undefined' ? require('./UI') : null +// this package has a reference to `window` that breaks SSR, so we require it conditionally +const blobCompare = + typeof window !== 'undefined' ? require('blob-compare') : null + const STUDIO_NOT_INITIALIZED_MESSAGE = `You seem to have imported '@theatre/studio' but haven't initialized it. You can initialize the studio by: \`\`\` import studio from '@theatre/studio' @@ -291,4 +298,176 @@ export class Studio { createContentOfSaveFile(projectId: string): OnDiskState { return this._store.createContentOfSaveFile(projectId as ProjectId) } + + /** A function that returns a promise to an object containing asset storage methods for a project to be used by studio. */ + async createAssetStorage(project: Project, baseUrl?: string) { + // in SSR we bail out and return a dummy asset manager + if (typeof window === 'undefined') { + return { + getAssetUrl: () => '', + createAsset: () => Promise.resolve(null), + } + } + + // Check for support. + if (!('indexedDB' in window)) { + console.log("This browser doesn't support IndexedDB.") + + return { + getAssetUrl: (assetId: string) => { + throw new Error( + `IndexedDB is required by the default asset manager, but it's not supported by this browser. To use assets, please provide your own asset manager to the project config.`, + ) + }, + createAsset: (asset: Blob) => { + throw new Error( + `IndexedDB is required by the default asset manager, but it's not supported by this browser. To use assets, please provide your own asset manager to the project config.`, + ) + }, + } + } + + const idb = createStore(`${project.address.projectId}-assets`) + + // get all possible asset ids referenced by either static props or keyframes + const possibleAssetIDs = getAllPossibleAssetIDs(project) + + // Clean up assets not referenced by the project. We can only do this at the start because otherwise + // we'd break undo/redo. + const idbKeys = await idb.keys() + await Promise.all( + idbKeys.map(async (key) => { + if (!possibleAssetIDs.includes(key)) { + await idb.del(key) + } + }), + ) + + // Clean up idb entries exported to disk + await Promise.all( + idbKeys.map(async (key) => { + const assetUrl = `${baseUrl}/${key}` + + try { + const response = await fetch(assetUrl, {method: 'HEAD'}) + if (response.ok) { + await idb.del(key) + } + } catch (e) { + notify.error( + 'Failed to access assets', + `Failed to access assets at ${ + project.config.assets?.baseUrl ?? '/' + }. This is likely due to a CORS issue.`, + ) + } + }), + ) + + // A map for caching the assets outside of the db. We also need this to be able to retrieve idb asset urls synchronously. + const assetsMap = new Map(await idb.entries()) + + // A map for caching the object urls created from idb assets. + const urlCache = new Map() + + /** Gets idb aset url from asset blob */ + const getUrlForAsset = (asset: Blob) => { + if (urlCache.has(asset)) { + return urlCache.get(asset)! + } else { + const url = URL.createObjectURL(asset) + urlCache.set(asset, url) + return url + } + } + + /** Gets idb asset url from id */ + const getUrlForId = (assetId: string) => { + const asset = assetsMap.get(assetId) + if (!asset) { + throw new Error(`Asset with id ${assetId} not found`) + } + return getUrlForAsset(asset) + } + + return { + getAssetUrl: (assetId: string) => { + return assetsMap.has(assetId) + ? getUrlForId(assetId) + : `${baseUrl}/${assetId}` + }, + createAsset: async (asset: File) => { + const existingIDs = getAllPossibleAssetIDs(project) + + let sameSame = false + + if (existingIDs.includes(asset.name)) { + let existingAsset: Blob | undefined + try { + existingAsset = + assetsMap.get(asset.name) ?? + (await fetch(`${baseUrl}/${asset.name}`).then((r) => + r.ok ? r.blob() : undefined, + )) + } catch (e) { + notify.error( + 'Failed to access assets', + `Failed to access assets at ${ + project.config.assets?.baseUrl ?? '/' + }. This is likely due to a CORS issue.`, + ) + + return Promise.resolve(null) + } + + if (existingAsset) { + // @ts-ignore + sameSame = await blobCompare!.isEqual(asset, existingAsset) + + // if same same, we do nothing + if (sameSame) { + return asset.name + // if different, we ask the user to pls rename + } else { + /** Initiates rename using a dialog. Returns a boolean indicating if the rename was succesful. */ + const renameAsset = (text: string): boolean => { + const newAssetName = prompt(text, asset.name) + + if (newAssetName === null) { + // asset creation canceled + return false + } else if (newAssetName === '') { + return renameAsset( + 'Asset name cannot be empty. Please choose a different file name for this asset.', + ) + } else if (existingIDs.includes(newAssetName)) { + console.log(existingIDs) + return renameAsset( + 'An asset with this name already exists. Please choose a different file name for this asset.', + ) + } + + // rename asset + asset = new File([asset], newAssetName, {type: asset.type}) + return true + } + + // rename asset returns false if the user cancels the rename + const success = renameAsset( + 'An asset with this name already exists. Please choose a different file name for this asset.', + ) + + if (!success) { + return null + } + } + } + } + + assetsMap.set(asset.name, asset) + await idb.set(asset.name, asset) + return asset.name + }, + } + } }