import { DataTexture, EquirectangularReflectionMapping, LinearSRGBColorSpace, SRGBColorSpace, Texture } from "three"; import { EXRLoader } from "three/examples/jsm/loaders/EXRLoader.js"; import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js"; import { createOpaqueId } from "../core/ids"; import { getStarterEnvironmentPreviewUrl, getStarterEnvironmentTextureUrl, isStarterEnvironmentImageAsset } from "./starter-environment-assets"; import { createProjectAssetStorageKey, type ImageAssetMetadata, type ImageAssetRecord } from "./project-assets"; import { type ProjectAssetStorage, type ProjectAssetStorageFileRecord, type ProjectAssetStoragePackageRecord } from "./project-asset-storage"; export interface LoadedImageAsset { assetId: string; storageKey: string; metadata: ImageAssetMetadata; texture: Texture; sourceUrl: string; previewUrl: string; revokeSourceUrl: () => void; } export interface ImportedImageAssetResult { asset: ImageAssetRecord; loadedAsset: LoadedImageAsset; } function getErrorDetail(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message.trim(); } return "Unknown error."; } function getFileExtension(sourceName: string): string { const match = /\.([^.]+)$/u.exec(sourceName.trim()); return match === null ? "" : match[1].toLowerCase(); } function inferImageMimeType(sourceName: string, fallbackMimeType: string): string { if (fallbackMimeType.trim().startsWith("image/")) { return fallbackMimeType.trim(); } switch (getFileExtension(sourceName)) { case "avif": return "image/avif"; case "exr": return "image/x-exr"; case "gif": return "image/gif"; case "hdr": return "image/vnd.radiance"; case "jpg": case "jpeg": return "image/jpeg"; case "png": return "image/png"; case "svg": return "image/svg+xml"; case "webp": return "image/webp"; default: throw new Error(`Unsupported image asset format for ${sourceName}. Use a browser-supported image file or .exr/.hdr.`); } } function isHdrFormat(sourceName: string): boolean { const ext = getFileExtension(sourceName); return ext === "exr" || ext === "hdr"; } function getImportedFilePath(file: File): string { const relativePath = typeof file.webkitRelativePath === "string" ? file.webkitRelativePath.trim() : ""; const sourcePath = relativePath.length > 0 ? relativePath : file.name.trim(); return sourcePath.replace(/\\/gu, "/"); } function createDataUrlForStoredFile(file: ProjectAssetStorageFileRecord): string { const byteArray = new Uint8Array(file.bytes); let binary = ""; const chunkSize = 0x8000; for (let index = 0; index < byteArray.length; index += chunkSize) { binary += String.fromCharCode(...byteArray.subarray(index, index + chunkSize)); } const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(file.bytes).toString("base64"); return `data:${file.mimeType};base64,${base64}`; } function createTransientResourceUrl(file: ProjectAssetStorageFileRecord): { revoke: () => void; url: string } { if (typeof URL.createObjectURL === "function" && typeof Blob !== "undefined") { const objectUrl = URL.createObjectURL(new Blob([file.bytes], { type: file.mimeType })); return { url: objectUrl, revoke: () => { if (typeof URL.revokeObjectURL === "function") { URL.revokeObjectURL(objectUrl); } } }; } return { url: createDataUrlForStoredFile(file), revoke: () => undefined }; } function createPersistentResourceUrl(url: string): { revoke: () => void; url: string; } { return { url, revoke: () => undefined }; } function combineRevokeHandlers(...handlers: Array<() => void>): () => void { return () => { for (const handler of handlers) { handler(); } }; } function loadImageElement(sourceUrl: string): Promise { return new Promise((resolve, reject) => { const image = new Image(); image.decoding = "async"; image.addEventListener("load", () => { resolve(image); }); image.addEventListener("error", () => { reject(new Error(`Image could not be loaded from ${sourceUrl}.`)); }); image.src = sourceUrl; }); } function detectImageHasAlpha(image: HTMLImageElement): boolean { const canvas = document.createElement("canvas"); const sampleWidth = Math.max(1, Math.min(64, image.naturalWidth || image.width)); const sampleHeight = Math.max(1, Math.min(64, image.naturalHeight || image.height)); const context = canvas.getContext("2d", { willReadFrequently: true }); if (context === null) { return false; } canvas.width = sampleWidth; canvas.height = sampleHeight; context.drawImage(image, 0, 0, sampleWidth, sampleHeight); try { const pixels = context.getImageData(0, 0, sampleWidth, sampleHeight).data; for (let index = 3; index < pixels.length; index += 4) { if (pixels[index] !== 255) { return true; } } } catch { return false; } return false; } function extractImageAssetMetadata(image: HTMLImageElement): ImageAssetMetadata { const width = image.naturalWidth || image.width; const height = image.naturalHeight || image.height; if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) { throw new Error("Imported image assets must have measurable dimensions."); } const warnings: string[] = []; const aspectRatio = width / height; if (Math.abs(aspectRatio - 2) > 0.15) { warnings.push("Background images work best as a 2:1 equirectangular panorama."); } return { kind: "image", width, height, hasAlpha: detectImageHasAlpha(image), warnings }; } function extractHdrTextureMetadata(texture: DataTexture): ImageAssetMetadata { const width = texture.image.width as number; const height = texture.image.height as number; const warnings: string[] = []; if (Math.abs(width / height - 2) > 0.15) { warnings.push("Background images work best as a 2:1 equirectangular panorama."); } return { kind: "image", width, height, hasAlpha: false, warnings }; } function createImageTexture(image: HTMLImageElement): Texture { const texture = new Texture(image); texture.colorSpace = SRGBColorSpace; texture.mapping = EquirectangularReflectionMapping; texture.needsUpdate = true; return texture; } function configureHdrTexture(texture: DataTexture): DataTexture { // HDR/EXR data is linear — do not apply sRGB color space texture.colorSpace = LinearSRGBColorSpace; texture.mapping = EquirectangularReflectionMapping; texture.needsUpdate = true; return texture; } function loadHdrTexture(url: string, sourceName: string): Promise { return new Promise((resolve, reject) => { if (getFileExtension(sourceName) === "exr") { new EXRLoader().load(url, resolve, undefined, () => { reject(new Error(`EXR file could not be loaded: ${sourceName}.`)); }); } else { new RGBELoader().load(url, resolve, undefined, () => { reject(new Error(`HDR file could not be loaded: ${sourceName}.`)); }); } }); } function createLoadedImageAsset( asset: ImageAssetRecord, image: HTMLImageElement, sourceUrl: string, previewUrl: string, revokeSourceUrl: () => void ): LoadedImageAsset { return { assetId: asset.id, storageKey: asset.storageKey, metadata: asset.metadata, texture: createImageTexture(image), sourceUrl, previewUrl, revokeSourceUrl }; } function createLoadedHdrImageAsset( asset: ImageAssetRecord, texture: DataTexture, sourceUrl: string, previewUrl: string, revokeSourceUrl: () => void ): LoadedImageAsset { return { assetId: asset.id, storageKey: asset.storageKey, metadata: asset.metadata, texture: configureHdrTexture(texture), sourceUrl, previewUrl, revokeSourceUrl }; } function createImageAssetRecord( sourceName: string, mimeType: string, byteLength: number, metadata: ImageAssetMetadata ): ImageAssetRecord { const assetId = createOpaqueId("asset-image"); return { id: assetId, kind: "image", sourceName, mimeType, storageKey: createProjectAssetStorageKey(assetId), byteLength, metadata }; } function getStoredImageAssetFile( asset: ImageAssetRecord, storedAsset: ProjectAssetStoragePackageRecord ): ProjectAssetStorageFileRecord | null { const directFile = storedAsset.files[asset.sourceName]; if (directFile !== undefined) { return directFile; } const storedFiles = Object.values(storedAsset.files); if (storedFiles.length === 1) { return storedFiles[0]; } return null; } function getStoredPreviewImageFile( storedAsset: ProjectAssetStoragePackageRecord ): ProjectAssetStorageFileRecord | null { for (const previewPath of [ "preview.jpg", "preview.jpeg", "preview.webp", "preview.png" ]) { const previewFile = storedAsset.files[previewPath]; if (previewFile !== undefined) { return previewFile; } } return null; } async function loadStarterEnvironmentImageAsset( asset: ImageAssetRecord ): Promise { const sourceResource = createPersistentResourceUrl( getStarterEnvironmentTextureUrl(asset) ); const previewResource = createPersistentResourceUrl( getStarterEnvironmentPreviewUrl(asset) ); try { const texture = await loadHdrTexture(sourceResource.url, asset.sourceName); return createLoadedHdrImageAsset( asset, texture, sourceResource.url, previewResource.url, combineRevokeHandlers(sourceResource.revoke, previewResource.revoke) ); } catch (error) { throw new Error( `Image asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}` ); } } async function loadImageAssetFromPackageRecord( asset: ImageAssetRecord, storedAsset: ProjectAssetStoragePackageRecord ): Promise { const fileRecord = getStoredImageAssetFile(asset, storedAsset); if (fileRecord === null) { throw new Error( `Missing stored image file for imported image asset ${asset.sourceName}.` ); } const transientResourceUrl = createTransientResourceUrl(fileRecord); if (isHdrFormat(asset.sourceName)) { const previewFileRecord = getStoredPreviewImageFile(storedAsset); const previewResourceUrl = previewFileRecord === null ? transientResourceUrl : createTransientResourceUrl(previewFileRecord); try { const texture = await loadHdrTexture(transientResourceUrl.url, asset.sourceName); return createLoadedHdrImageAsset( asset, texture, transientResourceUrl.url, previewResourceUrl.url, combineRevokeHandlers( transientResourceUrl.revoke, previewResourceUrl === transientResourceUrl ? () => undefined : previewResourceUrl.revoke ) ); } catch (error) { transientResourceUrl.revoke(); if (previewResourceUrl !== transientResourceUrl) { previewResourceUrl.revoke(); } throw new Error(`Image asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`); } } try { const image = await loadImageElement(transientResourceUrl.url); return createLoadedImageAsset( asset, image, transientResourceUrl.url, transientResourceUrl.url, transientResourceUrl.revoke ); } catch (error) { transientResourceUrl.revoke(); throw new Error(`Image asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`); } } export async function importBackgroundImageAssetFromFile( file: File, storage: ProjectAssetStorage ): Promise { const sourceName = getImportedFilePath(file); const mimeType = inferImageMimeType(sourceName, file.type); const bytes = await file.arrayBuffer(); const fileRecord: ProjectAssetStorageFileRecord = { bytes, mimeType }; let asset: ImageAssetRecord; let loadedAsset: LoadedImageAsset; if (isHdrFormat(sourceName)) { const transientResourceUrl = createTransientResourceUrl(fileRecord); let texture: DataTexture; try { texture = await loadHdrTexture(transientResourceUrl.url, sourceName); } catch (error) { transientResourceUrl.revoke(); throw new Error(`Image import failed for ${sourceName}: ${getErrorDetail(error)}`); } const metadata = extractHdrTextureMetadata(texture); asset = createImageAssetRecord(sourceName, mimeType, bytes.byteLength, metadata); loadedAsset = createLoadedHdrImageAsset( asset, texture, transientResourceUrl.url, transientResourceUrl.url, transientResourceUrl.revoke ); } else { const transientResourceUrl = createTransientResourceUrl(fileRecord); let image: HTMLImageElement; try { image = await loadImageElement(transientResourceUrl.url); } catch (error) { transientResourceUrl.revoke(); throw new Error(`Image import failed for ${sourceName}: ${getErrorDetail(error)}`); } const metadata = extractImageAssetMetadata(image); asset = createImageAssetRecord(sourceName, mimeType, bytes.byteLength, metadata); loadedAsset = createLoadedImageAsset( asset, image, transientResourceUrl.url, transientResourceUrl.url, transientResourceUrl.revoke ); } const packageRecord: ProjectAssetStoragePackageRecord = { files: { [sourceName]: fileRecord } }; try { await storage.putAsset(asset.storageKey, packageRecord); return { asset, loadedAsset }; } catch (error) { disposeLoadedImageAsset(loadedAsset); await storage.deleteAsset(asset.storageKey).catch(() => undefined); throw error; } } export async function loadImageAssetFromStorage( storage: ProjectAssetStorage | null, asset: ImageAssetRecord ): Promise { if (isStarterEnvironmentImageAsset(asset)) { return loadStarterEnvironmentImageAsset(asset); } if (storage === null) { throw new Error( `Missing stored binary data for imported image asset ${asset.sourceName}.` ); } let storedAsset: ProjectAssetStoragePackageRecord | null; try { storedAsset = await storage.getAsset(asset.storageKey); } catch (error) { throw new Error(`Image asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`); } if (storedAsset === null) { throw new Error(`Missing stored binary data for imported image asset ${asset.sourceName}.`); } return loadImageAssetFromPackageRecord(asset, storedAsset); } export function disposeLoadedImageAsset(asset: LoadedImageAsset) { asset.texture.dispose(); asset.revokeSourceUrl(); }