From 933bc152ea37ca2a79b14fec8c0f19bcc8624b24 Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Wed, 15 Jan 2025 12:55:29 -0300 Subject: [PATCH 01/16] refactor file dropping for ImportAsset component --- .../components/ImportAsset/ImportAsset.tsx | 262 ++++++------------ .../src/components/ImportAsset/types.ts | 28 ++ .../src/components/ImportAsset/utils.ts | 137 +++++++++ 3 files changed, 251 insertions(+), 176 deletions(-) create mode 100644 packages/@dcl/inspector/src/components/ImportAsset/types.ts diff --git a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx index 2adc8b296..b68148e4a 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx @@ -1,27 +1,28 @@ -import { GLTFValidation } from '@babylonjs/loaders' -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { HiOutlineUpload } from 'react-icons/hi' import { RxCross2 } from 'react-icons/rx' import classNames from 'classnames' -import FileInput from '../FileInput' -import { Container } from '../Container' -import { TextField } from '../ui/TextField' -import { Block } from '../Block' -import { Button } from '../Button' import { removeBasePath } from '../../lib/logic/remove-base-path' import { DIRECTORY, transformBase64ResourceToBinary, withAssetDir } from '../../lib/data-layer/host/fs-utils' import { importAsset, saveThumbnail } from '../../redux/data-layer' import { useAppDispatch, useAppSelector } from '../../redux/hooks' import { selectAssetCatalog, selectUploadFile, updateUploadFile } from '../../redux/app' + +import FileInput from '../FileInput' +import { Container } from '../Container' +import { TextField } from '../ui/TextField' +import { Block } from '../Block' +import { Button } from '../Button' import { AssetPreview } from '../AssetPreview' +import { processAssets } from './utils' +import { Asset } from './types' + import './ImportAsset.css' -const ONE_MB_IN_BYTES = 1_048_576 -const ONE_GB_IN_BYTES = ONE_MB_IN_BYTES * 1024 const ACCEPTED_FILE_TYPES = { - 'model/gltf-binary': ['.gltf', '.glb'], + 'model/gltf-binary': ['.gltf', '.glb', '.bin'], 'image/png': ['.png'], 'audio/mpeg': ['.mp3'], 'audio/wav': ['.wav'], @@ -29,201 +30,110 @@ const ACCEPTED_FILE_TYPES = { 'video/mp4': ['.mp4'] } -const IGNORED_ERROR_CODES = ['ACCESSOR_WEIGHTS_NON_NORMALIZED', 'MESH_PRIMITIVE_TOO_FEW_TEXCOORDS'] - interface PropTypes { onSave(): void } -type ValidationError = string | null -/* - Severity codes are Error (0), Warning (1), Information (2), Hint (3). - https://github.com/KhronosGroup/glTF-Validator/blob/main/lib/src/errors.dart -*/ -type BabylonValidationIssue = { - severity: number - code: string - message: string - pointer: string -} - -async function validateGltf(data: ArrayBuffer): Promise { - const pre = 'Invalid GLTF' - let result - try { - result = await GLTFValidation.ValidateAsync(new Uint8Array(data), '', '', (_uri) => { - throw new Error('external references are not supported yet') - }) - } catch (error) { - return `${pre}: ${error}` - } - - /* - Babylon's type declarations incorrectly state that result.issues.messages - is an Array. In fact, it's an array of objects with useful properties. - */ - const issues = result.issues.messages as unknown as BabylonValidationIssue[] - - const errors = issues.filter((issue) => issue.severity === 0 && !IGNORED_ERROR_CODES.includes(issue.code)) - - if (errors.length > 0) { - const error = errors[0] - return `${pre}: ${error.message} \n Check ${error.pointer}` - } - - return null -} - -async function validateAsset(extension: string, data: ArrayBuffer): Promise { - switch (extension) { - case 'glb': - case 'gltf': - return validateGltf(data) - // add validators for .png/.ktx2? - case 'png': - case 'ktx2': - case 'mp3': - case 'wav': - case 'ogg': - case 'mp4': - return null - default: - return `Invalid asset format ".${extension}"` - } -} - const ImportAsset: React.FC = ({ onSave }) => { - // TODO: multiple files const dispatch = useAppDispatch() - const files = useAppSelector(selectAssetCatalog) + const catalog = useAppSelector(selectAssetCatalog) const uploadFile = useAppSelector(selectUploadFile) - const [file, setFile] = useState() - const [thumbnail, setThumbnail] = useState(null) - const [validationError, setValidationError] = useState(null) - const [assetName, setAssetName] = useState('') - const [assetExtension, setAssetExtension] = useState('') - const { basePath, assets } = files ?? { basePath: '', assets: [] } + const [files, setFiles] = useState([]) + const { basePath, assets } = catalog ?? { basePath: '', assets: [] } useEffect(() => { - if (uploadFile && typeof uploadFile !== 'string' && (!file || (file && uploadFile.name !== file.name))) { - handleDrop([Object.values(uploadFile!)[0] as File]) - } - }, [uploadFile]) - - const handleDrop = (acceptedFiles: File[]) => { - // TODO: handle zip file. GLB with multiple external image references - const file = acceptedFiles[0] - if (!file) return - setFile(file) - setValidationError(null) - setThumbnail(null) - const normalizedName = file.name.trim().replaceAll(' ', '_').toLowerCase() - const splitName = normalizedName.split('.') - const extensionName = splitName.pop() - setAssetName(splitName.join('')) - setAssetExtension(extensionName ? extensionName : '') + const isValidFile = uploadFile && 'name' in uploadFile + if (isValidFile && !files.find(($) => $.name === uploadFile.name)) { + handleDrop([Object.values(uploadFile!)[0] as File]) + } + }, [uploadFile]) + + const handleDrop = async (acceptedFiles: File[]) => { + const assets = await processAssets(acceptedFiles) + console.log('assets: ', assets) + setFiles(assets) } const handleSave = () => { - const reader = new FileReader() - if (!file) return - reader.onload = async () => { - const binary: ArrayBuffer = reader.result as ArrayBuffer - - if (binary.byteLength > ONE_GB_IN_BYTES) { - setValidationError('Files bigger than 1GB are not accepted') - return - } + // const basePath = withAssetDir(DIRECTORY.SCENE) + // const content: Map = new Map() + // const fullName = assetName + '.' + assetExtension + // content.set(fullName, new Uint8Array(binary)) + + // dispatch( + // importAsset({ + // content, + // basePath, + // assetPackageName: '', + // reload: true + // }) + // ) + + // if (thumbnail) { + // dispatch( + // saveThumbnail({ + // content: transformBase64ResourceToBinary(thumbnail), + // path: `${DIRECTORY.THUMBNAILS}/${assetName}.png` + // }) + // ) + // } + + // // Clear uploaded file from the FileUploadField + // const newUploadFile = { ...uploadFile } + // for (const key in newUploadFile) { + // newUploadFile[key] = `${basePath}/${fullName}` + // } + // dispatch(updateUploadFile(newUploadFile)) + // setFile(undefined) + + // onSave() + // } + } - const validationError = await validateAsset(assetExtension, binary) - if (validationError !== null) { - setValidationError(validationError) - return - } + function removeAsset(asset: Asset) { + // e.stopPropagation() + setFiles(files.filter((file) => file.name !== asset.name)) + } - const basePath = withAssetDir(DIRECTORY.SCENE) - const content: Map = new Map() - const fullName = assetName + '.' + assetExtension - content.set(fullName, new Uint8Array(binary)) - - dispatch( - importAsset({ - content, - basePath, - assetPackageName: '', - reload: true - }) - ) - - if (thumbnail) { - dispatch( - saveThumbnail({ - content: transformBase64ResourceToBinary(thumbnail), - path: `${DIRECTORY.THUMBNAILS}/${assetName}.png` - }) - ) - } + // const handleNameChange = useCallback((event: React.ChangeEvent) => { + // setAssetName(event.target.value) + // }, []) - // Clear uploaded file from the FileUploadField - const newUploadFile = { ...uploadFile } - for (const key in newUploadFile) { - newUploadFile[key] = `${basePath}/${fullName}` - } - dispatch(updateUploadFile(newUploadFile)) - setFile(undefined) + // const isNameUnique = useCallback((name: string, ext: string) => { + // return !assets.find((asset) => { + // const [packageName, otherAssetName] = removeBasePath(basePath, asset.path).split('/') + // if (packageName === 'builder') return false + // return otherAssetName?.toLocaleLowerCase() === name?.toLocaleLowerCase() + '.' + ext + // }) + // }, []) - onSave() - } - reader.readAsArrayBuffer(file) - } + // const isNameRepeated = !isNameUnique(assetName, assetExtension) - function removeFile(e: React.MouseEvent) { - e.stopPropagation() - setFile(undefined) - setValidationError(null) - setThumbnail(null) - } + // const handleScreenshot = useCallback( + // (value: string) => { + // setThumbnail(value) + // }, + // [files] + // ) - const handleNameChange = useCallback((event: React.ChangeEvent) => { - setAssetName(event.target.value) - }, []) - - const isNameUnique = useCallback((name: string, ext: string) => { - return !assets.find((asset) => { - const [packageName, otherAssetName] = removeBasePath(basePath, asset.path).split('/') - if (packageName === 'builder') return false - return otherAssetName?.toLocaleLowerCase() === name?.toLocaleLowerCase() + '.' + ext - }) - }, []) - - const isNameRepeated = !isNameUnique(assetName, assetExtension) - - const handleScreenshot = useCallback( - (value: string) => { - setThumbnail(value) - }, - [file] - ) + const types = useMemo(() => Object.values(ACCEPTED_FILE_TYPES).flat().join('/').replaceAll('.', '').toUpperCase(), []) return (
- - {!file && ( + + {!files.length && ( <>
- - To import an asset drag and drop a single GLB/GLTF/PNG/MP3/MP4 file -
or click to select a file. -
+ Drop {types} files )} - {file && ( + {/* {file && (
-
+
@@ -242,7 +152,7 @@ const ImportAsset: React.FC = ({ onSave }) => { {validationError} {isNameRepeated && ( There's a file with this name already, you will overwrite it if you continue - )} + )} */}
) diff --git a/packages/@dcl/inspector/src/components/ImportAsset/types.ts b/packages/@dcl/inspector/src/components/ImportAsset/types.ts new file mode 100644 index 000000000..c39508fe2 --- /dev/null +++ b/packages/@dcl/inspector/src/components/ImportAsset/types.ts @@ -0,0 +1,28 @@ +export type FileAsset = { + blob: File; + name: string; + extension: string; + error?: string; + thumbnail?: string; +}; + +export type GltfAsset = FileAsset & { + buffers: FileAsset[]; + images: FileAsset[]; +}; + +export type Asset = GltfAsset | FileAsset; +export type Uri = { uri: string }; +export type GltfFile = { buffers: Uri[]; images: Uri[] }; + +export type ValidationError = string | undefined +/* + Severity codes are Error (0), Warning (1), Information (2), Hint (3). + https://github.com/KhronosGroup/glTF-Validator/blob/main/lib/src/errors.dart +*/ +export type BabylonValidationIssue = { + severity: number + code: string + message: string + pointer: string +} diff --git a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts index e25df8b46..d7414852f 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts @@ -1,3 +1,7 @@ +import { GLTFValidation } from '@babylonjs/loaders' + +import { FileAsset, GltfAsset, BabylonValidationIssue, ValidationError, Asset, Uri, GltfFile } from './types' + const sampleIndex = (list: any[]) => Math.floor(Math.random() * list.length) export function getRandomMnemonic() { @@ -88,3 +92,136 @@ export const adjectives = [ 'cheap', 'absorbed' ] + +const ONE_GB_IN_BYTES = 1024 * 1024 * 1024 +const VALID_EXTENSIONS = new Set([ + 'glb', 'gltf', 'bin', 'png', 'ktx2', 'mp3', 'wav', 'ogg', 'mp4' +]) +const IGNORED_ERROR_CODES = ['ACCESSOR_WEIGHTS_NON_NORMALIZED', 'MESH_PRIMITIVE_TOO_FEW_TEXCOORDS'] + +async function validateGltf(gltf: GltfAsset): Promise { + const pre = `Invalid GLTF "${gltf.blob.name}":` + const gltfContent = JSON.parse(await gltf.blob.text()) as GltfFile + const gltfBuffer = await gltf.blob.arrayBuffer() + const resourceMap = new Map([...gltf.buffers, ...gltf.images].map(($) => [$.blob.name, $.blob])) + + // ugly hack since "GLTFValidation.ValidateAsync" runs on a new Worker and throwed errors from + // "getExternalResource" callback are thrown to main thread + // In conclusion, checking for missing files inside "getExternalResource" is not possible... + for (const { uri } of [...gltfContent.buffers, ...gltfContent.images]) { + if (!resourceMap.has(uri)) { + throw new Error(`${pre}: resource "${uri}" is missing`) + } + } + + let result + try { + result = await GLTFValidation.ValidateAsync(new Uint8Array(gltfBuffer), '', '', (uri) => { + const resource = resourceMap.get(uri) + return resource!.arrayBuffer() + }) + } catch (error) { + throw new Error(`${pre}: ${error}`) + } + + /* + Babylon's type declarations incorrectly state that result.issues.messages + is an Array. In fact, it's an array of objects with useful properties. + */ + const issues = result.issues.messages as unknown as BabylonValidationIssue[] + + const errors = issues.filter((issue) => issue.severity === 0 && !IGNORED_ERROR_CODES.includes(issue.code)) + + if (errors.length > 0) { + const error = errors[0] + throw new Error(`${pre}: ${error.message} \n Check ${error.pointer}`) + } +} + +// Utility functions +function normalizeFileName(fileName: string): string { + return fileName.trim().replace(/\s+/g, "_").toLowerCase() +} + +function extractFileInfo(fileName: string): [string, string] { + const match = fileName.match(/^(.*?)(?:\.([^.]+))?$/) + return match ? [match[1], match[2]?.toLowerCase() || ""] : [fileName, ""] +} + +function formatFileName(file: FileAsset): string { + return `${file.name}.${file.extension}` +} + +function validateFileSize(size: number): ValidationError { + return size > ONE_GB_IN_BYTES ? 'Files bigger than 1GB are not accepted' : undefined +} + +function validateExtension(extension: string): ValidationError { + return VALID_EXTENSIONS.has(extension) ? + undefined : + `Invalid asset format ".${extension}"` +} + +async function processFile(file: File): Promise { + const normalizedName = normalizeFileName(file.name) + const [name, extension] = extractFileInfo(normalizedName) + + const extensionError = validateExtension(extension) + if (extensionError) { + return { blob: file, name, extension, error: extensionError } + } + + const sizeError = validateFileSize(file.size) + if (sizeError) { + return { blob: file, name, extension, error: sizeError } + } + + return { blob: file, name, extension } +} + +async function validateGltfWithDependencies(gltf: GltfAsset): Promise { + try { + await validateGltf(gltf) + } catch (error) { + return error instanceof Error ? error.message : 'Unknown error during GLTF validation' + } +} + +function resolveDependencies(uris: Uri[], fileMap: Map): FileAsset[] { + return uris.reduce((acc, { uri }) => { + const normalizedUri = normalizeFileName(uri) + const file = fileMap.get(normalizedUri) + if (file) { + acc.push(file) + fileMap.delete(normalizedUri) + } + return acc + }, []) +} + +async function processGltfAssets(files: FileAsset[]): Promise { + const fileMap = new Map(files.map(file => [formatFileName(file), file])) + + const gltfPromises = files + .filter(file => file.extension === 'gltf') + .map(async (gltfFile): Promise => { + const gltfContent = JSON.parse(await gltfFile.blob.text()) as GltfFile + const buffers = resolveDependencies(gltfContent.buffers, fileMap) + const images = resolveDependencies(gltfContent.images, fileMap) + const gltf: GltfAsset = { ...gltfFile, buffers, images } + const error = await validateGltfWithDependencies(gltf) + + fileMap.delete(formatFileName(gltfFile)) + return { ...gltf, error } + }) + + const gltfAssets = await Promise.all(gltfPromises) + const remainingAssets = Array.from(fileMap.values()) + + return [...gltfAssets, ...remainingAssets] +} + +export async function processAssets(files: File[]): Promise { + const processedFiles = await Promise.all(files.map(processFile)) + return processGltfAssets(processedFiles) +} From 29bd67a856b2628a29bfe822847c1725715f2bc9 Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Thu, 16 Jan 2025 11:15:25 -0300 Subject: [PATCH 02/16] refactor file dropping for ImportAsset component --- .../src/components/Assets/Assets.css | 2 +- .../src/components/Assets/Assets.tsx | 30 +++-- .../src/components/FileInput/FileInput.tsx | 34 ++++-- .../src/components/FileInput/types.ts | 1 + .../components/ImportAsset/ImportAsset.css | 45 ++++--- .../components/ImportAsset/ImportAsset.tsx | 113 ++++-------------- 6 files changed, 96 insertions(+), 129 deletions(-) diff --git a/packages/@dcl/inspector/src/components/Assets/Assets.css b/packages/@dcl/inspector/src/components/Assets/Assets.css index 75db5e1de..004d5662d 100644 --- a/packages/@dcl/inspector/src/components/Assets/Assets.css +++ b/packages/@dcl/inspector/src/components/Assets/Assets.css @@ -74,7 +74,7 @@ .Assets .Assets-content { background: var(--tree-bg-color) !important; - height: calc(100% - 36px); + height: 100%; } .Assets .Assets-content.Hide { diff --git a/packages/@dcl/inspector/src/components/Assets/Assets.tsx b/packages/@dcl/inspector/src/components/Assets/Assets.tsx index 4921ff8aa..7c36e8cca 100644 --- a/packages/@dcl/inspector/src/components/Assets/Assets.tsx +++ b/packages/@dcl/inspector/src/components/Assets/Assets.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useRef } from 'react' import cx from 'classnames' import { MdImageSearch } from 'react-icons/md' import { HiOutlinePlus } from 'react-icons/hi' @@ -17,6 +17,7 @@ import { CustomAssets } from '../CustomAssets' import { selectCustomAssets } from '../../redux/app' import { RenameAsset } from '../RenameAsset' import { CreateCustomAsset } from '../CreateCustomAsset' +import { InputRef } from '../FileInput/FileInput' import './Assets.css' @@ -31,6 +32,7 @@ function Assets({ isAssetsPanelCollapsed }: { isAssetsPanelCollapsed: boolean }) const dispatch = useAppDispatch() const tab = useAppSelector(getSelectedAssetsTab) const customAssets = useAppSelector(selectCustomAssets) + const inputRef = useRef(null); const handleTabClick = useCallback( (tab: AssetsTab) => () => { @@ -47,9 +49,14 @@ function Assets({ isAssetsPanelCollapsed }: { isAssetsPanelCollapsed: boolean }) const assetToRename = useAppSelector(selectAssetToRename) const stagedCustomAsset = useAppSelector(selectStagedCustomAsset) + const handleImportClick = useCallback(() => { + inputRef.current?.onClick() + }, [inputRef]) + return (
+
@@ -76,16 +83,17 @@ function Assets({ isAssetsPanelCollapsed }: { isAssetsPanelCollapsed: boolean })
-
- {tab === AssetsTab.AssetsPack && } - {tab === AssetsTab.FileSystem && } - {tab === AssetsTab.Import && } - {tab === AssetsTab.CustomAssets && } - {tab === AssetsTab.RenameAsset && assetToRename && ( - - )} - {tab === AssetsTab.CreateCustomAsset && stagedCustomAsset && } -
+ +
+ {tab === AssetsTab.AssetsPack && } + {tab === AssetsTab.FileSystem && } + {tab === AssetsTab.CustomAssets && } + {tab === AssetsTab.RenameAsset && assetToRename && ( + + )} + {tab === AssetsTab.CreateCustomAsset && stagedCustomAsset && } +
+
) } diff --git a/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx b/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx index dd783b67d..0fc2ed9ec 100644 --- a/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx +++ b/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx @@ -1,4 +1,4 @@ -import React, { useRef, PropsWithChildren, useCallback } from 'react' +import React, { useImperativeHandle, useCallback, useEffect, useRef } from 'react' import { useDrop } from 'react-dnd' import { NativeTypes } from 'react-dnd-html5-backend' import { PropTypes } from './types' @@ -11,12 +11,16 @@ function parseAccept(accept: PropTypes['accept']) { return value } -export function FileInput(props: PropsWithChildren) { +export interface InputRef { + onClick: () => void; +} + +export const FileInput = React.forwardRef>((props, parentRef) => { const { onDrop } = props - const inputRef = useRef(null) const acceptExtensions = Object.values(props.accept ?? []).flat() + const inputRef = useRef(null) - const [_, drop] = useDrop( + const [{ isHover }, drop] = useDrop( () => ({ accept: [NativeTypes.FILE], drop(item: { files: File[] }) { @@ -24,11 +28,18 @@ export function FileInput(props: PropsWithChildren) { }, canDrop(item: { files: File[] }) { return item.files.every((file) => !!acceptExtensions.find((ext) => file.name.endsWith(ext))) - } + }, + collect: (monitor) => ({ + isHover: monitor.canDrop() && monitor.isOver() + }) }), [props] ) + const handleClick = useCallback(() => { + inputRef?.current?.click() + }, [inputRef]) + const handleFileSelected = useCallback( (e: React.ChangeEvent): void => { const files = Array.from(e.target.files ?? []) @@ -37,8 +48,17 @@ export function FileInput(props: PropsWithChildren) { [onDrop] ) + useEffect(() => { + props.onHover?.(isHover) + return () => props.onHover?.(false) + }, [isHover]) + + useImperativeHandle(parentRef, () => ({ + onClick: handleClick, + }), [handleClick]); + return ( -
inputRef?.current?.click()}> +
) { {props.children}
) -} +}) export default FileInput diff --git a/packages/@dcl/inspector/src/components/FileInput/types.ts b/packages/@dcl/inspector/src/components/FileInput/types.ts index c42e1fa6d..22b346edc 100644 --- a/packages/@dcl/inspector/src/components/FileInput/types.ts +++ b/packages/@dcl/inspector/src/components/FileInput/types.ts @@ -1,5 +1,6 @@ export interface PropTypes { onDrop(files: File[]): void + onHover?(isHover: boolean): void accept?: Record disabled?: boolean } diff --git a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.css b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.css index f011715b9..9c0b52d49 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.css +++ b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.css @@ -1,9 +1,16 @@ .ImportAsset { height: 100%; - padding: 8px; } .ImportAsset > div:first-child { + height: 100%; +} + +.ImportAssetHover { + padding: 8px; +} + +.ImportAssetHover > div:first-child { display: flex; flex-direction: column; align-items: center; @@ -19,11 +26,11 @@ overflow-y: auto; } -.ImportAsset > div > span:last-of-type { +.ImportAssetHover > div > span:last-of-type { text-align: center; } -.ImportAsset .upload-icon { +.ImportAssetHover .upload-icon { background-color: var(--list-item-hover-bg-color); width: 40px; height: 40px; @@ -35,19 +42,19 @@ flex-shrink: 0; } -.ImportAsset .Container { +.ImportAssetHover .Container { overflow: visible; height: unset; background-color: var(--list-item-hover-bg-color); margin-right: 20px; } -.ImportAsset .Container svg { +.ImportAssetHover .Container svg { width: 40px; height: 40px; } -.ImportAsset .Container .content { +.ImportAssetHover .Container .content { height: 100%; width: 100%; display: flex; @@ -57,7 +64,7 @@ position: relative; } -.ImportAsset .Container .remove-icon { +.ImportAssetHover .Container .remove-icon { position: absolute; display: flex; align-items: center; @@ -68,12 +75,12 @@ border-radius: 50%; } -.ImportAsset .Container .remove-icon svg { +.ImportAssetHover .Container .remove-icon svg { width: 14px; height: 14px; } -.ImportAsset .Container .file-title { +.ImportAssetHover .Container .file-title { margin-top: 8px; text-overflow: ellipsis; overflow: hidden; @@ -82,49 +89,49 @@ text-align: center; } -.ImportAsset .file-container { +.ImportAssetHover .file-container { display: flex; flex-direction: row; margin-top: 16px; } -.ImportAsset .file-container .Container { +.ImportAssetHover .file-container .Container { width: 120px; } -.ImportAsset .file-container .Text.Field { +.ImportAssetHover .file-container .Text.Field { width: auto; height: 24px; } -.ImportAsset .file-container .error .Text.Field { +.ImportAssetHover .file-container .error .Text.Field { border-color: red; } -.ImportAsset .file-container > div:nth-child(2) { +.ImportAssetHover .file-container > div:nth-child(2) { display: flex; flex-direction: column; justify-content: center; } -.ImportAsset .file-container > div:nth-child(2) button { +.ImportAssetHover .file-container > div:nth-child(2) button { background-color: var(--primary-main); } -.ImportAsset .error { +.ImportAssetHover .error { color: var(--primary-main); margin-top: 6px; } -.ImportAsset .file-container .AssetPreview { +.ImportAssetHover .file-container .AssetPreview { width: 100px; height: 100px; } -.ImportAsset .warning { +.ImportAssetHover .warning { color: var(--danger); margin-top: 6px; } -.ImportAsset .text { +.ImportAssetHover .text { text-align: center; } diff --git a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx index b68148e4a..a78ed5cce 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react' +import cx from 'classnames' import { HiOutlineUpload } from 'react-icons/hi' import { RxCross2 } from 'react-icons/rx' import classNames from 'classnames' @@ -20,6 +21,7 @@ import { processAssets } from './utils' import { Asset } from './types' import './ImportAsset.css' +import { InputRef } from '../FileInput/FileInput' const ACCEPTED_FILE_TYPES = { 'model/gltf-binary': ['.gltf', '.glb', '.bin'], @@ -30,16 +32,23 @@ const ACCEPTED_FILE_TYPES = { 'video/mp4': ['.mp4'] } +const ACCEPTED_FILE_TYPES_STR = Object + .values(ACCEPTED_FILE_TYPES) + .flat().join('/') + .replaceAll('.', '') + .toUpperCase() + interface PropTypes { onSave(): void } -const ImportAsset: React.FC = ({ onSave }) => { +const ImportAsset = React.forwardRef>(({ onSave, children }, inputRef) => { const dispatch = useAppDispatch() const catalog = useAppSelector(selectAssetCatalog) const uploadFile = useAppSelector(selectUploadFile) const [files, setFiles] = useState([]) + const [isHover, setIsHover] = useState(false) const { basePath, assets } = catalog ?? { basePath: '', assets: [] } useEffect(() => { @@ -49,113 +58,35 @@ const ImportAsset: React.FC = ({ onSave }) => { } }, [uploadFile]) - const handleDrop = async (acceptedFiles: File[]) => { + const handleDrop = useCallback(async (acceptedFiles: File[]) => { const assets = await processAssets(acceptedFiles) console.log('assets: ', assets) setFiles(assets) - } - - const handleSave = () => { - // const basePath = withAssetDir(DIRECTORY.SCENE) - // const content: Map = new Map() - // const fullName = assetName + '.' + assetExtension - // content.set(fullName, new Uint8Array(binary)) - - // dispatch( - // importAsset({ - // content, - // basePath, - // assetPackageName: '', - // reload: true - // }) - // ) + }, []) - // if (thumbnail) { - // dispatch( - // saveThumbnail({ - // content: transformBase64ResourceToBinary(thumbnail), - // path: `${DIRECTORY.THUMBNAILS}/${assetName}.png` - // }) - // ) - // } - - // // Clear uploaded file from the FileUploadField - // const newUploadFile = { ...uploadFile } - // for (const key in newUploadFile) { - // newUploadFile[key] = `${basePath}/${fullName}` - // } - // dispatch(updateUploadFile(newUploadFile)) - // setFile(undefined) - - // onSave() - // } - } + const handleHover = useCallback((isHover: boolean) => { + setIsHover(isHover) + }, []) function removeAsset(asset: Asset) { // e.stopPropagation() setFiles(files.filter((file) => file.name !== asset.name)) } - // const handleNameChange = useCallback((event: React.ChangeEvent) => { - // setAssetName(event.target.value) - // }, []) - - // const isNameUnique = useCallback((name: string, ext: string) => { - // return !assets.find((asset) => { - // const [packageName, otherAssetName] = removeBasePath(basePath, asset.path).split('/') - // if (packageName === 'builder') return false - // return otherAssetName?.toLocaleLowerCase() === name?.toLocaleLowerCase() + '.' + ext - // }) - // }, []) - - // const isNameRepeated = !isNameUnique(assetName, assetExtension) - - // const handleScreenshot = useCallback( - // (value: string) => { - // setThumbnail(value) - // }, - // [files] - // ) - - const types = useMemo(() => Object.values(ACCEPTED_FILE_TYPES).flat().join('/').replaceAll('.', '').toUpperCase(), []) - return ( -
- - {!files.length && ( +
+ + {!files.length && isHover ? ( <>
- Drop {types} files + Drop {ACCEPTED_FILE_TYPES_STR} files - )} - {/* {file && ( -
- -
- -
- -
{file.name}
-
-
- - - - -
-
- )} - {validationError} - {isNameRepeated && ( - There's a file with this name already, you will overwrite it if you continue - )} */} + ) : children}
) -} +}) export default ImportAsset From bd4b90ba8c18e7b6b6fc1139f9d249b6859291f1 Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Thu, 16 Jan 2025 16:20:38 -0300 Subject: [PATCH 03/16] add initial modal for import asset --- packages/@dcl/inspector/.vscode/settings.json | 3 + .../src/components/Assets/Assets.tsx | 3 +- .../src/components/FileInput/FileInput.tsx | 6 +- .../components/ImportAsset/ImportAsset.tsx | 63 +++++++++++++++++-- .../src/components/ImportAsset/types.ts | 5 ++ .../src/components/ImportAsset/utils.ts | 29 ++++++++- 6 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 packages/@dcl/inspector/.vscode/settings.json diff --git a/packages/@dcl/inspector/.vscode/settings.json b/packages/@dcl/inspector/.vscode/settings.json new file mode 100644 index 000000000..25fa6215f --- /dev/null +++ b/packages/@dcl/inspector/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/packages/@dcl/inspector/src/components/Assets/Assets.tsx b/packages/@dcl/inspector/src/components/Assets/Assets.tsx index 7c36e8cca..2b9aa09a9 100644 --- a/packages/@dcl/inspector/src/components/Assets/Assets.tsx +++ b/packages/@dcl/inspector/src/components/Assets/Assets.tsx @@ -18,6 +18,7 @@ import { selectCustomAssets } from '../../redux/app' import { RenameAsset } from '../RenameAsset' import { CreateCustomAsset } from '../CreateCustomAsset' import { InputRef } from '../FileInput/FileInput' +import { Button } from '../Button' import './Assets.css' @@ -56,7 +57,7 @@ function Assets({ isAssetsPanelCollapsed }: { isAssetsPanelCollapsed: boolean }) return (
- +
diff --git a/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx b/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx index 0fc2ed9ec..65f0ca9bb 100644 --- a/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx +++ b/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx @@ -16,7 +16,7 @@ export interface InputRef { } export const FileInput = React.forwardRef>((props, parentRef) => { - const { onDrop } = props + const { disabled, onDrop } = props const acceptExtensions = Object.values(props.accept ?? []).flat() const inputRef = useRef(null) @@ -27,7 +27,7 @@ export const FileInput = React.forwardRef !!acceptExtensions.find((ext) => file.name.endsWith(ext))) + return !disabled && item.files.every((file) => !!acceptExtensions.find((ext) => file.name.endsWith(ext))) }, collect: (monitor) => ({ isHover: monitor.canDrop() && monitor.isOver() @@ -60,7 +60,7 @@ export const FileInput = React.forwardRef ([]) + const [screenshots, setScreenshots] = useState>(new Map()) const [isHover, setIsHover] = useState(false) const { basePath, assets } = catalog ?? { basePath: '', assets: [] } @@ -68,10 +71,20 @@ const ImportAsset = React.forwardRef { // e.stopPropagation() setFiles(files.filter((file) => file.name !== asset.name)) - } + }, []) + + const handleCloseModal = useCallback(() => { + setFiles([]) + setScreenshots(new Map()) + }, []) + + const handleScreenshot = useCallback((file: Asset) => (thumbnail: string) => { + const map = screenshots.set(formatFileName(file), thumbnail) + setScreenshots(new Map(map)) + }, []) return (
@@ -83,7 +96,47 @@ const ImportAsset = React.forwardRef Drop {ACCEPTED_FILE_TYPES_STR} files - ) : children} + ) : ( + <> + {files.map(($, i) => { + const resources = getAssetResources($) + return ( +
+ +
+ ) + })} + {children} + + )} + +

Import Assets

+
+ {files.length > 1 && {files.length}} +
+ {files.length > 1 && } +
+ {files.map(($, i) => { + const name = formatFileName($) + return ( +
+ + + {getAssetSize($)} +
+ ) + })} +
+ {files.length > 1 && } +
+
+ +
) diff --git a/packages/@dcl/inspector/src/components/ImportAsset/types.ts b/packages/@dcl/inspector/src/components/ImportAsset/types.ts index c39508fe2..d11f6b337 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/types.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/types.ts @@ -26,3 +26,8 @@ export type BabylonValidationIssue = { message: string pointer: string } + +export const isGltfAsset = (asset: Asset): asset is GltfAsset => { + const _asset = asset as any + return _asset.buffers && _asset.images +} diff --git a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts index d7414852f..50b48c259 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts @@ -1,6 +1,6 @@ import { GLTFValidation } from '@babylonjs/loaders' -import { FileAsset, GltfAsset, BabylonValidationIssue, ValidationError, Asset, Uri, GltfFile } from './types' +import { FileAsset, GltfAsset, BabylonValidationIssue, ValidationError, Asset, Uri, GltfFile, isGltfAsset } from './types' const sampleIndex = (list: any[]) => Math.floor(Math.random() * list.length) @@ -148,7 +148,7 @@ function extractFileInfo(fileName: string): [string, string] { return match ? [match[1], match[2]?.toLowerCase() || ""] : [fileName, ""] } -function formatFileName(file: FileAsset): string { +export function formatFileName(file: FileAsset): string { return `${file.name}.${file.extension}` } @@ -225,3 +225,28 @@ export async function processAssets(files: File[]): Promise { const processedFiles = await Promise.all(files.map(processFile)) return processGltfAssets(processedFiles) } + +export function normalizeBytes(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let value = bytes; + let unitIndex = 0; + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + + const roundedValue = Math.round(value * 100) / 100; + return `${roundedValue} ${units[unitIndex]}`; +} + +export function getAssetSize(asset: Asset): string { + const resources = getAssetResources(asset) + const sumSize = resources.reduce((size, resource) => size + resource.size, asset.blob.size) + return normalizeBytes(sumSize) +} + +export function getAssetResources(asset: Asset): File[] { + if (!isGltfAsset(asset)) return [] + return [...asset.buffers, ...asset.images].map(($) => $.blob) +} From 6b72c743e4330f00abe2427bba21d97f6bcbb288 Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Fri, 17 Jan 2025 18:28:39 -0300 Subject: [PATCH 04/16] add slider --- .../components/AssetPreview/AssetPreview.tsx | 7 +- .../components/ImportAsset/ImportAsset.css | 4 ++ .../components/ImportAsset/ImportAsset.tsx | 48 +++---------- .../components/ImportAsset/Slider/Slider.css | 60 ++++++++++++++++ .../components/ImportAsset/Slider/Slider.tsx | 69 +++++++++++++++++++ .../components/ImportAsset/Slider/index.ts | 1 + .../components/ImportAsset/Slider/types.ts | 8 +++ 7 files changed, 155 insertions(+), 42 deletions(-) create mode 100644 packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css create mode 100644 packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx create mode 100644 packages/@dcl/inspector/src/components/ImportAsset/Slider/index.ts create mode 100644 packages/@dcl/inspector/src/components/ImportAsset/Slider/types.ts diff --git a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx index 1ceb719e0..fac8ac1a8 100644 --- a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx +++ b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx @@ -14,11 +14,14 @@ const WIDTH = 300 const HEIGHT = 300 export function AssetPreview({ value, resources, onScreenshot, onLoad }: Props) { + const name = value.name + const isImage = name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') + return (
- {isGltf(value.name) ? ( + {isGltf(name) ? ( - ) : value.name.endsWith('png') ? ( + ) : isImage ? ( ) : ( diff --git a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.css b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.css index 9c0b52d49..8526a0efb 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.css +++ b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.css @@ -135,3 +135,7 @@ .ImportAssetHover .text { text-align: center; } + +.ImportAssetModal h2 { + text-align: center; +} diff --git a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx index bf6c21c3b..06d590d19 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx @@ -14,13 +14,13 @@ import FileInput from '../FileInput' import { Container } from '../Container' import { TextField } from '../ui/TextField' import { Block } from '../Block' -import { Button } from '../Button' import { AssetPreview } from '../AssetPreview' import { Modal } from '../Modal' import { Input } from '../Input' import { InputRef } from '../FileInput/FileInput' +import { Slider } from './Slider' -import { formatFileName, processAssets, getAssetSize, getAssetResources } from './utils' +import { formatFileName, processAssets } from './utils' import { Asset } from './types' import './ImportAsset.css' @@ -28,6 +28,7 @@ import './ImportAsset.css' const ACCEPTED_FILE_TYPES = { 'model/gltf-binary': ['.gltf', '.glb', '.bin'], 'image/png': ['.png'], + 'image/jpeg': ['.jpg', '.jpeg'], 'audio/mpeg': ['.mp3'], 'audio/wav': ['.wav'], 'audio/ogg': ['.ogg'], @@ -50,7 +51,7 @@ const ImportAsset = React.forwardRef([]) - const [screenshots, setScreenshots] = useState>(new Map()) + const [isHover, setIsHover] = useState(false) const { basePath, assets } = catalog ?? { basePath: '', assets: [] } @@ -78,12 +79,10 @@ const ImportAsset = React.forwardRef { setFiles([]) - setScreenshots(new Map()) }, []) - const handleScreenshot = useCallback((file: Asset) => (thumbnail: string) => { - const map = screenshots.set(formatFileName(file), thumbnail) - setScreenshots(new Map(map)) + const handleImport = useCallback(() => { + }, []) return ( @@ -96,19 +95,7 @@ const ImportAsset = React.forwardRef Drop {ACCEPTED_FILE_TYPES_STR} files - ) : ( - <> - {files.map(($, i) => { - const resources = getAssetResources($) - return ( -
- -
- ) - })} - {children} - - )} + ) : children}

Import Assets

-
- {files.length > 1 && {files.length}} -
- {files.length > 1 && } -
- {files.map(($, i) => { - const name = formatFileName($) - return ( -
- - - {getAssetSize($)} -
- ) - })} -
- {files.length > 1 && } -
-
- +
diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css new file mode 100644 index 000000000..9f24da6e1 --- /dev/null +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css @@ -0,0 +1,60 @@ +.Slider { + display: flex; + flex-direction: column; + align-items: center; + user-select: none; +} + +.Slider .content { + display: flex; + align-items: center; +} + +.Slider .left, +.Slider .right { + border: 1px solid var(--base-10);; + border-radius: 50%; + display: flex; + padding: 3px; + cursor: pointer; +} + +.Slider .left.disabled, +.Slider .right.disabled { + cursor: not-allowed; + opacity: 0.2; +} + +.Slider .asset { + display: flex; + flex-direction: column; + display: none; +} + +.Slider .asset.active { + display: flex; +} + +.Slider .asset > div { + display: flex; + flex-direction: column; + margin: 10px 10px 5px 10px; + width: 150px; +} + +.Slider .asset > div .AssetPreview, +.Slider .asset > div .thumbnail { + margin-bottom: 12px; + width: 100%; + height: 100%; +} + +.Slider .asset .size { + text-align: center; + font-size: 12px; + color: var(--base-10); +} + +.Slider button { + margin-top: 5px; +} diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx new file mode 100644 index 000000000..021a9b679 --- /dev/null +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx @@ -0,0 +1,69 @@ +import { useCallback, useMemo, useState } from 'react' +import { IoIosArrowBack, IoIosArrowForward } from 'react-icons/io' +import cx from 'classnames' + +import { AssetPreview } from '../../AssetPreview' +import { Button } from '../../Button' +import { Input } from '../../Input' + +import { formatFileName, getAssetSize, getAssetResources } from '../utils' + +import { Asset } from '../types' +import { PropTypes, Thumbnails } from './types' + +import './Slider.css' + +export function Slider({ assets, onSubmit }: PropTypes) { + const [slide, setSlide] = useState(0) + const [screenshots, setScreenshots] = useState({}) + const manyFiles = assets.length > 1 + + const handleScreenshot = useCallback((file: Asset) => (thumbnail: string) => { + const name = formatFileName(file) + if (!screenshots[name]) { + setScreenshots({ ...screenshots, [name]: thumbnail }) + } + }, [screenshots]) + + const handlePrevClick = useCallback(() => { + setSlide(Math.max(0, slide - 1)) + }, [slide]) + + const handleNextClick = useCallback(() => { + setSlide(Math.min(assets.length - 1, slide + 1)) + }, [slide]) + + const handleSubmit = useCallback(() => { + onSubmit(assets.map(($) => ({ + ...$, + thumbnail: screenshots[formatFileName($)] + }))) + }, [assets, screenshots]) + + const countText = useMemo(() => `${slide + 1}/${assets.length}`, [slide, assets]) + const importText = useMemo(() => `IMPORT${manyFiles ? ' ALL' : ''}`, [manyFiles]) + + if (!assets.length) return null + + return ( +
+ {manyFiles && {countText}} +
+ {manyFiles && } +
+ {assets.map(($, i) => ( +
+
+ + +
+ {getAssetSize($)} +
+ ))} +
+ {manyFiles && = assets.length - 1 })} onClick={handleNextClick}>} +
+ +
+ ) +} diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/index.ts b/packages/@dcl/inspector/src/components/ImportAsset/Slider/index.ts new file mode 100644 index 000000000..6d43da3c0 --- /dev/null +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/index.ts @@ -0,0 +1 @@ +export { Slider } from './Slider' diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/types.ts b/packages/@dcl/inspector/src/components/ImportAsset/Slider/types.ts new file mode 100644 index 000000000..dab841206 --- /dev/null +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/types.ts @@ -0,0 +1,8 @@ +import { Asset } from '../types' + +export type PropTypes = { + assets: Asset[] + onSubmit: (assets: Asset[]) => void +} + +export type Thumbnails = Record From 4412be4fc18aa7061584cea757319d4c8ed507bc Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Mon, 20 Jan 2025 14:38:10 -0300 Subject: [PATCH 05/16] some fixes for slider --- .../components/AssetPreview/AssetPreview.tsx | 45 +++++++++++-------- .../src/components/FileInput/FileInput.tsx | 1 + .../src/components/FileInput/types.ts | 1 + .../components/ImportAsset/ImportAsset.tsx | 7 ++- .../components/ImportAsset/Slider/Slider.css | 13 ++++-- .../components/ImportAsset/Slider/Slider.tsx | 22 ++++----- 6 files changed, 53 insertions(+), 36 deletions(-) diff --git a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx index fac8ac1a8..cc06a3450 100644 --- a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx +++ b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx @@ -1,9 +1,10 @@ -import * as React from 'react' +import { useCallback, useMemo } from 'react' import { PreviewCamera, PreviewProjection } from '@dcl/schemas' import { WearablePreview } from 'decentraland-ui' -import { IoIosImage } from 'react-icons/io' +import { AiFillSound } from "react-icons/ai"; +import { IoVideocamOutline } from "react-icons/io5"; +import { FaFile } from "react-icons/fa"; -import { isAsset as isGltf } from '../EntityInspector/GltfInspector/utils' import { toWearableWithBlobs } from './utils' import { Props } from './types' @@ -14,24 +15,32 @@ const WIDTH = 300 const HEIGHT = 300 export function AssetPreview({ value, resources, onScreenshot, onLoad }: Props) { - const name = value.name - const isImage = name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') + const preview = useMemo(() => { + const ext = value.name.split('.').pop() + switch (ext) { + case 'gltf': + case 'glb': + return ; + case 'png': + case 'jpg': + case 'jpeg': + return ; + case 'mp3': + case 'wav': + case 'ogg': + return ; + case 'mp4': + return ; + default: + return ; + } + }, []) - return ( -
- {isGltf(name) ? ( - - ) : isImage ? ( - - ) : ( - - )} -
- ) + return
{preview}
} function GltfPreview({ value, resources, onScreenshot, onLoad }: Props) { - const handleLoad = React.useCallback(() => { + const handleLoad = useCallback(() => { onLoad?.() const wp = WearablePreview.createController(value.name) void wp.scene.getScreenshot(WIDTH, HEIGHT).then(($) => onScreenshot($)) @@ -42,7 +51,7 @@ function GltfPreview({ value, resources, onScreenshot, onLoad }: Props) { id={value.name} blob={toWearableWithBlobs(value, resources)} disableAutoRotate - disableBackground + background="#3c3c3c" projection={PreviewProjection.ORTHOGRAPHIC} camera={PreviewCamera.STATIC} onLoad={handleLoad} diff --git a/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx b/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx index 65f0ca9bb..c162081af 100644 --- a/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx +++ b/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx @@ -67,6 +67,7 @@ export const FileInput = React.forwardRef {props.children}
diff --git a/packages/@dcl/inspector/src/components/FileInput/types.ts b/packages/@dcl/inspector/src/components/FileInput/types.ts index 22b346edc..d3403bbb9 100644 --- a/packages/@dcl/inspector/src/components/FileInput/types.ts +++ b/packages/@dcl/inspector/src/components/FileInput/types.ts @@ -3,4 +3,5 @@ export interface PropTypes { onHover?(isHover: boolean): void accept?: Record disabled?: boolean + multiple?: boolean } diff --git a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx index 06d590d19..c4cf0a6c3 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx @@ -64,7 +64,6 @@ const ImportAsset = React.forwardRef { const assets = await processAssets(acceptedFiles) - console.log('assets: ', assets) setFiles(assets) }, []) @@ -81,13 +80,13 @@ const ImportAsset = React.forwardRef { - + const handleImport = useCallback((assets: Asset[]) => { + console.log('handleImport', assets) }, []) return (
- + {!files.length && isHover ? ( <>
diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css index 9f24da6e1..3aa2f8d02 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css @@ -42,11 +42,16 @@ width: 150px; } -.Slider .asset > div .AssetPreview, -.Slider .asset > div .thumbnail { +.Slider .asset > div .AssetPreview { margin-bottom: 12px; - width: 100%; - height: 100%; + width: 150px; + height: 150px; + background-color: var(--background-gray); +} + +.Slider .asset > div .AssetPreview > svg { + width: 60%; + height: 60%; } .Slider .asset .size { diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx index 021a9b679..c72ac9b4e 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx @@ -16,14 +16,13 @@ import './Slider.css' export function Slider({ assets, onSubmit }: PropTypes) { const [slide, setSlide] = useState(0) const [screenshots, setScreenshots] = useState({}) - const manyFiles = assets.length > 1 - const handleScreenshot = useCallback((file: Asset) => (thumbnail: string) => { + const handleScreenshot = (file: Asset) => (thumbnail: string) => { const name = formatFileName(file) if (!screenshots[name]) { - setScreenshots({ ...screenshots, [name]: thumbnail }) + setScreenshots((value) => ({ ...value, [name]: thumbnail })) } - }, [screenshots]) + } const handlePrevClick = useCallback(() => { setSlide(Math.max(0, slide - 1)) @@ -38,18 +37,21 @@ export function Slider({ assets, onSubmit }: PropTypes) { ...$, thumbnail: screenshots[formatFileName($)] }))) - }, [assets, screenshots]) + }, [screenshots]) + const manyAssets = useMemo(() => assets.length > 1, [assets]) const countText = useMemo(() => `${slide + 1}/${assets.length}`, [slide, assets]) - const importText = useMemo(() => `IMPORT${manyFiles ? ' ALL' : ''}`, [manyFiles]) + const importText = useMemo(() => `IMPORT${manyAssets ? ' ALL' : ''}`, [manyAssets]) + const leftArrowDisabled = useMemo(() => slide <= 0, [slide]) + const rightArrowDisabled = useMemo(() => slide >= assets.length - 1, [slide, assets]) if (!assets.length) return null return (
- {manyFiles && {countText}} + {manyAssets && {countText}}
- {manyFiles && } + {manyAssets && }
{assets.map(($, i) => (
@@ -61,9 +63,9 @@ export function Slider({ assets, onSubmit }: PropTypes) {
))}
- {manyFiles && = assets.length - 1 })} onClick={handleNextClick}>} + {manyAssets && }
- +
) } From 4121461d058fa5f07301667d17bf4955aae31468 Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Tue, 21 Jan 2025 11:29:15 -0300 Subject: [PATCH 06/16] added import --- .../components/ImportAsset/ImportAsset.tsx | 81 ++++++++++++------- .../components/ImportAsset/Slider/Slider.tsx | 36 +++++---- .../src/components/ImportAsset/utils.ts | 41 +++++++++- 3 files changed, 112 insertions(+), 46 deletions(-) diff --git a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx index c4cf0a6c3..89485354b 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx @@ -1,8 +1,6 @@ -import React, { PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react' +import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react' import cx from 'classnames' import { HiOutlineUpload } from 'react-icons/hi' -import { RxCross2 } from 'react-icons/rx' -import classNames from 'classnames' import { removeBasePath } from '../../lib/logic/remove-base-path' import { DIRECTORY, transformBase64ResourceToBinary, withAssetDir } from '../../lib/data-layer/host/fs-utils' @@ -11,30 +9,15 @@ import { useAppDispatch, useAppSelector } from '../../redux/hooks' import { selectAssetCatalog, selectUploadFile, updateUploadFile } from '../../redux/app' import FileInput from '../FileInput' -import { Container } from '../Container' -import { TextField } from '../ui/TextField' -import { Block } from '../Block' -import { AssetPreview } from '../AssetPreview' import { Modal } from '../Modal' -import { Input } from '../Input' import { InputRef } from '../FileInput/FileInput' import { Slider } from './Slider' -import { formatFileName, processAssets } from './utils' -import { Asset } from './types' +import { processAssets, assetsAreValid, ACCEPTED_FILE_TYPES, formatFileName, transformAssetToImport } from './utils' +import { Asset, isGltfAsset } from './types' import './ImportAsset.css' -const ACCEPTED_FILE_TYPES = { - 'model/gltf-binary': ['.gltf', '.glb', '.bin'], - 'image/png': ['.png'], - 'image/jpeg': ['.jpg', '.jpeg'], - 'audio/mpeg': ['.mp3'], - 'audio/wav': ['.wav'], - 'audio/ogg': ['.ogg'], - 'video/mp4': ['.mp4'] -} - const ACCEPTED_FILE_TYPES_STR = Object .values(ACCEPTED_FILE_TYPES) .flat().join('/') @@ -45,7 +28,7 @@ interface PropTypes { onSave(): void } -const ImportAsset = React.forwardRef>(({ onSave, children }, inputRef) => { +const ImportAsset = React.forwardRef>(({ onSave, children }, inputRef) => { const dispatch = useAppDispatch() const catalog = useAppSelector(selectAssetCatalog) const uploadFile = useAppSelector(selectUploadFile) @@ -71,18 +54,58 @@ const ImportAsset = React.forwardRef { - // e.stopPropagation() - setFiles(files.filter((file) => file.name !== asset.name)) - }, []) - const handleCloseModal = useCallback(() => { setFiles([]) }, []) - const handleImport = useCallback((assets: Asset[]) => { - console.log('handleImport', assets) - }, []) + const handleImport = useCallback(async (assets: Asset[]) => { + if (!assetsAreValid(assets)) return + + const basePath = withAssetDir(DIRECTORY.SCENE) + // TODO: we are dispatching importAsset + saveThumbnail for every asset, refreshing the app state and UI multiple + // times. This can be improved by doing all the process once... + for (const asset of assets) { + const content = await transformAssetToImport(asset) + + dispatch( + importAsset({ + content, + basePath, + assetPackageName: isGltfAsset(asset) ? asset.name : '', + reload: true + }) + ) + + if (asset.thumbnail) { + dispatch( + saveThumbnail({ + content: transformBase64ResourceToBinary(asset.thumbnail), + path: `${DIRECTORY.THUMBNAILS}/${asset.name}.png` + }) + ) + } + + // Clear uploaded file from the FileUploadField + const newUploadFile = { ...uploadFile } + for (const key in newUploadFile) { + newUploadFile[key] = `${basePath}/${formatFileName(asset)}` + } + dispatch(updateUploadFile(newUploadFile)) + } + + setFiles([]) + onSave() + }, [uploadFile]) + + // const isNameUnique = useCallback((name: string, ext: string) => { + // return !assets.find((asset) => { + // const [packageName, otherAssetName] = removeBasePath(basePath, asset.path).split('/') + // if (packageName === 'builder') return false + // return otherAssetName?.toLocaleLowerCase() === name?.toLocaleLowerCase() + '.' + ext + // }) + // }, []) + + // const isNameRepeated = !isNameUnique(assetName, assetExtension) return (
diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx index c72ac9b4e..2d88844a3 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx @@ -14,38 +14,46 @@ import { PropTypes, Thumbnails } from './types' import './Slider.css' export function Slider({ assets, onSubmit }: PropTypes) { + const [value, setValue] = useState(assets) const [slide, setSlide] = useState(0) const [screenshots, setScreenshots] = useState({}) - const handleScreenshot = (file: Asset) => (thumbnail: string) => { - const name = formatFileName(file) + const handleScreenshot = useCallback((file: Asset) => (thumbnail: string) => { + const { name } = file.blob if (!screenshots[name]) { - setScreenshots((value) => ({ ...value, [name]: thumbnail })) + setScreenshots(($) => ({ ...$, [name]: thumbnail })) } - } + }, [screenshots]) const handlePrevClick = useCallback(() => { setSlide(Math.max(0, slide - 1)) }, [slide]) const handleNextClick = useCallback(() => { - setSlide(Math.min(assets.length - 1, slide + 1)) + setSlide(Math.min(value.length - 1, slide + 1)) }, [slide]) + const handleNameChange = useCallback((fileIdx: number) => (newName: string) => { + setValue(value.map(($, i) => { + if (fileIdx !== i) return $ + return { ...$, name: newName } + })) + }, [value]) + const handleSubmit = useCallback(() => { - onSubmit(assets.map(($) => ({ + onSubmit(value.map(($) => ({ ...$, - thumbnail: screenshots[formatFileName($)] + thumbnail: screenshots[$.blob.name] }))) - }, [screenshots]) + }, [value, screenshots]) - const manyAssets = useMemo(() => assets.length > 1, [assets]) - const countText = useMemo(() => `${slide + 1}/${assets.length}`, [slide, assets]) + const manyAssets = useMemo(() => value.length > 1, [value]) + const countText = useMemo(() => `${slide + 1}/${value.length}`, [slide, value]) const importText = useMemo(() => `IMPORT${manyAssets ? ' ALL' : ''}`, [manyAssets]) const leftArrowDisabled = useMemo(() => slide <= 0, [slide]) - const rightArrowDisabled = useMemo(() => slide >= assets.length - 1, [slide, assets]) + const rightArrowDisabled = useMemo(() => slide >= value.length - 1, [slide, value]) - if (!assets.length) return null + if (!value.length) return null return (
@@ -53,11 +61,11 @@ export function Slider({ assets, onSubmit }: PropTypes) {
{manyAssets && }
- {assets.map(($, i) => ( + {value.map(($, i) => (
- +
{getAssetSize($)}
diff --git a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts index 50b48c259..9ce9a24e1 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts @@ -93,10 +93,18 @@ export const adjectives = [ 'absorbed' ] +export const ACCEPTED_FILE_TYPES = { + 'model/gltf-binary': ['.gltf', '.glb', '.bin'], + 'image/png': ['.png'], + 'image/jpeg': ['.jpg', '.jpeg'], + 'audio/mpeg': ['.mp3'], + 'audio/wav': ['.wav'], + 'audio/ogg': ['.ogg'], + 'video/mp4': ['.mp4'] +} + const ONE_GB_IN_BYTES = 1024 * 1024 * 1024 -const VALID_EXTENSIONS = new Set([ - 'glb', 'gltf', 'bin', 'png', 'ktx2', 'mp3', 'wav', 'ogg', 'mp4' -]) +const VALID_EXTENSIONS = new Set(Object.values(ACCEPTED_FILE_TYPES).flat().map(($) => $.replaceAll('.', ''))) const IGNORED_ERROR_CODES = ['ACCESSOR_WEIGHTS_NON_NORMALIZED', 'MESH_PRIMITIVE_TOO_FEW_TEXCOORDS'] async function validateGltf(gltf: GltfAsset): Promise { @@ -250,3 +258,30 @@ export function getAssetResources(asset: Asset): File[] { if (!isGltfAsset(asset)) return [] return [...asset.buffers, ...asset.images].map(($) => $.blob) } + +export function assetIsValid(asset: Asset): boolean { + if (!asset.error) return true + if (isGltfAsset(asset)) return !![...asset.buffers, ...asset.images].find(($) => assetIsValid($)) + return false +} + +export function assetsAreValid(assets: Asset[]): boolean { + return !!assets.find(($) => assetIsValid($)) +} + +export async function transformAssetToImport(asset: Asset): Promise> { + const content: Map = new Map() + const fullName = formatFileName(asset) + const binary = await asset.blob.arrayBuffer() + content.set(fullName, new Uint8Array(binary)) + + if (isGltfAsset(asset)) { + const resources = getAssetResources(asset) + for (const resource of resources) { + const resourceBinary = await resource.arrayBuffer() + content.set(resource.name, new Uint8Array(resourceBinary)) + } + } + + return content +} From 36d6645b52370b9644b4a092ab6291b1bd1aec9b Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Tue, 21 Jan 2025 13:05:37 -0300 Subject: [PATCH 07/16] some fixes and renaming --- .../src/components/Assets/Assets.css | 20 +++++--- .../src/components/Assets/Assets.tsx | 7 +-- .../AssetsCatalog/AssetsCatalog.tsx | 13 ++--- .../components/ImportAsset/ImportAsset.tsx | 18 ++++--- .../src/components/ImportAsset/types.ts | 12 +++-- .../src/components/ImportAsset/utils.ts | 50 +++++++++++++------ .../ui/FileUploadField/FileUploadField.tsx | 3 -- packages/@dcl/inspector/src/redux/ui/types.ts | 1 - 8 files changed, 68 insertions(+), 56 deletions(-) diff --git a/packages/@dcl/inspector/src/components/Assets/Assets.css b/packages/@dcl/inspector/src/components/Assets/Assets.css index 004d5662d..ede2489ee 100644 --- a/packages/@dcl/inspector/src/components/Assets/Assets.css +++ b/packages/@dcl/inspector/src/components/Assets/Assets.css @@ -17,8 +17,10 @@ padding-left: 8px; display: flex; background: var(--tree-bg-color); - height: 36px; + height: 60px; position: relative; + align-items: center; + justify-content: center; } .Assets .Assets-buttons > div > div { @@ -61,15 +63,17 @@ user-select: none; } -.Assets .Assets-buttons > div:last-child svg { - width: 16px; - height: 16px; +.Assets .Assets-buttons > button:first-child { + margin: 3px; + position: absolute; + left: 0px; + margin-left: 8px; + display: flex; + align-items: center; } -.Assets .Assets-buttons > div:last-child { - position: absolute; - right: 0px; - margin-right: 8px; +.Assets .Assets-buttons > button:first-child svg { + margin-right: 5px; } .Assets .Assets-content { diff --git a/packages/@dcl/inspector/src/components/Assets/Assets.tsx b/packages/@dcl/inspector/src/components/Assets/Assets.tsx index 2b9aa09a9..d73b9cd8c 100644 --- a/packages/@dcl/inspector/src/components/Assets/Assets.tsx +++ b/packages/@dcl/inspector/src/components/Assets/Assets.tsx @@ -57,7 +57,7 @@ function Assets({ isAssetsPanelCollapsed }: { isAssetsPanelCollapsed: boolean }) return (
- +
@@ -78,11 +78,6 @@ function Assets({ isAssetsPanelCollapsed }: { isAssetsPanelCollapsed: boolean }) ASSET PACKS
-
-
- -
-
diff --git a/packages/@dcl/inspector/src/components/AssetsCatalog/AssetsCatalog.tsx b/packages/@dcl/inspector/src/components/AssetsCatalog/AssetsCatalog.tsx index ac993c2b8..1d4b16bd6 100644 --- a/packages/@dcl/inspector/src/components/AssetsCatalog/AssetsCatalog.tsx +++ b/packages/@dcl/inspector/src/components/AssetsCatalog/AssetsCatalog.tsx @@ -3,8 +3,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { AssetPack } from '../../lib/logic/catalog' import { analytics, Event } from '../../lib/logic/analytics' import { useAppDispatch } from '../../redux/hooks' -import { selectAssetsTab } from '../../redux/ui' -import { AssetsTab } from '../../redux/ui/types' import { Header } from './Header' import { Themes } from './Themes' @@ -16,16 +14,11 @@ import { Props } from './types' import './AssetsCatalog.css' const AssetsCatalog: React.FC = ({ catalog }) => { - const dispatch = useAppDispatch() const [selectedTheme, setSelectedTheme] = useState() const [search, setSearch] = useState('') const handleThemeChange = useCallback((value?: AssetPack) => setSelectedTheme(value), [setSelectedTheme]) - const handleUploadAsset = useCallback(() => { - dispatch(selectAssetsTab({ tab: AssetsTab.Import })) - }, []) - const handleSearchAssets = useCallback( (value: string) => { setSearch(value) @@ -63,8 +56,8 @@ const AssetsCatalog: React.FC = ({ catalog }) => { }, [search, filteredCatalog]) const renderEmptySearch = useCallback(() => { - const ctaMethod = selectedTheme ? handleThemeChange : handleUploadAsset - const ctaText = selectedTheme ? 'search all categories' : 'upload your own asset' + const ctaMethod = selectedTheme ? handleThemeChange : () => undefined + const ctaText = selectedTheme ? 'search all categories' : 'upload your own asset by drag & drop' return (
No results for '{search}'. @@ -77,7 +70,7 @@ const AssetsCatalog: React.FC = ({ catalog }) => {
) - }, [search, selectedTheme, handleThemeChange, handleUploadAsset]) + }, [search, selectedTheme, handleThemeChange]) const renderAssets = useCallback(() => { if (filteredCatalog.length > 0) { diff --git a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx index 89485354b..911de1611 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx @@ -13,7 +13,7 @@ import { Modal } from '../Modal' import { InputRef } from '../FileInput/FileInput' import { Slider } from './Slider' -import { processAssets, assetsAreValid, ACCEPTED_FILE_TYPES, formatFileName, transformAssetToImport } from './utils' +import { processAssets, assetsAreValid, ACCEPTED_FILE_TYPES, formatFileName, convertAssetToBinary, determineAssetType } from './utils' import { Asset, isGltfAsset } from './types' import './ImportAsset.css' @@ -39,11 +39,11 @@ const ImportAsset = React.forwardRef>(({ const { basePath, assets } = catalog ?? { basePath: '', assets: [] } useEffect(() => { - const isValidFile = uploadFile && 'name' in uploadFile - if (isValidFile && !files.find(($) => $.name === uploadFile.name)) { - handleDrop([Object.values(uploadFile!)[0] as File]) - } - }, [uploadFile]) + const isValidFile = uploadFile && 'name' in uploadFile + if (isValidFile && !files.find(($) => $.name === uploadFile.name)) { + handleDrop([Object.values(uploadFile!)[0] as File]) + } + }, [uploadFile]) const handleDrop = useCallback(async (acceptedFiles: File[]) => { const assets = await processAssets(acceptedFiles) @@ -65,13 +65,15 @@ const ImportAsset = React.forwardRef>(({ // TODO: we are dispatching importAsset + saveThumbnail for every asset, refreshing the app state and UI multiple // times. This can be improved by doing all the process once... for (const asset of assets) { - const content = await transformAssetToImport(asset) + const content = await convertAssetToBinary(asset) + const classification = determineAssetType(asset) + const assetPackageName = isGltfAsset(asset) ? `${classification}/${asset.name}` : classification dispatch( importAsset({ content, basePath, - assetPackageName: isGltfAsset(asset) ? asset.name : '', + assetPackageName, reload: true }) ) diff --git a/packages/@dcl/inspector/src/components/ImportAsset/types.ts b/packages/@dcl/inspector/src/components/ImportAsset/types.ts index d11f6b337..43e7a8a98 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/types.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/types.ts @@ -1,4 +1,4 @@ -export type FileAsset = { +export type BaseAsset = { blob: File; name: string; extension: string; @@ -6,12 +6,12 @@ export type FileAsset = { thumbnail?: string; }; -export type GltfAsset = FileAsset & { - buffers: FileAsset[]; - images: FileAsset[]; +export type GltfAsset = BaseAsset & { + buffers: BaseAsset[]; + images: BaseAsset[]; }; -export type Asset = GltfAsset | FileAsset; +export type Asset = GltfAsset | BaseAsset; export type Uri = { uri: string }; export type GltfFile = { buffers: Uri[]; images: Uri[] }; @@ -27,6 +27,8 @@ export type BabylonValidationIssue = { pointer: string } +export type AssetType = 'models' | 'images' | 'audio' | 'video' | 'other' + export const isGltfAsset = (asset: Asset): asset is GltfAsset => { const _asset = asset as any return _asset.buffers && _asset.images diff --git a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts index 9ce9a24e1..55f3ab895 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts @@ -1,6 +1,6 @@ import { GLTFValidation } from '@babylonjs/loaders' -import { FileAsset, GltfAsset, BabylonValidationIssue, ValidationError, Asset, Uri, GltfFile, isGltfAsset } from './types' +import { BaseAsset, GltfAsset, BabylonValidationIssue, ValidationError, Asset, Uri, GltfFile, isGltfAsset, AssetType } from './types' const sampleIndex = (list: any[]) => Math.floor(Math.random() * list.length) @@ -156,7 +156,7 @@ function extractFileInfo(fileName: string): [string, string] { return match ? [match[1], match[2]?.toLowerCase() || ""] : [fileName, ""] } -export function formatFileName(file: FileAsset): string { +export function formatFileName(file: BaseAsset): string { return `${file.name}.${file.extension}` } @@ -170,7 +170,7 @@ function validateExtension(extension: string): ValidationError { `Invalid asset format ".${extension}"` } -async function processFile(file: File): Promise { +async function processFile(file: File): Promise { const normalizedName = normalizeFileName(file.name) const [name, extension] = extractFileInfo(normalizedName) @@ -195,8 +195,8 @@ async function validateGltfWithDependencies(gltf: GltfAsset): Promise): FileAsset[] { - return uris.reduce((acc, { uri }) => { +function resolveDependencies(uris: Uri[], fileMap: Map): BaseAsset[] { + return uris.reduce((acc, { uri }) => { const normalizedUri = normalizeFileName(uri) const file = fileMap.get(normalizedUri) if (file) { @@ -207,7 +207,7 @@ function resolveDependencies(uris: Uri[], fileMap: Map): File }, []) } -async function processGltfAssets(files: FileAsset[]): Promise { +async function processGltfAssets(files: BaseAsset[]): Promise { const fileMap = new Map(files.map(file => [formatFileName(file), file])) const gltfPromises = files @@ -269,19 +269,39 @@ export function assetsAreValid(assets: Asset[]): boolean { return !!assets.find(($) => assetIsValid($)) } -export async function transformAssetToImport(asset: Asset): Promise> { - const content: Map = new Map() - const fullName = formatFileName(asset) - const binary = await asset.blob.arrayBuffer() - content.set(fullName, new Uint8Array(binary)) +export async function convertAssetToBinary(asset: Asset): Promise> { + const binaryContents: Map = new Map(); + const fullName = formatFileName(asset); + const binary = await asset.blob.arrayBuffer(); + binaryContents.set(fullName, new Uint8Array(binary)); if (isGltfAsset(asset)) { - const resources = getAssetResources(asset) + const resources = getAssetResources(asset); for (const resource of resources) { - const resourceBinary = await resource.arrayBuffer() - content.set(resource.name, new Uint8Array(resourceBinary)) + const resourceBinary = await resource.arrayBuffer(); + binaryContents.set(resource.name, new Uint8Array(resourceBinary)); } } - return content + return binaryContents; +} + +export function determineAssetType(asset: Asset): AssetType { + switch (asset.extension) { + case 'gltf': + case 'glb': + return 'models' + case 'png': + case 'jpg': + case 'jpeg': + return 'images' + case 'mp3': + case 'wav': + case 'ogg': + return 'audio' + case 'mp4': + return 'video' + default: + return 'other' + } } diff --git a/packages/@dcl/inspector/src/components/ui/FileUploadField/FileUploadField.tsx b/packages/@dcl/inspector/src/components/ui/FileUploadField/FileUploadField.tsx index 55dc5877f..caf6e41df 100644 --- a/packages/@dcl/inspector/src/components/ui/FileUploadField/FileUploadField.tsx +++ b/packages/@dcl/inspector/src/components/ui/FileUploadField/FileUploadField.tsx @@ -5,8 +5,6 @@ import { v4 as uuidv4 } from 'uuid' import { VscFolderOpened as FolderIcon } from 'react-icons/vsc' import { selectAssetCatalog, selectUploadFile, updateUploadFile } from '../../../redux/app' -import { selectAssetsTab } from '../../../redux/ui' -import { AssetsTab } from '../../../redux/ui/types' import { useAppDispatch, useAppSelector } from '../../../redux/hooks' import { DropTypesEnum, LocalAssetDrop, getNode } from '../../../lib/sdk/drag-drop' import { EXTENSIONS, withAssetDir } from '../../../lib/data-layer/host/fs-utils' @@ -130,7 +128,6 @@ const FileUploadField: React.FC = ({ const file = event.target.files?.[0] if (file && isValidFileName(file.name)) { setDropError(false) - dispatch(selectAssetsTab({ tab: AssetsTab.Import })) const newUploadFile = { ...uploadFile } newUploadFile[id.current] = file dispatch(updateUploadFile(newUploadFile)) diff --git a/packages/@dcl/inspector/src/redux/ui/types.ts b/packages/@dcl/inspector/src/redux/ui/types.ts index 8b25f4907..c12f05afb 100644 --- a/packages/@dcl/inspector/src/redux/ui/types.ts +++ b/packages/@dcl/inspector/src/redux/ui/types.ts @@ -2,7 +2,6 @@ export enum AssetsTab { FileSystem = 'FileSystem', CustomAssets = 'CustomAssets', AssetsPack = 'AssetsPack', - Import = 'Import', RenameAsset = 'RenameAsset', CreateCustomAsset = 'CreateCustomAsset' } From 9b1ccbac2ee29af080b161fbbdc4f3159226da12 Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Tue, 21 Jan 2025 13:25:31 -0300 Subject: [PATCH 08/16] lint --- .../components/AssetPreview/AssetPreview.tsx | 16 +-- .../src/components/Assets/Assets.tsx | 7 +- .../AssetsCatalog/AssetsCatalog.tsx | 1 - .../src/components/FileInput/FileInput.tsx | 12 +- .../components/ImportAsset/ImportAsset.tsx | 109 ++++++++++-------- .../components/ImportAsset/Slider/Slider.tsx | 62 ++++++---- .../src/components/ImportAsset/types.ts | 24 ++-- .../src/components/ImportAsset/utils.ts | 92 ++++++++------- .../src/lib/data-layer/host/fs-utils.ts | 5 - 9 files changed, 188 insertions(+), 140 deletions(-) diff --git a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx index cc06a3450..11718dc34 100644 --- a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx +++ b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx @@ -1,9 +1,9 @@ import { useCallback, useMemo } from 'react' import { PreviewCamera, PreviewProjection } from '@dcl/schemas' import { WearablePreview } from 'decentraland-ui' -import { AiFillSound } from "react-icons/ai"; -import { IoVideocamOutline } from "react-icons/io5"; -import { FaFile } from "react-icons/fa"; +import { AiFillSound } from 'react-icons/ai' +import { IoVideocamOutline } from 'react-icons/io5' +import { FaFile } from 'react-icons/fa' import { toWearableWithBlobs } from './utils' import { Props } from './types' @@ -20,19 +20,19 @@ export function AssetPreview({ value, resources, onScreenshot, onLoad }: Props) switch (ext) { case 'gltf': case 'glb': - return ; + return case 'png': case 'jpg': case 'jpeg': - return ; + return case 'mp3': case 'wav': case 'ogg': - return ; + return case 'mp4': - return ; + return default: - return ; + return } }, []) diff --git a/packages/@dcl/inspector/src/components/Assets/Assets.tsx b/packages/@dcl/inspector/src/components/Assets/Assets.tsx index d73b9cd8c..48b4580c6 100644 --- a/packages/@dcl/inspector/src/components/Assets/Assets.tsx +++ b/packages/@dcl/inspector/src/components/Assets/Assets.tsx @@ -33,7 +33,7 @@ function Assets({ isAssetsPanelCollapsed }: { isAssetsPanelCollapsed: boolean }) const dispatch = useAppDispatch() const tab = useAppSelector(getSelectedAssetsTab) const customAssets = useAppSelector(selectCustomAssets) - const inputRef = useRef(null); + const inputRef = useRef(null) const handleTabClick = useCallback( (tab: AssetsTab) => () => { @@ -57,7 +57,10 @@ function Assets({ isAssetsPanelCollapsed }: { isAssetsPanelCollapsed: boolean }) return (
- +
diff --git a/packages/@dcl/inspector/src/components/AssetsCatalog/AssetsCatalog.tsx b/packages/@dcl/inspector/src/components/AssetsCatalog/AssetsCatalog.tsx index 1d4b16bd6..4437bdef0 100644 --- a/packages/@dcl/inspector/src/components/AssetsCatalog/AssetsCatalog.tsx +++ b/packages/@dcl/inspector/src/components/AssetsCatalog/AssetsCatalog.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { AssetPack } from '../../lib/logic/catalog' import { analytics, Event } from '../../lib/logic/analytics' -import { useAppDispatch } from '../../redux/hooks' import { Header } from './Header' import { Themes } from './Themes' diff --git a/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx b/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx index c162081af..30b98b267 100644 --- a/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx +++ b/packages/@dcl/inspector/src/components/FileInput/FileInput.tsx @@ -12,7 +12,7 @@ function parseAccept(accept: PropTypes['accept']) { } export interface InputRef { - onClick: () => void; + onClick: () => void } export const FileInput = React.forwardRef>((props, parentRef) => { @@ -53,9 +53,13 @@ export const FileInput = React.forwardRef props.onHover?.(false) }, [isHover]) - useImperativeHandle(parentRef, () => ({ - onClick: handleClick, - }), [handleClick]); + useImperativeHandle( + parentRef, + () => ({ + onClick: handleClick + }), + [handleClick] + ) return (
diff --git a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx index 911de1611..c7bd70eae 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx @@ -2,7 +2,7 @@ import React, { PropsWithChildren, useCallback, useEffect, useState } from 'reac import cx from 'classnames' import { HiOutlineUpload } from 'react-icons/hi' -import { removeBasePath } from '../../lib/logic/remove-base-path' +// import { removeBasePath } from '../../lib/logic/remove-base-path' import { DIRECTORY, transformBase64ResourceToBinary, withAssetDir } from '../../lib/data-layer/host/fs-utils' import { importAsset, saveThumbnail } from '../../redux/data-layer' import { useAppDispatch, useAppSelector } from '../../redux/hooks' @@ -13,16 +13,19 @@ import { Modal } from '../Modal' import { InputRef } from '../FileInput/FileInput' import { Slider } from './Slider' -import { processAssets, assetsAreValid, ACCEPTED_FILE_TYPES, formatFileName, convertAssetToBinary, determineAssetType } from './utils' +import { + processAssets, + assetsAreValid, + ACCEPTED_FILE_TYPES, + formatFileName, + convertAssetToBinary, + determineAssetType +} from './utils' import { Asset, isGltfAsset } from './types' import './ImportAsset.css' -const ACCEPTED_FILE_TYPES_STR = Object - .values(ACCEPTED_FILE_TYPES) - .flat().join('/') - .replaceAll('.', '') - .toUpperCase() +const ACCEPTED_FILE_TYPES_STR = Object.values(ACCEPTED_FILE_TYPES).flat().join('/').replaceAll('.', '').toUpperCase() interface PropTypes { onSave(): void @@ -30,18 +33,18 @@ interface PropTypes { const ImportAsset = React.forwardRef>(({ onSave, children }, inputRef) => { const dispatch = useAppDispatch() - const catalog = useAppSelector(selectAssetCatalog) + // const catalog = useAppSelector(selectAssetCatalog) const uploadFile = useAppSelector(selectUploadFile) const [files, setFiles] = useState([]) const [isHover, setIsHover] = useState(false) - const { basePath, assets } = catalog ?? { basePath: '', assets: [] } + // const { basePath, assets } = catalog ?? { basePath: '', assets: [] } useEffect(() => { const isValidFile = uploadFile && 'name' in uploadFile if (isValidFile && !files.find(($) => $.name === uploadFile.name)) { - handleDrop([Object.values(uploadFile!)[0] as File]) + void handleDrop([Object.values(uploadFile!)[0] as File]) } }, [uploadFile]) @@ -58,46 +61,49 @@ const ImportAsset = React.forwardRef>(({ setFiles([]) }, []) - const handleImport = useCallback(async (assets: Asset[]) => { - if (!assetsAreValid(assets)) return - - const basePath = withAssetDir(DIRECTORY.SCENE) - // TODO: we are dispatching importAsset + saveThumbnail for every asset, refreshing the app state and UI multiple - // times. This can be improved by doing all the process once... - for (const asset of assets) { - const content = await convertAssetToBinary(asset) - const classification = determineAssetType(asset) - const assetPackageName = isGltfAsset(asset) ? `${classification}/${asset.name}` : classification - - dispatch( - importAsset({ - content, - basePath, - assetPackageName, - reload: true - }) - ) - - if (asset.thumbnail) { + const handleImport = useCallback( + async (assets: Asset[]) => { + if (!assetsAreValid(assets)) return + + const basePath = withAssetDir(DIRECTORY.SCENE) + // TODO: we are dispatching importAsset + saveThumbnail for every asset, refreshing the app state and UI multiple + // times. This can be improved by doing all the process once... + for (const asset of assets) { + const content = await convertAssetToBinary(asset) + const classification = determineAssetType(asset) + const assetPackageName = isGltfAsset(asset) ? `${classification}/${asset.name}` : classification + dispatch( - saveThumbnail({ - content: transformBase64ResourceToBinary(asset.thumbnail), - path: `${DIRECTORY.THUMBNAILS}/${asset.name}.png` + importAsset({ + content, + basePath, + assetPackageName, + reload: true }) ) - } - // Clear uploaded file from the FileUploadField - const newUploadFile = { ...uploadFile } - for (const key in newUploadFile) { - newUploadFile[key] = `${basePath}/${formatFileName(asset)}` + if (asset.thumbnail) { + dispatch( + saveThumbnail({ + content: transformBase64ResourceToBinary(asset.thumbnail), + path: `${DIRECTORY.THUMBNAILS}/${asset.name}.png` + }) + ) + } + + // Clear uploaded file from the FileUploadField + const newUploadFile = { ...uploadFile } + for (const key in newUploadFile) { + newUploadFile[key] = `${basePath}/${formatFileName(asset)}` + } + dispatch(updateUploadFile(newUploadFile)) } - dispatch(updateUploadFile(newUploadFile)) - } - setFiles([]) - onSave() - }, [uploadFile]) + setFiles([]) + onSave() + }, + [uploadFile] + ) // const isNameUnique = useCallback((name: string, ext: string) => { // return !assets.find((asset) => { @@ -110,8 +116,15 @@ const ImportAsset = React.forwardRef>(({ // const isNameRepeated = !isNameUnique(assetName, assetExtension) return ( -
- +
+ {!files.length && isHover ? ( <>
@@ -119,7 +132,9 @@ const ImportAsset = React.forwardRef>(({
Drop {ACCEPTED_FILE_TYPES_STR} files - ) : children} + ) : ( + children + )} ({}) - const handleScreenshot = useCallback((file: Asset) => (thumbnail: string) => { - const { name } = file.blob - if (!screenshots[name]) { - setScreenshots(($) => ({ ...$, [name]: thumbnail })) - } - }, [screenshots]) + const handleScreenshot = useCallback( + (file: Asset) => (thumbnail: string) => { + const { name } = file.blob + if (!screenshots[name]) { + setScreenshots(($) => ({ ...$, [name]: thumbnail })) + } + }, + [screenshots] + ) const handlePrevClick = useCallback(() => { setSlide(Math.max(0, slide - 1)) @@ -33,18 +36,25 @@ export function Slider({ assets, onSubmit }: PropTypes) { setSlide(Math.min(value.length - 1, slide + 1)) }, [slide]) - const handleNameChange = useCallback((fileIdx: number) => (newName: string) => { - setValue(value.map(($, i) => { - if (fileIdx !== i) return $ - return { ...$, name: newName } - })) - }, [value]) + const handleNameChange = useCallback( + (fileIdx: number) => (newName: string) => { + setValue( + value.map(($, i) => { + if (fileIdx !== i) return $ + return { ...$, name: newName } + }) + ) + }, + [value] + ) const handleSubmit = useCallback(() => { - onSubmit(value.map(($) => ({ - ...$, - thumbnail: screenshots[$.blob.name] - }))) + onSubmit( + value.map(($) => ({ + ...$, + thumbnail: screenshots[$.blob.name] + })) + ) }, [value, screenshots]) const manyAssets = useMemo(() => value.length > 1, [value]) @@ -59,10 +69,14 @@ export function Slider({ assets, onSubmit }: PropTypes) {
{manyAssets && {countText}}
- {manyAssets && } + {manyAssets && ( + + + + )}
{value.map(($, i) => ( -
+
@@ -71,9 +85,15 @@ export function Slider({ assets, onSubmit }: PropTypes) {
))}
- {manyAssets && } + {manyAssets && ( + + + + )}
- +
) } diff --git a/packages/@dcl/inspector/src/components/ImportAsset/types.ts b/packages/@dcl/inspector/src/components/ImportAsset/types.ts index 43e7a8a98..b9f6826da 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/types.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/types.ts @@ -1,19 +1,19 @@ export type BaseAsset = { - blob: File; - name: string; - extension: string; - error?: string; - thumbnail?: string; -}; + blob: File + name: string + extension: string + error?: string + thumbnail?: string +} export type GltfAsset = BaseAsset & { - buffers: BaseAsset[]; - images: BaseAsset[]; -}; + buffers: BaseAsset[] + images: BaseAsset[] +} -export type Asset = GltfAsset | BaseAsset; -export type Uri = { uri: string }; -export type GltfFile = { buffers: Uri[]; images: Uri[] }; +export type Asset = GltfAsset | BaseAsset +export type Uri = { uri: string } +export type GltfFile = { buffers: Uri[]; images: Uri[] } export type ValidationError = string | undefined /* diff --git a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts index 55f3ab895..c66b64113 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts @@ -1,6 +1,16 @@ import { GLTFValidation } from '@babylonjs/loaders' -import { BaseAsset, GltfAsset, BabylonValidationIssue, ValidationError, Asset, Uri, GltfFile, isGltfAsset, AssetType } from './types' +import { + BaseAsset, + GltfAsset, + BabylonValidationIssue, + ValidationError, + Asset, + Uri, + GltfFile, + isGltfAsset, + AssetType +} from './types' const sampleIndex = (list: any[]) => Math.floor(Math.random() * list.length) @@ -104,7 +114,11 @@ export const ACCEPTED_FILE_TYPES = { } const ONE_GB_IN_BYTES = 1024 * 1024 * 1024 -const VALID_EXTENSIONS = new Set(Object.values(ACCEPTED_FILE_TYPES).flat().map(($) => $.replaceAll('.', ''))) +const VALID_EXTENSIONS = new Set( + Object.values(ACCEPTED_FILE_TYPES) + .flat() + .map(($) => $.replaceAll('.', '')) +) const IGNORED_ERROR_CODES = ['ACCESSOR_WEIGHTS_NON_NORMALIZED', 'MESH_PRIMITIVE_TOO_FEW_TEXCOORDS'] async function validateGltf(gltf: GltfAsset): Promise { @@ -148,12 +162,12 @@ async function validateGltf(gltf: GltfAsset): Promise { // Utility functions function normalizeFileName(fileName: string): string { - return fileName.trim().replace(/\s+/g, "_").toLowerCase() + return fileName.trim().replace(/\s+/g, '_').toLowerCase() } function extractFileInfo(fileName: string): [string, string] { const match = fileName.match(/^(.*?)(?:\.([^.]+))?$/) - return match ? [match[1], match[2]?.toLowerCase() || ""] : [fileName, ""] + return match ? [match[1], match[2]?.toLowerCase() || ''] : [fileName, ''] } export function formatFileName(file: BaseAsset): string { @@ -165,9 +179,7 @@ function validateFileSize(size: number): ValidationError { } function validateExtension(extension: string): ValidationError { - return VALID_EXTENSIONS.has(extension) ? - undefined : - `Invalid asset format ".${extension}"` + return VALID_EXTENSIONS.has(extension) ? undefined : `Invalid asset format ".${extension}"` } async function processFile(file: File): Promise { @@ -208,10 +220,10 @@ function resolveDependencies(uris: Uri[], fileMap: Map): Base } async function processGltfAssets(files: BaseAsset[]): Promise { - const fileMap = new Map(files.map(file => [formatFileName(file), file])) + const fileMap = new Map(files.map((file) => [formatFileName(file), file])) const gltfPromises = files - .filter(file => file.extension === 'gltf') + .filter((file) => file.extension === 'gltf') .map(async (gltfFile): Promise => { const gltfContent = JSON.parse(await gltfFile.blob.text()) as GltfFile const buffers = resolveDependencies(gltfContent.buffers, fileMap) @@ -235,17 +247,17 @@ export async function processAssets(files: File[]): Promise { } export function normalizeBytes(bytes: number): string { - const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; - let value = bytes; - let unitIndex = 0; + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] + let value = bytes + let unitIndex = 0 while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex++; + value /= 1024 + unitIndex++ } - const roundedValue = Math.round(value * 100) / 100; - return `${roundedValue} ${units[unitIndex]}`; + const roundedValue = Math.round(value * 100) / 100 + return `${roundedValue} ${units[unitIndex]}` } export function getAssetSize(asset: Asset): string { @@ -270,38 +282,38 @@ export function assetsAreValid(assets: Asset[]): boolean { } export async function convertAssetToBinary(asset: Asset): Promise> { - const binaryContents: Map = new Map(); - const fullName = formatFileName(asset); - const binary = await asset.blob.arrayBuffer(); - binaryContents.set(fullName, new Uint8Array(binary)); + const binaryContents: Map = new Map() + const fullName = formatFileName(asset) + const binary = await asset.blob.arrayBuffer() + binaryContents.set(fullName, new Uint8Array(binary)) if (isGltfAsset(asset)) { - const resources = getAssetResources(asset); + const resources = getAssetResources(asset) for (const resource of resources) { - const resourceBinary = await resource.arrayBuffer(); - binaryContents.set(resource.name, new Uint8Array(resourceBinary)); + const resourceBinary = await resource.arrayBuffer() + binaryContents.set(resource.name, new Uint8Array(resourceBinary)) } } - return binaryContents; + return binaryContents } export function determineAssetType(asset: Asset): AssetType { switch (asset.extension) { - case 'gltf': - case 'glb': - return 'models' - case 'png': - case 'jpg': - case 'jpeg': - return 'images' - case 'mp3': - case 'wav': - case 'ogg': - return 'audio' - case 'mp4': - return 'video' - default: - return 'other' - } + case 'gltf': + case 'glb': + return 'models' + case 'png': + case 'jpg': + case 'jpeg': + return 'images' + case 'mp3': + case 'wav': + case 'ogg': + return 'audio' + case 'mp4': + return 'video' + default: + return 'other' + } } diff --git a/packages/@dcl/inspector/src/lib/data-layer/host/fs-utils.ts b/packages/@dcl/inspector/src/lib/data-layer/host/fs-utils.ts index f6b7f4522..5cb43fcdb 100644 --- a/packages/@dcl/inspector/src/lib/data-layer/host/fs-utils.ts +++ b/packages/@dcl/inspector/src/lib/data-layer/host/fs-utils.ts @@ -55,11 +55,6 @@ export function isFileInAssetDir(filePath: string = '') { return filePath.startsWith(DIRECTORY.ASSETS) } -export function getFileName(fileName: string, ext: string) { - if (EXTENSIONS.some(($) => fileName.endsWith($))) return fileName - return `${fileName}.${ext}` -} - export function getCurrentCompositePath() { return withAssetDir(`${DIRECTORY.SCENE}/main.composite`) } From 5e10db1ca383982112261ac955b92c519eb16dc8 Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Wed, 22 Jan 2025 16:58:56 -0300 Subject: [PATCH 09/16] add support for complex glb's --- .../components/ImportAsset/ImportAsset.tsx | 6 +- .../src/components/ImportAsset/types.ts | 9 +- .../src/components/ImportAsset/utils.ts | 145 ++++++++++++++---- 3 files changed, 119 insertions(+), 41 deletions(-) diff --git a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx index c7bd70eae..eb5adc773 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx @@ -6,7 +6,7 @@ import { HiOutlineUpload } from 'react-icons/hi' import { DIRECTORY, transformBase64ResourceToBinary, withAssetDir } from '../../lib/data-layer/host/fs-utils' import { importAsset, saveThumbnail } from '../../redux/data-layer' import { useAppDispatch, useAppSelector } from '../../redux/hooks' -import { selectAssetCatalog, selectUploadFile, updateUploadFile } from '../../redux/app' +import { selectUploadFile, updateUploadFile } from '../../redux/app' import FileInput from '../FileInput' import { Modal } from '../Modal' @@ -21,7 +21,7 @@ import { convertAssetToBinary, determineAssetType } from './utils' -import { Asset, isGltfAsset } from './types' +import { Asset, isModelAsset } from './types' import './ImportAsset.css' @@ -71,7 +71,7 @@ const ImportAsset = React.forwardRef>(({ for (const asset of assets) { const content = await convertAssetToBinary(asset) const classification = determineAssetType(asset) - const assetPackageName = isGltfAsset(asset) ? `${classification}/${asset.name}` : classification + const assetPackageName = isModelAsset(asset) ? `${classification}/${asset.name}` : classification dispatch( importAsset({ diff --git a/packages/@dcl/inspector/src/components/ImportAsset/types.ts b/packages/@dcl/inspector/src/components/ImportAsset/types.ts index b9f6826da..ebe6a60e5 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/types.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/types.ts @@ -6,13 +6,14 @@ export type BaseAsset = { thumbnail?: string } -export type GltfAsset = BaseAsset & { +export type ModelAsset = BaseAsset & { + gltf: Record buffers: BaseAsset[] images: BaseAsset[] } -export type Asset = GltfAsset | BaseAsset -export type Uri = { uri: string } +export type Asset = ModelAsset | BaseAsset +export type Uri = { uri: string } | { name: string } export type GltfFile = { buffers: Uri[]; images: Uri[] } export type ValidationError = string | undefined @@ -29,7 +30,7 @@ export type BabylonValidationIssue = { export type AssetType = 'models' | 'images' | 'audio' | 'video' | 'other' -export const isGltfAsset = (asset: Asset): asset is GltfAsset => { +export const isModelAsset = (asset: Asset): asset is ModelAsset => { const _asset = asset as any return _asset.buffers && _asset.images } diff --git a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts index c66b64113..4210c50fb 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts @@ -2,13 +2,13 @@ import { GLTFValidation } from '@babylonjs/loaders' import { BaseAsset, - GltfAsset, + ModelAsset, BabylonValidationIssue, ValidationError, Asset, Uri, GltfFile, - isGltfAsset, + isModelAsset, AssetType } from './types' @@ -104,7 +104,8 @@ export const adjectives = [ ] export const ACCEPTED_FILE_TYPES = { - 'model/gltf-binary': ['.gltf', '.glb', '.bin'], + 'model/gltf-binary': ['.gltf', '.glb'], + 'application/octet-stream': ['.bin'], 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg'], 'audio/mpeg': ['.mp3'], @@ -119,26 +120,33 @@ const VALID_EXTENSIONS = new Set( .flat() .map(($) => $.replaceAll('.', '')) ) -const IGNORED_ERROR_CODES = ['ACCESSOR_WEIGHTS_NON_NORMALIZED', 'MESH_PRIMITIVE_TOO_FEW_TEXCOORDS'] +const MODEL_EXTENSIONS = ACCEPTED_FILE_TYPES['model/gltf-binary'] +const IGNORED_ERROR_CODES = [ + 'ACCESSOR_WEIGHTS_NON_NORMALIZED', + 'MESH_PRIMITIVE_TOO_FEW_TEXCOORDS', + 'ACCESSOR_VECTOR3_NON_UNIT' +] -async function validateGltf(gltf: GltfAsset): Promise { - const pre = `Invalid GLTF "${gltf.blob.name}":` - const gltfContent = JSON.parse(await gltf.blob.text()) as GltfFile - const gltfBuffer = await gltf.blob.arrayBuffer() - const resourceMap = new Map([...gltf.buffers, ...gltf.images].map(($) => [$.blob.name, $.blob])) +async function validateModel(model: ModelAsset): Promise { + const pre = `Invalid GLTF "${model.blob.name}"` + const buffer = await model.blob.arrayBuffer() + const resourcesArr: [string, File][] = [...model.buffers, ...model.images].map(($) => [$.blob.name, $.blob]) + const resourceMap = new Map(resourcesArr) // ugly hack since "GLTFValidation.ValidateAsync" runs on a new Worker and throwed errors from // "getExternalResource" callback are thrown to main thread // In conclusion, checking for missing files inside "getExternalResource" is not possible... - for (const { uri } of [...gltfContent.buffers, ...gltfContent.images]) { - if (!resourceMap.has(uri)) { - throw new Error(`${pre}: resource "${uri}" is missing`) + for (const uri of [...model.gltf.buffers, ...model.gltf.images]) { + const _uri = getUri(uri) + if (_uri === model.name) continue // a model can include an entry with the same name as the model inside buffer resources... + if (!_uri || !resourceMap.has(_uri)) { + throw new Error(`${pre}: resource "${_uri}" is missing`) } } let result try { - result = await GLTFValidation.ValidateAsync(new Uint8Array(gltfBuffer), '', '', (uri) => { + result = await GLTFValidation.ValidateAsync(new Uint8Array(buffer), '', '', (uri) => { const resource = resourceMap.get(uri) return resource!.arrayBuffer() }) @@ -199,16 +207,22 @@ async function processFile(file: File): Promise { return { blob: file, name, extension } } -async function validateGltfWithDependencies(gltf: GltfAsset): Promise { +async function validateModelWithDependencies(model: ModelAsset): Promise { try { - await validateGltf(gltf) + await validateModel(model) } catch (error) { return error instanceof Error ? error.message : 'Unknown error during GLTF validation' } } -function resolveDependencies(uris: Uri[], fileMap: Map): BaseAsset[] { - return uris.reduce((acc, { uri }) => { +function getUri(uri: Uri): string | undefined { + return (uri as any).uri || (uri as any).name +} + +function resolveDependencies(items: Uri[], fileMap: Map): BaseAsset[] { + return items.reduce((acc, item) => { + const uri = getUri(item) + if (!uri) return acc const normalizedUri = normalizeFileName(uri) const file = fileMap.get(normalizedUri) if (file) { @@ -219,23 +233,86 @@ function resolveDependencies(uris: Uri[], fileMap: Map): Base }, []) } -async function processGltfAssets(files: BaseAsset[]): Promise { +async function getGlbMetadata(asset: BaseAsset): Promise { + const file = asset.blob + const decoder = new TextDecoder() + const reader = file.stream().getReader() + let gltf: GltfFile = { buffers: [], images: [] } + let buffer = '' + let jsonFound = false + let braceDepth = 0 + + const getEndIndex = (data: string): number => { + for (let i = 0; i < data.length; i++) { + if (data[i] === '{') braceDepth++ + else if (data[i] === '}') { + braceDepth-- + if (braceDepth === 0) { + return i + } + } + } + return -1 + } + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + let chunk = decoder.decode(value, { stream: true }) + + if (!jsonFound) { + const startIndex = chunk.indexOf('JSON{') + if (startIndex === -1) continue + jsonFound = true + buffer = chunk.slice(startIndex + 4) // +4 for "JSON" keyword + chunk = buffer // opmitimization for always reading from chunks when looking for "endIndex" instead of reading the whole buffer on every iteration + } else { + buffer += chunk + } + + const endIndex = getEndIndex(chunk) + if (endIndex !== -1) { + gltf = JSON.parse(buffer.slice(0, endIndex + 1)) + break + } + } + } catch (_) { + } finally { + reader.releaseLock() + return gltf + } +} + +async function getModelInfo(asset: BaseAsset): Promise { + switch (asset.extension) { + case 'gltf': + return JSON.parse(await asset.blob.text()) as GltfFile + case 'glb': + return getGlbMetadata(asset) + } + + return { buffers: [], images: [] } +} + +async function processModels(files: BaseAsset[]): Promise { const fileMap = new Map(files.map((file) => [formatFileName(file), file])) - const gltfPromises = files - .filter((file) => file.extension === 'gltf') - .map(async (gltfFile): Promise => { - const gltfContent = JSON.parse(await gltfFile.blob.text()) as GltfFile - const buffers = resolveDependencies(gltfContent.buffers, fileMap) - const images = resolveDependencies(gltfContent.images, fileMap) - const gltf: GltfAsset = { ...gltfFile, buffers, images } - const error = await validateGltfWithDependencies(gltf) - - fileMap.delete(formatFileName(gltfFile)) - return { ...gltf, error } + const modelPromises = files + .filter((asset) => MODEL_EXTENSIONS.includes(`.${asset.extension}`)) + .map(async (asset): Promise => { + const gltf = await getModelInfo(asset) + const buffers = resolveDependencies(gltf.buffers, fileMap) + const images = resolveDependencies(gltf.images, fileMap) + const model: ModelAsset = { ...asset, gltf, buffers, images } + const error = await validateModelWithDependencies(model) + + fileMap.delete(formatFileName(asset)) + return { ...model, error } }) - const gltfAssets = await Promise.all(gltfPromises) + const gltfAssets = await Promise.all(modelPromises) const remainingAssets = Array.from(fileMap.values()) return [...gltfAssets, ...remainingAssets] @@ -243,7 +320,7 @@ async function processGltfAssets(files: BaseAsset[]): Promise { export async function processAssets(files: File[]): Promise { const processedFiles = await Promise.all(files.map(processFile)) - return processGltfAssets(processedFiles) + return processModels(processedFiles) } export function normalizeBytes(bytes: number): string { @@ -267,13 +344,13 @@ export function getAssetSize(asset: Asset): string { } export function getAssetResources(asset: Asset): File[] { - if (!isGltfAsset(asset)) return [] + if (!isModelAsset(asset)) return [] return [...asset.buffers, ...asset.images].map(($) => $.blob) } export function assetIsValid(asset: Asset): boolean { if (!asset.error) return true - if (isGltfAsset(asset)) return !![...asset.buffers, ...asset.images].find(($) => assetIsValid($)) + if (isModelAsset(asset)) return !![...asset.buffers, ...asset.images].find(($) => assetIsValid($)) return false } @@ -287,7 +364,7 @@ export async function convertAssetToBinary(asset: Asset): Promise Date: Thu, 23 Jan 2025 12:51:04 -0300 Subject: [PATCH 10/16] add errors to import modal --- .../components/ImportAsset/Error/Error.css | 38 ++++++++++++++ .../components/ImportAsset/Error/Error.tsx | 51 +++++++++++++++++++ .../components/ImportAsset/Error/alert.svg | 10 ++++ .../src/components/ImportAsset/Error/index.ts | 1 + .../src/components/ImportAsset/Error/types.ts | 6 +++ .../components/ImportAsset/ImportAsset.tsx | 7 ++- .../components/ImportAsset/Slider/Slider.css | 2 +- .../src/components/ImportAsset/types.ts | 9 +++- .../src/components/ImportAsset/utils.ts | 33 +++++------- 9 files changed, 134 insertions(+), 23 deletions(-) create mode 100644 packages/@dcl/inspector/src/components/ImportAsset/Error/Error.css create mode 100644 packages/@dcl/inspector/src/components/ImportAsset/Error/Error.tsx create mode 100644 packages/@dcl/inspector/src/components/ImportAsset/Error/alert.svg create mode 100644 packages/@dcl/inspector/src/components/ImportAsset/Error/index.ts create mode 100644 packages/@dcl/inspector/src/components/ImportAsset/Error/types.ts diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Error/Error.css b/packages/@dcl/inspector/src/components/ImportAsset/Error/Error.css new file mode 100644 index 000000000..0d8c405b4 --- /dev/null +++ b/packages/@dcl/inspector/src/components/ImportAsset/Error/Error.css @@ -0,0 +1,38 @@ +.ImportError { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 60px; +} + +.ImportError .alert-icon { + background-image: url(./alert.svg); + background-size: 80px; + background-repeat: no-repeat; + background-position: center; + width: 80px; + height: 80px; +} + +.ImportError .errors { + display: flex; + flex-direction: column; + color: var(--base-09); + line-height: 26px; +} + +.ImportError .errors span { + display: flex; + align-items: center; +} + +.ImportError .errors .InfoTooltipTrigger { + display: inherit; + margin-left: 6px; + cursor: pointer; +} + +.ImportError > .Button { + margin-top: 60px; + padding: 12px 20px; +} diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Error/Error.tsx b/packages/@dcl/inspector/src/components/ImportAsset/Error/Error.tsx new file mode 100644 index 000000000..a2ad20337 --- /dev/null +++ b/packages/@dcl/inspector/src/components/ImportAsset/Error/Error.tsx @@ -0,0 +1,51 @@ +import { useCallback } from 'react' + +import { InfoTooltip } from '../../ui' +import { formatFileName } from '../utils' +import { Button } from '../../Button' + +import { PropTypes } from './types' + +import './Error.css' +import { ValidationError } from '../types' + +export function Error({ assets, onSubmit }: PropTypes) { + const getErrorMessage = useCallback((error: ValidationError): string => { + if (!error) return 'Unknown error' + + switch (error.type) { + case 'type': + return 'File type not supported' + case 'model': + return 'The model has some issues' + case 'size': + return 'File size is too large' + default: + return 'Unknown error' + } + }, []) + + return ( +
+
+

Asset failed to import

+
+ {assets.map(($, i) => ( + + ))} +
+ +
+ ) +} + +function ErrorMessage({ asset, message }: { asset: PropTypes['assets'][0]; message: string }) { + const errorMessage = asset.error?.message + return ( + + {formatFileName(asset)} - {message} {errorMessage && } + + ) +} diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Error/alert.svg b/packages/@dcl/inspector/src/components/ImportAsset/Error/alert.svg new file mode 100644 index 000000000..17113c232 --- /dev/null +++ b/packages/@dcl/inspector/src/components/ImportAsset/Error/alert.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Error/index.ts b/packages/@dcl/inspector/src/components/ImportAsset/Error/index.ts new file mode 100644 index 000000000..93c997216 --- /dev/null +++ b/packages/@dcl/inspector/src/components/ImportAsset/Error/index.ts @@ -0,0 +1 @@ +export { Error } from './Error' diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Error/types.ts b/packages/@dcl/inspector/src/components/ImportAsset/Error/types.ts new file mode 100644 index 000000000..80ca3e0f0 --- /dev/null +++ b/packages/@dcl/inspector/src/components/ImportAsset/Error/types.ts @@ -0,0 +1,6 @@ +import { Asset } from '../types' + +export type PropTypes = { + assets: Asset[] + onSubmit: () => void +} diff --git a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx index eb5adc773..4e5a5464c 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx @@ -24,6 +24,7 @@ import { import { Asset, isModelAsset } from './types' import './ImportAsset.css' +import { Error } from './Error' const ACCEPTED_FILE_TYPES_STR = Object.values(ACCEPTED_FILE_TYPES).flat().join('/').replaceAll('.', '').toUpperCase() @@ -142,7 +143,11 @@ const ImportAsset = React.forwardRef>(({ overlayClassName="ImportAssetModalOverlay" >

Import Assets

- + {assetsAreValid(files) ? ( + + ) : ( + + )}
diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css index 3aa2f8d02..1de88af42 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css @@ -12,7 +12,7 @@ .Slider .left, .Slider .right { - border: 1px solid var(--base-10);; + border: 1px solid var(--base-10); border-radius: 50%; display: flex; padding: 3px; diff --git a/packages/@dcl/inspector/src/components/ImportAsset/types.ts b/packages/@dcl/inspector/src/components/ImportAsset/types.ts index ebe6a60e5..e0563b7ef 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/types.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/types.ts @@ -2,7 +2,7 @@ export type BaseAsset = { blob: File name: string extension: string - error?: string + error?: ValidationError thumbnail?: string } @@ -16,7 +16,12 @@ export type Asset = ModelAsset | BaseAsset export type Uri = { uri: string } | { name: string } export type GltfFile = { buffers: Uri[]; images: Uri[] } -export type ValidationError = string | undefined +export type ValidationError = + | { + type: 'size' | 'type' | 'name' | 'model' + message: string + } + | undefined /* Severity codes are Error (0), Warning (1), Information (2), Hint (3). https://github.com/KhronosGroup/glTF-Validator/blob/main/lib/src/errors.dart diff --git a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts index 4210c50fb..4969a3957 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts @@ -128,7 +128,6 @@ const IGNORED_ERROR_CODES = [ ] async function validateModel(model: ModelAsset): Promise { - const pre = `Invalid GLTF "${model.blob.name}"` const buffer = await model.blob.arrayBuffer() const resourcesArr: [string, File][] = [...model.buffers, ...model.images].map(($) => [$.blob.name, $.blob]) const resourceMap = new Map(resourcesArr) @@ -140,19 +139,14 @@ async function validateModel(model: ModelAsset): Promise { const _uri = getUri(uri) if (_uri === model.name) continue // a model can include an entry with the same name as the model inside buffer resources... if (!_uri || !resourceMap.has(_uri)) { - throw new Error(`${pre}: resource "${_uri}" is missing`) + throw new Error(`Resource "${_uri}" is missing`) } } - let result - try { - result = await GLTFValidation.ValidateAsync(new Uint8Array(buffer), '', '', (uri) => { - const resource = resourceMap.get(uri) - return resource!.arrayBuffer() - }) - } catch (error) { - throw new Error(`${pre}: ${error}`) - } + const result = await GLTFValidation.ValidateAsync(new Uint8Array(buffer), '', '', (uri) => { + const resource = resourceMap.get(uri) + return resource!.arrayBuffer() + }) /* Babylon's type declarations incorrectly state that result.issues.messages @@ -164,7 +158,7 @@ async function validateModel(model: ModelAsset): Promise { if (errors.length > 0) { const error = errors[0] - throw new Error(`${pre}: ${error.message} \n Check ${error.pointer}`) + throw new Error(`${error.message} \n Check ${error.pointer}`) } } @@ -183,11 +177,11 @@ export function formatFileName(file: BaseAsset): string { } function validateFileSize(size: number): ValidationError { - return size > ONE_GB_IN_BYTES ? 'Files bigger than 1GB are not accepted' : undefined + return size <= ONE_GB_IN_BYTES ? undefined : { type: 'size', message: 'Files bigger than 1GB are not accepted' } } function validateExtension(extension: string): ValidationError { - return VALID_EXTENSIONS.has(extension) ? undefined : `Invalid asset format ".${extension}"` + return VALID_EXTENSIONS.has(extension) ? undefined : { type: 'type', message: `Invalid asset format ".${extension}"` } } async function processFile(file: File): Promise { @@ -211,7 +205,8 @@ async function validateModelWithDependencies(model: ModelAsset): Promise assetIsValid($)) - return false + if (!!asset.error) return false + if (isModelAsset(asset)) return [...asset.buffers, ...asset.images].every(($) => assetIsValid($)) + return true } export function assetsAreValid(assets: Asset[]): boolean { - return !!assets.find(($) => assetIsValid($)) + return assets.every(($) => assetIsValid($)) } export async function convertAssetToBinary(asset: Asset): Promise> { From 64baf6a7cb9196345ffc5685b51cdf03fab100ab Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Thu, 23 Jan 2025 15:31:41 -0300 Subject: [PATCH 11/16] add gltf-validator --- packages/@dcl/inspector/package-lock.json | 7 + packages/@dcl/inspector/package.json | 1 + .../src/components/ImportAsset/types.ts | 76 +++++++-- .../src/components/ImportAsset/utils.ts | 161 +++++------------- 4 files changed, 116 insertions(+), 129 deletions(-) diff --git a/packages/@dcl/inspector/package-lock.json b/packages/@dcl/inspector/package-lock.json index e2137cc04..914a783d4 100644 --- a/packages/@dcl/inspector/package-lock.json +++ b/packages/@dcl/inspector/package-lock.json @@ -41,6 +41,7 @@ "esbuild": "^0.18.17", "ethereum-cryptography": "^2.1.2", "fp-future": "^1.0.1", + "gltf-validator": "^2.0.0-dev.3.10", "hotkeys-js": "^3.13.5", "jest-environment-jsdom": "^29.5.0", "jest-puppeteer": "^9.0.0", @@ -3255,6 +3256,12 @@ "node": ">=0.10.0" } }, + "node_modules/gltf-validator": { + "version": "2.0.0-dev.3.10", + "resolved": "https://registry.npmjs.org/gltf-validator/-/gltf-validator-2.0.0-dev.3.10.tgz", + "integrity": "sha512-odJ4k0tRkGXiDGn78yDBg+fBbAIvBnXxh3RwAta0emSxGtyagFE8B4xELB1oYe3S5RD8Ci3uZAsZaascH2LAEQ==", + "dev": true + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", diff --git a/packages/@dcl/inspector/package.json b/packages/@dcl/inspector/package.json index 82d3b20d1..ceca8df63 100644 --- a/packages/@dcl/inspector/package.json +++ b/packages/@dcl/inspector/package.json @@ -35,6 +35,7 @@ "esbuild": "^0.18.17", "ethereum-cryptography": "^2.1.2", "fp-future": "^1.0.1", + "gltf-validator": "^2.0.0-dev.3.10", "hotkeys-js": "^3.13.5", "jest-environment-jsdom": "^29.5.0", "jest-puppeteer": "^9.0.0", diff --git a/packages/@dcl/inspector/src/components/ImportAsset/types.ts b/packages/@dcl/inspector/src/components/ImportAsset/types.ts index e0563b7ef..32ae0aad6 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/types.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/types.ts @@ -7,14 +7,12 @@ export type BaseAsset = { } export type ModelAsset = BaseAsset & { - gltf: Record + gltf: Gltf buffers: BaseAsset[] images: BaseAsset[] } export type Asset = ModelAsset | BaseAsset -export type Uri = { uri: string } | { name: string } -export type GltfFile = { buffers: Uri[]; images: Uri[] } export type ValidationError = | { @@ -22,20 +20,74 @@ export type ValidationError = message: string } | undefined + +export type AssetType = 'models' | 'images' | 'audio' | 'video' | 'other' + +export const isModelAsset = (asset: Asset): asset is ModelAsset => { + const _asset = asset as any + return _asset.buffers && _asset.images +} + +export interface Gltf { + mimeType: string + validatorVersion: string + validatedAt: Date + issues: GltfIssues + info: GltfInfo +} + +export interface GltfInfo { + version: string + generator: string + resources: GltfResource[] + animationCount: number + materialCount: number + hasMorphTargets: boolean + hasSkins: boolean + hasTextures: boolean + hasDefaultScene: boolean + drawCallCount: number + totalVertexCount: number + totalTriangleCount: number + maxUVs: number + maxInfluences: number + maxAttributes: number +} + +export interface GltfResource { + pointer: string + mimeType: string + storage: string + uri: string + byteLength?: number + image?: GltfImage +} + +export interface GltfImage { + width: number + height: number + format: string + primaries: string + transfer: string + bits: number +} + +export interface GltfIssues { + numErrors: number + numWarnings: number + numInfos: number + numHints: number + messages: GltfMessage[] + truncated: boolean +} + /* Severity codes are Error (0), Warning (1), Information (2), Hint (3). https://github.com/KhronosGroup/glTF-Validator/blob/main/lib/src/errors.dart */ -export type BabylonValidationIssue = { - severity: number +export interface GltfMessage { code: string message: string + severity: number pointer: string } - -export type AssetType = 'models' | 'images' | 'audio' | 'video' | 'other' - -export const isModelAsset = (asset: Asset): asset is ModelAsset => { - const _asset = asset as any - return _asset.buffers && _asset.images -} diff --git a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts index 4969a3957..8a13112c9 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts @@ -1,16 +1,7 @@ -import { GLTFValidation } from '@babylonjs/loaders' - -import { - BaseAsset, - ModelAsset, - BabylonValidationIssue, - ValidationError, - Asset, - Uri, - GltfFile, - isModelAsset, - AssetType -} from './types' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const validator = require('gltf-validator') + +import { BaseAsset, ModelAsset, ValidationError, Asset, isModelAsset, AssetType, Gltf } from './types' const sampleIndex = (list: any[]) => Math.floor(Math.random() * list.length) @@ -127,38 +118,30 @@ const IGNORED_ERROR_CODES = [ 'ACCESSOR_VECTOR3_NON_UNIT' ] -async function validateModel(model: ModelAsset): Promise { - const buffer = await model.blob.arrayBuffer() - const resourcesArr: [string, File][] = [...model.buffers, ...model.images].map(($) => [$.blob.name, $.blob]) - const resourceMap = new Map(resourcesArr) - - // ugly hack since "GLTFValidation.ValidateAsync" runs on a new Worker and throwed errors from - // "getExternalResource" callback are thrown to main thread - // In conclusion, checking for missing files inside "getExternalResource" is not possible... - for (const uri of [...model.gltf.buffers, ...model.gltf.images]) { - const _uri = getUri(uri) - if (_uri === model.name) continue // a model can include an entry with the same name as the model inside buffer resources... - if (!_uri || !resourceMap.has(_uri)) { - throw new Error(`Resource "${_uri}" is missing`) - } - } - - const result = await GLTFValidation.ValidateAsync(new Uint8Array(buffer), '', '', (uri) => { - const resource = resourceMap.get(uri) - return resource!.arrayBuffer() - }) +async function getGltf(file: File, getExternalResource: (uri: string) => Promise): Promise { + try { + const buffer = await file.arrayBuffer() + // const resourceMap = new Map([...model.buffers, ...model.images].map(($) => [$.blob.name, $.blob])) + const result: Gltf = await validator.validateBytes(new Uint8Array(buffer), { + ignoredIssues: IGNORED_ERROR_CODES, + externalResourceFunction: getExternalResource + }) - /* - Babylon's type declarations incorrectly state that result.issues.messages - is an Array. In fact, it's an array of objects with useful properties. - */ - const issues = result.issues.messages as unknown as BabylonValidationIssue[] + return result + } catch (e) { + const msg = `Unable to process model ${file.name}` + console.log(msg, e) + throw new Error(msg) + } +} +async function validateGltf(gltf: Gltf): Promise { + const issues = gltf.issues.messages const errors = issues.filter((issue) => issue.severity === 0 && !IGNORED_ERROR_CODES.includes(issue.code)) if (errors.length > 0) { const error = errors[0] - throw new Error(`${error.message} \n Check ${error.pointer}`) + throw new Error(error.message.replace('Node Exception: ', '').replace('Error: ', '')) } } @@ -203,92 +186,41 @@ async function processFile(file: File): Promise { async function validateModelWithDependencies(model: ModelAsset): Promise { try { - await validateModel(model) + await validateGltf(model.gltf) } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error during GLTF validation' return { type: 'model', message } } } -function getUri(uri: Uri): string | undefined { - return (uri as any).uri || (uri as any).name -} +async function getModel(asset: BaseAsset, fileMap: Map): Promise { + const gltf = await getGltf(asset.blob, async (uri: string) => { + const resource = fileMap.get(normalizeFileName(uri)) -function resolveDependencies(items: Uri[], fileMap: Map): BaseAsset[] { - return items.reduce((acc, item) => { - const uri = getUri(item) - if (!uri) return acc - const normalizedUri = normalizeFileName(uri) - const file = fileMap.get(normalizedUri) - if (file) { - acc.push(file) - fileMap.delete(normalizedUri) + if (!resource) { + throw new Error(`Resource "${uri}" is missing`) } - return acc - }, []) -} -async function getGlbMetadata(asset: BaseAsset): Promise { - const file = asset.blob - const decoder = new TextDecoder() - const reader = file.stream().getReader() - let gltf: GltfFile = { buffers: [], images: [] } - let buffer = '' - let jsonFound = false - let braceDepth = 0 - - const getEndIndex = (data: string): number => { - for (let i = 0; i < data.length; i++) { - if (data[i] === '{') braceDepth++ - else if (data[i] === '}') { - braceDepth-- - if (braceDepth === 0) { - return i - } - } - } - return -1 - } + const resourceBuffer = await resource.blob.arrayBuffer() + return new Uint8Array(resourceBuffer) + }) - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - let chunk = decoder.decode(value, { stream: true }) - - if (!jsonFound) { - const startIndex = chunk.indexOf('JSON{') - if (startIndex === -1) continue - jsonFound = true - buffer = chunk.slice(startIndex + 4) // +4 for "JSON" keyword - chunk = buffer // opmitimization for always reading from chunks when looking for "endIndex" instead of reading the whole buffer on every iteration - } else { - buffer += chunk - } - - const endIndex = getEndIndex(chunk) - if (endIndex !== -1) { - gltf = JSON.parse(buffer.slice(0, endIndex + 1)) - break - } + const buffers: BaseAsset[] = [] + const images: BaseAsset[] = [] + + for (const resource of gltf.info.resources || []) { + if (resource.storage === 'external') { + const normalizedName = normalizeFileName(resource.uri) + const uri = fileMap.get(normalizedName)! + if (resource.pointer.includes('buffer')) buffers.push(uri) + if (resource.pointer.includes('image')) images.push(uri) + fileMap.delete(normalizedName) } - } catch (_) { - } finally { - reader.releaseLock() - return gltf } -} -async function getModelInfo(asset: BaseAsset): Promise { - switch (asset.extension) { - case 'gltf': - return JSON.parse(await asset.blob.text()) as GltfFile - case 'glb': - return getGlbMetadata(asset) - } + fileMap.delete(formatFileName(asset)) - return { buffers: [], images: [] } + return { ...asset, gltf, buffers, images } } async function processModels(files: BaseAsset[]): Promise { @@ -297,13 +229,8 @@ async function processModels(files: BaseAsset[]): Promise { const modelPromises = files .filter((asset) => MODEL_EXTENSIONS.includes(`.${asset.extension}`)) .map(async (asset): Promise => { - const gltf = await getModelInfo(asset) - const buffers = resolveDependencies(gltf.buffers, fileMap) - const images = resolveDependencies(gltf.images, fileMap) - const model: ModelAsset = { ...asset, gltf, buffers, images } + const model = await getModel(asset, fileMap) const error = await validateModelWithDependencies(model) - - fileMap.delete(formatFileName(asset)) return { ...model, error } }) From 5e310a8303f95f50e8adebc4aa34db62af921d54 Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Fri, 24 Jan 2025 11:57:22 -0300 Subject: [PATCH 12/16] small fixes --- .../components/AssetPreview/AssetPreview.css | 8 +++++ .../components/AssetPreview/AssetPreview.tsx | 29 ++++++++++++------- .../components/ImportAsset/Slider/Slider.tsx | 3 +- .../src/components/ImportAsset/utils.ts | 2 +- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.css b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.css index edd0d7ad3..158ee7f4f 100644 --- a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.css +++ b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.css @@ -7,3 +7,11 @@ .AssetPreview canvas { width: 100%; } + +.AssetPreview .GltfPreview.hidden { + visibility: hidden; +} + +.AssetPreview > .loading { + position: absolute; +} diff --git a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx index 11718dc34..80659d203 100644 --- a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx +++ b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx @@ -1,10 +1,12 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import { PreviewCamera, PreviewProjection } from '@dcl/schemas' +import cx from 'classnames' import { WearablePreview } from 'decentraland-ui' import { AiFillSound } from 'react-icons/ai' import { IoVideocamOutline } from 'react-icons/io5' import { FaFile } from 'react-icons/fa' +import { Loading } from '../Loading' import { toWearableWithBlobs } from './utils' import { Props } from './types' @@ -40,22 +42,29 @@ export function AssetPreview({ value, resources, onScreenshot, onLoad }: Props) } function GltfPreview({ value, resources, onScreenshot, onLoad }: Props) { + const [loading, setLoading] = useState(true) const handleLoad = useCallback(() => { onLoad?.() const wp = WearablePreview.createController(value.name) void wp.scene.getScreenshot(WIDTH, HEIGHT).then(($) => onScreenshot($)) + setTimeout(() => setLoading(false), 1000) // ugly hack to avoid iframe flickering... }, [onLoad]) return ( - + <> +
+ +
+ {loading && } + ) } diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx index 859dd0b71..25ea0540e 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx @@ -62,6 +62,7 @@ export function Slider({ assets, onSubmit }: PropTypes) { const importText = useMemo(() => `IMPORT${manyAssets ? ' ALL' : ''}`, [manyAssets]) const leftArrowDisabled = useMemo(() => slide <= 0, [slide]) const rightArrowDisabled = useMemo(() => slide >= value.length - 1, [slide, value]) + const allScreenshotsTaken = useMemo(() => value.length === Object.keys(screenshots).length, [value, screenshots]) if (!value.length) return null @@ -91,7 +92,7 @@ export function Slider({ assets, onSubmit }: PropTypes) { )}
-
diff --git a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts index 8a13112c9..5c88b2661 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts @@ -160,7 +160,7 @@ export function formatFileName(file: BaseAsset): string { } function validateFileSize(size: number): ValidationError { - return size <= ONE_GB_IN_BYTES ? undefined : { type: 'size', message: 'Files bigger than 1GB are not accepted' } + return size <= ONE_GB_IN_BYTES ? undefined : { type: 'size', message: 'Max file size: 1 GB' } } function validateExtension(extension: string): ValidationError { From a09b31dbf3c07258e7a9905748e1df8ae0e9b35d Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Fri, 24 Jan 2025 13:05:07 -0300 Subject: [PATCH 13/16] add proper icons on asset explorer --- .../components/AssetPreview/AssetPreview.tsx | 10 ++++-- .../components/ImportAsset/ImportAsset.tsx | 2 +- .../components/ImportAsset/Slider/Slider.tsx | 10 ++++-- .../src/components/ImportAsset/types.ts | 2 +- .../src/components/ImportAsset/utils.ts | 22 ++++++------ .../ProjectAssetExplorer/ProjectView.tsx | 36 ++++++++++++++----- .../ProjectAssetExplorer/Tile/Tile.tsx | 21 +++++++++-- 7 files changed, 73 insertions(+), 30 deletions(-) diff --git a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx index 80659d203..0544be5e3 100644 --- a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx +++ b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx @@ -46,13 +46,17 @@ function GltfPreview({ value, resources, onScreenshot, onLoad }: Props) { const handleLoad = useCallback(() => { onLoad?.() const wp = WearablePreview.createController(value.name) - void wp.scene.getScreenshot(WIDTH, HEIGHT).then(($) => onScreenshot($)) - setTimeout(() => setLoading(false), 1000) // ugly hack to avoid iframe flickering... + void wp.scene.getScreenshot(WIDTH, HEIGHT).then(($) => { + setTimeout(() => { + onScreenshot($) + setLoading(false) + }, 1000) // ugly hack to avoid iframe flickering... + }) }, [onLoad]) return ( <> -
+
>(({ // times. This can be improved by doing all the process once... for (const asset of assets) { const content = await convertAssetToBinary(asset) - const classification = determineAssetType(asset) + const classification = determineAssetType(asset.extension) const assetPackageName = isModelAsset(asset) ? `${classification}/${asset.name}` : classification dispatch( diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx index 25ea0540e..de04a8601 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx @@ -6,7 +6,7 @@ import { AssetPreview } from '../../AssetPreview' import { Button } from '../../Button' import { Input } from '../../Input' -import { getAssetSize, getAssetResources } from '../utils' +import { getAssetSize, getAssetResources, determineAssetType } from '../utils' import { Asset } from '../types' import { PropTypes, Thumbnails } from './types' @@ -62,7 +62,13 @@ export function Slider({ assets, onSubmit }: PropTypes) { const importText = useMemo(() => `IMPORT${manyAssets ? ' ALL' : ''}`, [manyAssets]) const leftArrowDisabled = useMemo(() => slide <= 0, [slide]) const rightArrowDisabled = useMemo(() => slide >= value.length - 1, [slide, value]) - const allScreenshotsTaken = useMemo(() => value.length === Object.keys(screenshots).length, [value, screenshots]) + const allScreenshotsTaken = useMemo(() => { + const neededScreenshots = value.filter(($) => { + const type = determineAssetType($.extension) + return type === '3D Model' || type === 'Image' + }) + return neededScreenshots.length === Object.keys(screenshots).length + }, [value, screenshots]) if (!value.length) return null diff --git a/packages/@dcl/inspector/src/components/ImportAsset/types.ts b/packages/@dcl/inspector/src/components/ImportAsset/types.ts index 32ae0aad6..235bdbd88 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/types.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/types.ts @@ -21,7 +21,7 @@ export type ValidationError = } | undefined -export type AssetType = 'models' | 'images' | 'audio' | 'video' | 'other' +export type AssetType = '3D Model' | 'Image' | 'Audio' | 'Video' | 'Other' export const isModelAsset = (asset: Asset): asset is ModelAsset => { const _asset = asset as any diff --git a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts index 5c88b2661..0d3a7229c 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line @typescript-eslint/no-var-requires const validator = require('gltf-validator') -import { BaseAsset, ModelAsset, ValidationError, Asset, isModelAsset, AssetType, Gltf } from './types' +import { BaseAsset, ModelAsset, ValidationError, Asset, isModelAsset, Gltf, AssetType } from './types' const sampleIndex = (list: any[]) => Math.floor(Math.random() * list.length) @@ -146,11 +146,11 @@ async function validateGltf(gltf: Gltf): Promise { } // Utility functions -function normalizeFileName(fileName: string): string { +export function normalizeFileName(fileName: string): string { return fileName.trim().replace(/\s+/g, '_').toLowerCase() } -function extractFileInfo(fileName: string): [string, string] { +export function extractFileExtension(fileName: string): [string, string] { const match = fileName.match(/^(.*?)(?:\.([^.]+))?$/) return match ? [match[1], match[2]?.toLowerCase() || ''] : [fileName, ''] } @@ -169,7 +169,7 @@ function validateExtension(extension: string): ValidationError { async function processFile(file: File): Promise { const normalizedName = normalizeFileName(file.name) - const [name, extension] = extractFileInfo(normalizedName) + const [name, extension] = extractFileExtension(normalizedName) const extensionError = validateExtension(extension) if (extensionError) { @@ -297,22 +297,22 @@ export async function convertAssetToBinary(asset: Asset): Promise { - const [name, extension] = value.split('.') + const [name] = value.split('.') const thumbnail = thumbnails.find(($) => $.path.endsWith(name + '.png')) - if (thumbnail) { - return thumbnail?.content - } else if (extension === 'png') { - } + if (thumbnail) return thumbnail.content }, [thumbnails] ) @@ -279,13 +280,30 @@ function NodeIcon({ value }: { value?: TreeNode }) {
) - } else + } else { + const Icon = useMemo(() => { + const classification = determineAssetType(extractFileExtension(value.name)[1]) + switch (classification) { + case '3D Model': + return ModelIcon + case 'Image': + return ImageIcon + case 'Audio': + return AudioIcon + case 'Video': + return VideoIcon + case 'Other': + return OtherIcon + } + }, []) + return ( <> - + ) + } } export default React.memo(ProjectView) diff --git a/packages/@dcl/inspector/src/components/ProjectAssetExplorer/Tile/Tile.tsx b/packages/@dcl/inspector/src/components/ProjectAssetExplorer/Tile/Tile.tsx index 198718206..e842263b6 100644 --- a/packages/@dcl/inspector/src/components/ProjectAssetExplorer/Tile/Tile.tsx +++ b/packages/@dcl/inspector/src/components/ProjectAssetExplorer/Tile/Tile.tsx @@ -1,6 +1,8 @@ import { useCallback, useMemo } from 'react' -import { AiFillDelete as DeleteIcon } from 'react-icons/ai' -import { IoIosImage } from 'react-icons/io' +import { AiFillDelete as DeleteIcon, AiOutlineSound as AudioIcon } from 'react-icons/ai' +import { IoIosImage as ImageIcon } from 'react-icons/io' +import { IoCubeOutline as ModelIcon, IoVideocamOutline as VideoIcon } from 'react-icons/io5' +import { FaFile as OtherIcon } from 'react-icons/fa' import { Item as MenuItem } from 'react-contexify' import { useDrag } from 'react-dnd' import { Loader } from 'decentraland-ui/dist/components/Loader/Loader' @@ -15,6 +17,7 @@ import { useContextMenu } from '../../../hooks/sdk/useContextMenu' import { Props } from './types' import './Tile.css' +import { determineAssetType, extractFileExtension } from '../../ImportAsset/utils' export const Tile = withContextMenu( ({ valueId, value, getDragContext, onSelect, onRemove, contextMenuId, dndType, getThumbnail }) => { @@ -38,7 +41,19 @@ export const Tile = withContextMenu( if (value.type === 'folder') return const thumbnail = getThumbnail(value.name) if (thumbnail) return {value.name} - return + const classification = determineAssetType(extractFileExtension(value.name)[1]) + switch (classification) { + case '3D Model': + return + case 'Image': + return + case 'Audio': + return + case 'Video': + return + case 'Other': + return + } }, []) const renderOverlayLoading = useCallback(() => { From 2da31407d8b755d90263e1b529afd52703be5f18 Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Mon, 27 Jan 2025 11:52:09 -0300 Subject: [PATCH 14/16] add renaming assets before import --- .../components/ImportAsset/ImportAsset.tsx | 37 ++++++++++--------- .../components/ImportAsset/Slider/Slider.css | 5 +++ .../components/ImportAsset/Slider/Slider.tsx | 31 ++++++++++++++-- .../components/ImportAsset/Slider/types.ts | 3 +- .../src/components/ImportAsset/utils.ts | 8 +++- .../inspector/src/components/Input/Input.tsx | 3 +- .../inspector/src/components/Input/types.ts | 1 + 7 files changed, 63 insertions(+), 25 deletions(-) diff --git a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx index eadf324b7..eb00b35cc 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.tsx @@ -2,11 +2,11 @@ import React, { PropsWithChildren, useCallback, useEffect, useState } from 'reac import cx from 'classnames' import { HiOutlineUpload } from 'react-icons/hi' -// import { removeBasePath } from '../../lib/logic/remove-base-path' +import { removeBasePath } from '../../lib/logic/remove-base-path' import { DIRECTORY, transformBase64ResourceToBinary, withAssetDir } from '../../lib/data-layer/host/fs-utils' import { importAsset, saveThumbnail } from '../../redux/data-layer' import { useAppDispatch, useAppSelector } from '../../redux/hooks' -import { selectUploadFile, updateUploadFile } from '../../redux/app' +import { selectAssetCatalog, selectUploadFile, updateUploadFile } from '../../redux/app' import FileInput from '../FileInput' import { Modal } from '../Modal' @@ -19,9 +19,9 @@ import { ACCEPTED_FILE_TYPES, formatFileName, convertAssetToBinary, - determineAssetType + buildAssetPath } from './utils' -import { Asset, isModelAsset } from './types' +import { Asset } from './types' import './ImportAsset.css' import { Error } from './Error' @@ -34,13 +34,13 @@ interface PropTypes { const ImportAsset = React.forwardRef>(({ onSave, children }, inputRef) => { const dispatch = useAppDispatch() - // const catalog = useAppSelector(selectAssetCatalog) + const catalog = useAppSelector(selectAssetCatalog) const uploadFile = useAppSelector(selectUploadFile) const [files, setFiles] = useState([]) const [isHover, setIsHover] = useState(false) - // const { basePath, assets } = catalog ?? { basePath: '', assets: [] } + const { basePath, assets } = catalog ?? { basePath: '', assets: [] } useEffect(() => { const isValidFile = uploadFile && 'name' in uploadFile @@ -71,8 +71,7 @@ const ImportAsset = React.forwardRef>(({ // times. This can be improved by doing all the process once... for (const asset of assets) { const content = await convertAssetToBinary(asset) - const classification = determineAssetType(asset.extension) - const assetPackageName = isModelAsset(asset) ? `${classification}/${asset.name}` : classification + const assetPackageName = buildAssetPath(asset) dispatch( importAsset({ @@ -106,15 +105,17 @@ const ImportAsset = React.forwardRef>(({ [uploadFile] ) - // const isNameUnique = useCallback((name: string, ext: string) => { - // return !assets.find((asset) => { - // const [packageName, otherAssetName] = removeBasePath(basePath, asset.path).split('/') - // if (packageName === 'builder') return false - // return otherAssetName?.toLocaleLowerCase() === name?.toLocaleLowerCase() + '.' + ext - // }) - // }, []) - - // const isNameRepeated = !isNameUnique(assetName, assetExtension) + const validateName = useCallback( + (asset: Asset, fileName: string) => { + return !assets.find(($) => { + const [packageName, ...otherAssetName] = removeBasePath(basePath, $.path).split('/') + if (packageName === 'builder') return false + const assetPath = buildAssetPath(asset) + return otherAssetName.join('/') === `${assetPath}/${fileName}` + }) + }, + [assets] + ) return (
@@ -144,7 +145,7 @@ const ImportAsset = React.forwardRef>(({ >

Import Assets

{assetsAreValid(files) ? ( - + ) : ( )} diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css index 1de88af42..e64108647 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css @@ -54,6 +54,11 @@ height: 60%; } +.Slider .asset .name-error { + font-size: 12px; + color: var(--primary-main) +} + .Slider .asset .size { text-align: center; font-size: 12px; diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx index de04a8601..b9bcf2def 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx @@ -6,14 +6,14 @@ import { AssetPreview } from '../../AssetPreview' import { Button } from '../../Button' import { Input } from '../../Input' -import { getAssetSize, getAssetResources, determineAssetType } from '../utils' +import { getAssetSize, getAssetResources, determineAssetType, formatFileName } from '../utils' import { Asset } from '../types' import { PropTypes, Thumbnails } from './types' import './Slider.css' -export function Slider({ assets, onSubmit }: PropTypes) { +export function Slider({ assets, onSubmit, isNameValid }: PropTypes) { const [value, setValue] = useState(assets) const [slide, setSlide] = useState(0) const [screenshots, setScreenshots] = useState({}) @@ -70,6 +70,30 @@ export function Slider({ assets, onSubmit }: PropTypes) { return neededScreenshots.length === Object.keys(screenshots).length }, [value, screenshots]) + const invalidNames = useMemo(() => { + const all = new Set() + const invalid = new Set() + + for (const asset of value) { + const name = formatFileName(asset) + if (all.has(name) || !isNameValid(asset, name)) { + invalid.add(name) + } else { + all.add(name) + } + } + + return invalid + }, [value]) + + const isNameUnique = useCallback( + (asset: Asset) => { + const name = formatFileName(asset) + return !invalidNames.has(name) + }, + [invalidNames] + ) + if (!value.length) return null return ( @@ -87,6 +111,7 @@ export function Slider({ assets, onSubmit }: PropTypes) {
+ {!isNameUnique($) && Filename already exists}
{getAssetSize($)}
@@ -98,7 +123,7 @@ export function Slider({ assets, onSubmit }: PropTypes) { )}
-
diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/types.ts b/packages/@dcl/inspector/src/components/ImportAsset/Slider/types.ts index dab841206..93241db10 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/Slider/types.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/types.ts @@ -2,7 +2,8 @@ import { Asset } from '../types' export type PropTypes = { assets: Asset[] - onSubmit: (assets: Asset[]) => void + onSubmit(assets: Asset[]): void + isNameValid(asset: Asset, newName: string): boolean } export type Thumbnails = Record diff --git a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts index 0d3a7229c..47a80e9be 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts @@ -121,7 +121,6 @@ const IGNORED_ERROR_CODES = [ async function getGltf(file: File, getExternalResource: (uri: string) => Promise): Promise { try { const buffer = await file.arrayBuffer() - // const resourceMap = new Map([...model.buffers, ...model.images].map(($) => [$.blob.name, $.blob])) const result: Gltf = await validator.validateBytes(new Uint8Array(buffer), { ignoredIssues: IGNORED_ERROR_CODES, externalResourceFunction: getExternalResource @@ -145,7 +144,6 @@ async function validateGltf(gltf: Gltf): Promise { } } -// Utility functions export function normalizeFileName(fileName: string): string { return fileName.trim().replace(/\s+/g, '_').toLowerCase() } @@ -316,3 +314,9 @@ export function determineAssetType(extension: string): AssetType { return 'Other' } } + +export function buildAssetPath(asset: Asset): string { + const classification = determineAssetType(asset.extension) + const assetPath = isModelAsset(asset) ? `${classification}/${asset.name}` : classification + return assetPath +} diff --git a/packages/@dcl/inspector/src/components/Input/Input.tsx b/packages/@dcl/inspector/src/components/Input/Input.tsx index d00b988de..5a8f00092 100644 --- a/packages/@dcl/inspector/src/components/Input/Input.tsx +++ b/packages/@dcl/inspector/src/components/Input/Input.tsx @@ -17,7 +17,7 @@ const getRolesFromTarget = (target: HTMLElement | null): Roles => { } } -const Input = ({ value, onCancel, onSubmit, onChange, onBlur, placeholder }: PropTypes) => { +const Input = ({ value, onCancel, onSubmit, onChange, onBlur, placeholder, disabled }: PropTypes) => { const ref = useRef(null) const [stateValue, setStateValue] = useState(value) @@ -71,6 +71,7 @@ const Input = ({ value, onCancel, onSubmit, onChange, onBlur, placeholder }: Pro placeholder={placeholder} value={stateValue} onChange={handleTextChange} + disabled={disabled} /> ) } diff --git a/packages/@dcl/inspector/src/components/Input/types.ts b/packages/@dcl/inspector/src/components/Input/types.ts index 9655dfc69..1fdcf25a8 100644 --- a/packages/@dcl/inspector/src/components/Input/types.ts +++ b/packages/@dcl/inspector/src/components/Input/types.ts @@ -1,6 +1,7 @@ export interface PropTypes { value: string placeholder?: string + disabled?: boolean onChange?: (value: string) => void onCancel?: () => void onSubmit?: (newValue: string) => void From 7745fb4a96c6c147e1e1269f614821ac17f8aa63 Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Mon, 27 Jan 2025 15:49:34 -0300 Subject: [PATCH 15/16] rename AssetType --- .../inspector/src/components/ImportAsset/Slider/Slider.tsx | 2 +- packages/@dcl/inspector/src/components/ImportAsset/types.ts | 2 +- packages/@dcl/inspector/src/components/ImportAsset/utils.ts | 4 ++-- .../src/components/ProjectAssetExplorer/ProjectView.tsx | 4 ++-- .../src/components/ProjectAssetExplorer/Tile/Tile.tsx | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx index b9bcf2def..11188c4d3 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx @@ -65,7 +65,7 @@ export function Slider({ assets, onSubmit, isNameValid }: PropTypes) { const allScreenshotsTaken = useMemo(() => { const neededScreenshots = value.filter(($) => { const type = determineAssetType($.extension) - return type === '3D Model' || type === 'Image' + return type === 'Models' || type === 'Images' }) return neededScreenshots.length === Object.keys(screenshots).length }, [value, screenshots]) diff --git a/packages/@dcl/inspector/src/components/ImportAsset/types.ts b/packages/@dcl/inspector/src/components/ImportAsset/types.ts index 235bdbd88..b267e9928 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/types.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/types.ts @@ -21,7 +21,7 @@ export type ValidationError = } | undefined -export type AssetType = '3D Model' | 'Image' | 'Audio' | 'Video' | 'Other' +export type AssetType = 'Models' | 'Images' | 'Audio' | 'Video' | 'Other' export const isModelAsset = (asset: Asset): asset is ModelAsset => { const _asset = asset as any diff --git a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts index 47a80e9be..0ffc560d1 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/utils.ts +++ b/packages/@dcl/inspector/src/components/ImportAsset/utils.ts @@ -299,11 +299,11 @@ export function determineAssetType(extension: string): AssetType { switch (extension) { case 'gltf': case 'glb': - return '3D Model' + return 'Models' case 'png': case 'jpg': case 'jpeg': - return 'Image' + return 'Images' case 'mp3': case 'wav': case 'ogg': diff --git a/packages/@dcl/inspector/src/components/ProjectAssetExplorer/ProjectView.tsx b/packages/@dcl/inspector/src/components/ProjectAssetExplorer/ProjectView.tsx index 946a6b7f3..871da38c1 100644 --- a/packages/@dcl/inspector/src/components/ProjectAssetExplorer/ProjectView.tsx +++ b/packages/@dcl/inspector/src/components/ProjectAssetExplorer/ProjectView.tsx @@ -284,9 +284,9 @@ function NodeIcon({ value }: { value?: TreeNode }) { const Icon = useMemo(() => { const classification = determineAssetType(extractFileExtension(value.name)[1]) switch (classification) { - case '3D Model': + case 'Models': return ModelIcon - case 'Image': + case 'Images': return ImageIcon case 'Audio': return AudioIcon diff --git a/packages/@dcl/inspector/src/components/ProjectAssetExplorer/Tile/Tile.tsx b/packages/@dcl/inspector/src/components/ProjectAssetExplorer/Tile/Tile.tsx index e842263b6..29dd3891f 100644 --- a/packages/@dcl/inspector/src/components/ProjectAssetExplorer/Tile/Tile.tsx +++ b/packages/@dcl/inspector/src/components/ProjectAssetExplorer/Tile/Tile.tsx @@ -43,9 +43,9 @@ export const Tile = withContextMenu( if (thumbnail) return {value.name} const classification = determineAssetType(extractFileExtension(value.name)[1]) switch (classification) { - case '3D Model': + case 'Models': return - case 'Image': + case 'Images': return case 'Audio': return From 4d55ade1ca9b4ac675d2524bc85318a9541ed579 Mon Sep 17 00:00:00 2001 From: Nicolas Echezarreta Date: Tue, 28 Jan 2025 11:02:54 -0300 Subject: [PATCH 16/16] fix comments --- .../components/AssetPreview/AssetPreview.css | 4 ---- .../components/AssetPreview/AssetPreview.tsx | 4 +--- .../src/components/Assets/Assets.css | 2 +- .../CreateCustomAsset/CreateCustomAsset.css | 7 +++++++ .../CreateCustomAsset/CreateCustomAsset.tsx | 4 +++- .../components/ImportAsset/ImportAsset.css | 4 ++++ .../components/ImportAsset/Slider/Slider.css | 20 ++++++++++++------- .../components/ImportAsset/Slider/Slider.tsx | 6 ++++++ 8 files changed, 35 insertions(+), 16 deletions(-) diff --git a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.css b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.css index 158ee7f4f..a482cfe75 100644 --- a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.css +++ b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.css @@ -11,7 +11,3 @@ .AssetPreview .GltfPreview.hidden { visibility: hidden; } - -.AssetPreview > .loading { - position: absolute; -} diff --git a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx index 0544be5e3..82c5090b3 100644 --- a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx +++ b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx @@ -6,7 +6,6 @@ import { AiFillSound } from 'react-icons/ai' import { IoVideocamOutline } from 'react-icons/io5' import { FaFile } from 'react-icons/fa' -import { Loading } from '../Loading' import { toWearableWithBlobs } from './utils' import { Props } from './types' @@ -61,13 +60,12 @@ function GltfPreview({ value, resources, onScreenshot, onLoad }: Props) { id={value.name} blob={toWearableWithBlobs(value, resources)} disableAutoRotate - background="#3c3c3c" + disableBackground projection={PreviewProjection.ORTHOGRAPHIC} camera={PreviewCamera.STATIC} onLoad={handleLoad} />
- {loading && } ) } diff --git a/packages/@dcl/inspector/src/components/Assets/Assets.css b/packages/@dcl/inspector/src/components/Assets/Assets.css index ede2489ee..55143dda3 100644 --- a/packages/@dcl/inspector/src/components/Assets/Assets.css +++ b/packages/@dcl/inspector/src/components/Assets/Assets.css @@ -17,7 +17,7 @@ padding-left: 8px; display: flex; background: var(--tree-bg-color); - height: 60px; + padding: 5px 0; position: relative; align-items: center; justify-content: center; diff --git a/packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.css b/packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.css index 193b1d259..9c8822ec3 100644 --- a/packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.css +++ b/packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.css @@ -32,8 +32,15 @@ } .CreateCustomAsset .preview-container .AssetPreview { + display: none; +} + +.CreateCustomAsset .preview-container .thumbnail { width: 100%; height: 100%; + background-color: var(--background-gray); + background-size: cover; + background-position: center; } .CreateCustomAsset .loader-container { diff --git a/packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.tsx b/packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.tsx index 2f4762433..b2509dfb6 100644 --- a/packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.tsx +++ b/packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.tsx @@ -112,10 +112,12 @@ const CreateCustomAsset: React.FC = () => {
{previewFile && resources !== null ? (
- {isGeneratingThumbnail && ( + {isGeneratingThumbnail && !thumbnail ? (
+ ) : ( +
)}
diff --git a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.css b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.css index 8526a0efb..dadb345e9 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.css +++ b/packages/@dcl/inspector/src/components/ImportAsset/ImportAsset.css @@ -1,3 +1,7 @@ +.ImportAssetModalOverlay { + z-index: 2; +} + .ImportAsset { height: 100%; } diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css index e64108647..01f542652 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.css @@ -38,20 +38,26 @@ .Slider .asset > div { display: flex; flex-direction: column; - margin: 10px 10px 5px 10px; + margin: 10px; width: 150px; } -.Slider .asset > div .AssetPreview { +.Slider .asset .loading { + position: relative; +} + +.Slider .asset .AssetPreview { + display: none; +} + +.Slider .asset .thumbnail, +.Slider .asset .loading { margin-bottom: 12px; width: 150px; height: 150px; background-color: var(--background-gray); -} - -.Slider .asset > div .AssetPreview > svg { - width: 60%; - height: 60%; + background-size: cover; + background-position: center; } .Slider .asset .name-error { diff --git a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx index 11188c4d3..f62a27375 100644 --- a/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx +++ b/packages/@dcl/inspector/src/components/ImportAsset/Slider/Slider.tsx @@ -5,6 +5,7 @@ import cx from 'classnames' import { AssetPreview } from '../../AssetPreview' import { Button } from '../../Button' import { Input } from '../../Input' +import { Loading } from '../../Loading' import { getAssetSize, getAssetResources, determineAssetType, formatFileName } from '../utils' @@ -110,6 +111,11 @@ export function Slider({ assets, onSubmit, isNameValid }: PropTypes) {
+ {screenshots[$.blob.name] ? ( +
+ ) : ( + + )} {!isNameUnique($) && Filename already exists}