import { CreateTextureCommand } from '../core/commands/assetCommands'; import { nextAssetName } from '../core/commands/naming'; import type { TextureAsset } from '../core/model/Asset'; import type { TextureStore } from '../core/store/TextureStore'; import type { EditorState } from './state'; /** What the picker offers and a drop accepts; the server rejects everything else anyway. */ export 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. */ export 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; } /** One file's outcome; exactly one of `assetId` and `error` is set. */ export interface ImportOutcome { name: string; assetId: string | null; error: string | null; /** The project already had this exact picture, so its existing asset was used instead. */ reused?: boolean; } /** * Imports the files sequentially, so each asset is named against the project as the previous one * left it. A file that fails is reported in the notice; the remaining ones are still imported. */ export async function importImages( files: readonly File[], deps: TextureImportDeps, ): Promise { if (files.length === 0) return []; const outcomes: ImportOutcome[] = []; for (const file of files) outcomes.push(await importImage(file, deps)); const created = outcomes.filter((outcome) => outcome.assetId !== null); const last = created[created.length - 1]; if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId }); deps.state.setNotice(importNotice(outcomes)); return outcomes; } async function importImage(file: File, deps: TextureImportDeps): Promise { try { const image = await prepareImage(file); const uploaded = await deps.textures.upload(image.blob, image.type); // The server stores by content hash, so a re-imported picture comes back as the file the // project already references; two assets on one file would disagree about `filter`. const existing = deps.state.project.assets.find( (asset): asset is TextureAsset => asset.type === 'texture' && asset.file === uploaded.file, ); if (existing) { return { name: file.name, assetId: existing.id, error: null, reused: true }; } const name = nextAssetName(deps.state.project, assetNameFor(file)); const command = new CreateTextureCommand(name, uploaded); // A rejected command has already put its reason into the notice; that is the message here. if (!deps.state.execute(command)) return { name: file.name, assetId: null, error: deps.state.notice }; return { name: file.name, assetId: command.asset.id, error: null, reused: false }; } catch (error) { return { name: file.name, assetId: null, error: error instanceof Error ? error.message : String(error) }; } } /** One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie. */ export function importNotice(outcomes: readonly ImportOutcome[]): string { const added = outcomes.filter((outcome) => outcome.assetId !== null && !outcome.reused).length; const reused = outcomes.filter((outcome) => outcome.reused).length; const failed = outcomes.filter((outcome) => outcome.error !== null); const first = failed[0]; const known = reused === 0 ? '' : ` ${reused === 1 ? 'One was' : `${reused} were`} already in the project.`; if (!first) { if (added === 0 && reused > 0) return reused === 1 ? 'That image is already in the project.' : `Those ${reused} images are already in the project.`; return `Imported ${added} ${added === 1 ? 'image' : 'images'}.${known}`; } const more = failed.length > 1 ? ` (and ${failed.length - 1} more)` : ''; const problem = `Could not import "${first.name}": ${first.error ?? ''}${more}`; if (added + reused === 0) return problem; return `Imported ${added + reused} of ${outcomes.length}.${known} ${problem}`; } function assetNameFor(file: File): string { return file.name.replace(/\.[^.]+$/, '').trim() || 'Texture'; } function formatBytes(bytes: number): string { return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`; } // --- ways in --------------------------------------------------------------------------------- /** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */ export function pickImageFiles(): Promise { return new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; input.accept = IMAGE_ACCEPT; input.multiple = true; input.style.display = 'none'; document.body.appendChild(input); const finish = (files: File[]) => { input.remove(); resolve(files); }; input.addEventListener('change', () => finish([...(input.files ?? [])])); // Without `cancel` a dismissed dialog leaves both the element and this promise behind. input.addEventListener('cancel', () => finish([])); input.click(); }); } export function pickAndImport(deps: TextureImportDeps): void { void pickImageFiles() .then((files) => importImages(files, deps)) .catch((error: unknown) => deps.state.setNotice(String(error))); } /** * Wires drop-onto-`target` and paste, and returns the remover for both. Paste is bound to the * window because the canvas cannot hold focus, and skipped while a DOM text field has it. */ export function installTextureImport(target: HTMLElement, deps: TextureImportDeps): () => void { const onDragOver = (event: DragEvent) => { if (!hasImageItems(event.dataTransfer)) return; // Without this the browser navigates away to the dropped file. event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; }; const onDrop = (event: DragEvent) => { const files = imageFilesOf(event.dataTransfer); if (files.length === 0) { if (!event.dataTransfer?.files.length) return; event.preventDefault(); deps.state.setNotice('Only image files can be dropped into the editor.'); return; } event.preventDefault(); run(files, deps); }; const onPaste = (event: ClipboardEvent) => { if (isTextFieldFocused()) return; const files = imageFilesOf(event.clipboardData); if (files.length === 0) return; event.preventDefault(); run(files, deps); }; target.addEventListener('dragover', onDragOver); target.addEventListener('drop', onDrop); window.addEventListener('paste', onPaste); return () => { target.removeEventListener('dragover', onDragOver); target.removeEventListener('drop', onDrop); window.removeEventListener('paste', onPaste); }; } function run(files: readonly File[], deps: TextureImportDeps): void { void importImages(files, deps).catch((error: unknown) => deps.state.setNotice(String(error))); } function imageFilesOf(data: DataTransfer | null): File[] { return [...(data?.files ?? [])].filter((file) => file.type.startsWith('image/')); } /** During a drag the files themselves are not readable yet – only the kinds and types are. */ function hasImageItems(data: DataTransfer | null): boolean { return [...(data?.items ?? [])].some((item) => item.kind === 'file' && item.type.startsWith('image/')); } function isTextFieldFocused(): boolean { const active = document.activeElement; return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement; }