Skip to content

Commit

Permalink
Create idb-backed image prop (theatre-js#366)
Browse files Browse the repository at this point in the history
Co-authored-by: Clement Roche <[email protected]>
  • Loading branch information
2 people authored and AriaMinaei committed Dec 31, 2022
1 parent 95b329b commit 8d8e234
Show file tree
Hide file tree
Showing 21 changed files with 838 additions and 31 deletions.
67 changes: 67 additions & 0 deletions packages/playground/src/shared/image/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* A super basic Turtle geometry renderer hooked up to Theatre, so the parameters
* can be tweaked and animated.
*/
import {getProject, types} from '@theatre/core'
import studio from '@theatre/studio'
import React, {useEffect, useState} from 'react'
import {render} from 'react-dom'
import styled from 'styled-components'

studio.initialize()
const project = getProject('Image type playground', {
assets: {
baseUrl: 'http://localhost:3000',
},
})
const sheet = project.sheet('Image type')

const Wrapper = styled.div`
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
`

const ImageTypeExample: React.FC<{}> = (props) => {
const [imageUrl, setImageUrl] = useState<string>()

useEffect(() => {
const object = sheet.object('image', {
image: types.image('', {
label: 'texture',
}),
image2: types.image('', {
label: 'another texture',
}),
something: 'asdf',
})
object.onValuesChange(({image}) => {
setImageUrl(project.getAssetUrl(image))
})

return () => {
sheet.detachObject('canvas')
}
}, [])

return (
<Wrapper
onClick={() => {
if (sheet.sequence.position === 0) {
sheet.sequence.position = 0
sheet.sequence.play()
} else {
sheet.sequence.position = 0
}
}}
>
<img src={imageUrl} />
</Wrapper>
)
}

project.ready.then(() => {
render(<ImageTypeExample />, document.getElementById('root'))
})
191 changes: 191 additions & 0 deletions theatre/core/src/projects/DefaultAssetStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
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
},
}
},
}
}
22 changes: 22 additions & 0 deletions theatre/core/src/projects/IDBStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as idb from 'idb-keyval'

/**
* Custom IDB keyval storage creator. Right now this exists solely as a more convenient way to use idb-keyval with a custom db name.
* It also automatically prefixes the provided name with `theatrejs-` to avoid conflicts with other libraries.
*
* @param name - The name of the database
* @returns An object with the same methods as idb-keyval, but with a custom database name
*/
export const createStore = (name: string) => {
const customStore = idb.createStore(`theatrejs-${name}`, 'default-store')

return {
set: (key: string, value: any) => idb.set(key, value, customStore),
get: <T = any>(key: string) => idb.get<T>(key, customStore),
del: (key: string) => idb.del(key, customStore),
keys: <T extends IDBValidKey>() => idb.keys<T>(customStore),
entries: <KeyType extends IDBValidKey, ValueType = any>() =>
idb.entries<KeyType, ValueType>(customStore),
values: <T = any>() => idb.values<T>(customStore),
}
}
Loading

0 comments on commit 8d8e234

Please sign in to comment.