import { CreateTextureCommand } from '../core/commands/assetCommands'; import type { TextureStore, UploadedTexture } from '../core/store/TextureStore'; import { formatBytes } from '../ui'; import { type ImportKind, type ImportOutcome, importFiles, importStored, pickAndImport } from './assetImport'; import type { EditorState } from './state'; /** What the picker offers and a drop accepts; the server rejects everything else anyway. */ const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif'; /** Longest side a stored texture may have: a 4000×3000 photo costs 48 MB of video memory. */ export const MAX_TEXTURE_SIDE = 2048; /** Byte budget for one upload: nginx cuts every request off at 1 MB, below the API's own 4 MB. */ export const MAX_UPLOAD_BYTES = 950 * 1024; /** Tried in order when an image has to be re-encoded; the first result that fits wins. */ const WEBP_QUALITIES = [0.9, 0.75, 0.6] as const; export type ImagePlan = { kind: 'keep' } | { kind: 'reencode'; width: number; height: number }; /** * Re-encoding would turn crisp pixel art into mush, so an image within both limits is kept byte * for byte. `bytes` is the encoded file size, not the pixel count. */ export function planImageFit(width: number, height: number, bytes: number): ImagePlan { const scale = Math.min(1, MAX_TEXTURE_SIDE / Math.max(width, height)); if (scale === 1 && bytes <= MAX_UPLOAD_BYTES) return { kind: 'keep' }; return { kind: 'reencode', width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)), }; } /** Encodes at decreasing quality; when none fits the budget, the last attempt comes back anyway. */ export async function encodeWithinBudget(encode: (quality: number) => Promise): Promise { let result = await encode(WEBP_QUALITIES[0]); for (const quality of WEBP_QUALITIES.slice(1)) { if (result.size <= MAX_UPLOAD_BYTES) return result; result = await encode(quality); } return result; } export interface PreparedImage { blob: Blob; /** Content-Type for the upload; `image/webp` whenever the image had to be re-encoded. */ type: string; width: number; height: number; } /** The bytes to upload: the file itself when it already fits, otherwise a downscaled WebP. */ async function prepareImage(file: File): Promise { let bitmap: ImageBitmap; try { bitmap = await createImageBitmap(file); } catch { throw new Error('The file is not a readable image.'); } try { const plan = planImageFit(bitmap.width, bitmap.height, file.size); if (plan.kind === 'keep') { return { blob: file, type: file.type || 'application/octet-stream', width: bitmap.width, height: bitmap.height, }; } // Only the first frame of an animated GIF survives this. const canvas = new OffscreenCanvas(plan.width, plan.height); const context = canvas.getContext('2d'); if (!context) throw new Error('This browser cannot resize images.'); context.drawImage(bitmap, 0, 0, plan.width, plan.height); const blob = await encodeWithinBudget((quality) => canvas.convertToBlob({ type: 'image/webp', quality })); if (blob.size > MAX_UPLOAD_BYTES) { throw new Error( `The image is too large to upload: still ${formatBytes(blob.size)} at ${plan.width}×${plan.height}. Shrink it before importing.`, ); } return { blob, type: 'image/webp', width: plan.width, height: plan.height }; } finally { bitmap.close(); } } export interface TextureImportDeps { state: EditorState; textures: TextureStore; } const IMAGE_KIND: ImportKind = { type: 'texture', noun: 'image', fallbackName: 'Texture', store: (deps) => deps.textures, prepare: prepareImage, create: (name, image) => new CreateTextureCommand(name, image), }; export function importImages(files: readonly File[], deps: TextureImportDeps): Promise { return importFiles(IMAGE_KIND, files, deps); } export function importStoredImage(image: UploadedTexture, deps: TextureImportDeps): ImportOutcome { return importStored(IMAGE_KIND, image, deps); } export function pickAndImportImages(deps: TextureImportDeps): void { pickAndImport(IMAGE_KIND, IMAGE_ACCEPT, deps); }