Files
webeditor3d/src/assets/image-assets.ts

325 lines
8.9 KiB
TypeScript

import {
EquirectangularReflectionMapping,
SRGBColorSpace,
Texture,
type TextureDataType
} from "three";
import { createOpaqueId } from "../core/ids";
import type { ImageAssetMetadata, ImageAssetRecord } from "./project-assets";
import {
createProjectAssetStorageKey,
type ProjectAssetStorage,
type ProjectAssetStorageFileRecord,
type ProjectAssetStoragePackageRecord
} from "./project-asset-storage";
export interface LoadedImageAsset {
assetId: string;
storageKey: string;
metadata: ImageAssetMetadata;
texture: Texture;
sourceUrl: 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 "gif":
return "image/gif";
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.`);
}
}
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 loadImageElement(sourceUrl: string): Promise<HTMLImageElement> {
return new Promise<HTMLImageElement>((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 createImageTexture(image: HTMLImageElement): Texture {
const texture = new Texture(image);
texture.colorSpace = SRGBColorSpace;
texture.mapping = EquirectangularReflectionMapping;
texture.needsUpdate = true;
return texture;
}
function createLoadedImageAsset(
asset: ImageAssetRecord,
image: HTMLImageElement,
sourceUrl: string,
revokeSourceUrl: () => void
): LoadedImageAsset {
return {
assetId: asset.id,
storageKey: asset.storageKey,
metadata: asset.metadata,
texture: createImageTexture(image),
sourceUrl,
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;
}
async function loadImageAssetFromFileRecord(
asset: ImageAssetRecord,
fileRecord: ProjectAssetStorageFileRecord
): Promise<LoadedImageAsset> {
const transientResourceUrl = createTransientResourceUrl(fileRecord);
try {
const image = await loadImageElement(transientResourceUrl.url);
return createLoadedImageAsset(asset, image, 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<ImportedImageAssetResult> {
const sourceName = getImportedFilePath(file);
const mimeType = inferImageMimeType(sourceName, file.type);
const bytes = await file.arrayBuffer();
const fileRecord: ProjectAssetStorageFileRecord = {
bytes,
mimeType
};
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);
const asset = createImageAssetRecord(sourceName, mimeType, bytes.byteLength, metadata);
const loadedAsset = createLoadedImageAsset(asset, image, 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,
asset: ImageAssetRecord
): Promise<LoadedImageAsset> {
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}.`);
}
const storedImageFile = getStoredImageAssetFile(asset, storedAsset);
if (storedImageFile === null) {
throw new Error(`Missing stored image file for imported image asset ${asset.sourceName}.`);
}
return loadImageAssetFromFileRecord(asset, storedImageFile);
}
export function disposeLoadedImageAsset(asset: LoadedImageAsset) {
asset.texture.dispose();
asset.revokeSourceUrl();
}