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:
parent
718beb4d7b
commit
ccabda65a3
4 changed files with 188 additions and 208 deletions
|
@ -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
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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<IStudioAssetStorage>
|
||||
}
|
||||
|
||||
type IAssetConf = {
|
||||
|
@ -77,7 +74,7 @@ export default class Project {
|
|||
readonly address: ProjectAddress
|
||||
|
||||
private readonly _studioReadyDeferred: Deferred<undefined>
|
||||
private readonly _defaultAssetStorageReadyDeferred: Deferred<undefined>
|
||||
private readonly _assetStorageReadyDeferred: Deferred<undefined>
|
||||
private readonly _readyPromise: Promise<void>
|
||||
|
||||
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<void> 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'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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<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
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue