Files
feuerwerk/src/editor/textureImport.ts
T
rootandClaude Opus 5 4d84231b9f Trim needless comments and fix the picker width
Remove comments that only restate the code, keeping the section banners
and the notes that carry a reason, a measured fact or an external
contract.

ButtonOptions.width claimed to be a fixed width but only set minWidth,
so the Graph and Texture pickers grew with the name they showed. It is
now what it promises, with a separate minWidth for the buttons that have
two forms. Narrow buttons shrink their padding, otherwise a 24px icon
button had 8px left for its glyph and truncate dropped it entirely.

__gm.labelsDropped() guards that class: the overflow guard only saw text
that was too wide, never a label truncated to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:52:05 +02:00

260 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Blob>): Promise<Blob> {
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<PreparedImage> {
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<ImportOutcome[]> {
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<ImportOutcome> {
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<File[]> {
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;
}