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 <aria.minaei@gmail.com>
This commit is contained in:
Andrew Prifer 2023-01-04 20:34:27 +01:00 committed by GitHub
parent 718beb4d7b
commit ccabda65a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 188 additions and 208 deletions

View file

@ -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<string>()
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<string, Blob>())
// A map for caching the object urls created from idb assets.
const urlCache = new Map<Blob, string>()
/** 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
},
}
},
}
}

View file

@ -24,7 +24,6 @@ import type {
ITheatreLoggingConfig, ITheatreLoggingConfig,
} from '@theatre/shared/logger' } from '@theatre/shared/logger'
import {_coreLogger} from '@theatre/core/_coreLogger' import {_coreLogger} from '@theatre/core/_coreLogger'
import {createDefaultAssetStorageConfig} from './DefaultAssetStorage'
type ICoreAssetStorage = { type ICoreAssetStorage = {
/** Returns a URL for the provided asset ID */ /** Returns a URL for the provided asset ID */
@ -41,8 +40,6 @@ export type IAssetStorageConfig = {
* An object containing the core asset storage methods. * An object containing the core asset storage methods.
*/ */
coreAssetStorage: ICoreAssetStorage coreAssetStorage: ICoreAssetStorage
/** A function that returns a promise to an object containing asset storage methods to be used by studio. */
createStudioAssetStorage: () => Promise<IStudioAssetStorage>
} }
type IAssetConf = { type IAssetConf = {
@ -77,7 +74,7 @@ export default class Project {
readonly address: ProjectAddress readonly address: ProjectAddress
private readonly _studioReadyDeferred: Deferred<undefined> private readonly _studioReadyDeferred: Deferred<undefined>
private readonly _defaultAssetStorageReadyDeferred: Deferred<undefined> private readonly _assetStorageReadyDeferred: Deferred<undefined>
private readonly _readyPromise: Promise<void> private readonly _readyPromise: Promise<void>
private _sheetTemplates = new Atom<{ private _sheetTemplates = new Atom<{
@ -85,7 +82,6 @@ export default class Project {
}>({}) }>({})
sheetTemplatesP = this._sheetTemplates.pointer sheetTemplatesP = this._sheetTemplates.pointer
private _studio: Studio | undefined private _studio: Studio | undefined
private _defaultAssetStorageConfig: IAssetStorageConfig
assetStorage: IStudioAssetStorage assetStorage: IStudioAssetStorage
type: 'Theatre_Project' = 'Theatre_Project' type: 'Theatre_Project' = 'Theatre_Project'
@ -117,13 +113,9 @@ export default class Project {
}, },
}) })
this._defaultAssetStorageConfig = createDefaultAssetStorageConfig({ this._assetStorageReadyDeferred = defer()
project: this,
baseUrl: config.assets?.baseUrl,
})
this._defaultAssetStorageReadyDeferred = defer()
this.assetStorage = { 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 // Until the asset storage is ready, we'll throw an error when the user tries to use it
createAsset: () => { createAsset: () => {
@ -149,7 +141,7 @@ export default class Project {
this._readyPromise = Promise.all([ this._readyPromise = Promise.all([
this._studioReadyDeferred.promise, this._studioReadyDeferred.promise,
this._defaultAssetStorageReadyDeferred.promise, this._assetStorageReadyDeferred.promise,
// hide the array from the user, i.e. make it Promise<void> instead of Promise<[undefined, undefined]> // hide the array from the user, i.e. make it Promise<void> instead of Promise<[undefined, undefined]>
]).then(() => {}) ]).then(() => {})
@ -159,7 +151,7 @@ export default class Project {
// let's give it one tick to attach itself // let's give it one tick to attach itself
if (!this._studio) { if (!this._studio) {
this._studioReadyDeferred.resolve(undefined) this._studioReadyDeferred.resolve(undefined)
this._defaultAssetStorageReadyDeferred.resolve(undefined) this._assetStorageReadyDeferred.resolve(undefined)
this._logger._trace('ready deferred resolved with no state') this._logger._trace('ready deferred resolved with no state')
} }
}, 0) }, 0)
@ -218,11 +210,11 @@ export default class Project {
) )
// asset storage has to be initialized after the pointers are set // asset storage has to be initialized after the pointers are set
this._defaultAssetStorageConfig studio
.createStudioAssetStorage() .createAssetStorage(this, this.config.assets?.baseUrl)
.then((assetStorage) => { .then((assetStorage) => {
this.assetStorage = assetStorage this.assetStorage = assetStorage
this._defaultAssetStorageReadyDeferred.resolve(undefined) this._assetStorageReadyDeferred.resolve(undefined)
}) })
this._studioReadyDeferred.resolve(undefined) this._studioReadyDeferred.resolve(undefined)
@ -240,7 +232,7 @@ export default class Project {
isReady() { isReady() {
return ( return (
this._studioReadyDeferred.status === 'resolved' && this._studioReadyDeferred.status === 'resolved' &&
this._defaultAssetStorageReadyDeferred.status === 'resolved' this._assetStorageReadyDeferred.status === 'resolved'
) )
} }

View file

@ -23,12 +23,19 @@ import {defer} from '@theatre/shared/utils/defer'
import type {ProjectId} from '@theatre/shared/utils/ids' import type {ProjectId} from '@theatre/shared/utils/ids'
import checkForUpdates from './checkForUpdates' import checkForUpdates from './checkForUpdates'
import shallowEqual from 'shallowequal' import shallowEqual from 'shallowequal'
import {createStore} from './IDBStorage'
import {getAllPossibleAssetIDs} from '@theatre/shared/utils/assets'
import {notify} from './notify'
export type CoreExports = typeof _coreExports export type CoreExports = typeof _coreExports
const UIConstructorModule = const UIConstructorModule =
typeof window !== 'undefined' ? require('./UI') : null 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: 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' import studio from '@theatre/studio'
@ -291,4 +298,176 @@ export class Studio {
createContentOfSaveFile(projectId: string): OnDiskState { createContentOfSaveFile(projectId: string): OnDiskState {
return this._store.createContentOfSaveFile(projectId as ProjectId) 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<string>()
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<string, Blob>())
// A map for caching the object urls created from idb assets.
const urlCache = new Map<Blob, string>()
/** 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
},
}
}
} }