Add textures for sprites
Images are uploaded to a content-addressed blob endpoint and stored as data/textures/<sha256>.<ext>; the project only keeps the file name. The sprite points at a texture asset through an ordinary property of the new kind `asset`, so instance overrides, "Apply to container", "Reset to asset", undo and `Set property` work without any extra code. The new kind also replaces the ad-hoc asset picker the graph nodes faked as an enum. Import goes through + -> Texture, drag & drop onto the canvas or Ctrl+V. Images wider than 2048px are downscaled client-side and re-encoded to WebP if needed, because nginx caps uploads at 1MB; an image that already fits is uploaded byte for byte so pixel art is never resampled. The sprite colour default changes from red to white, since colour tints the texture multiplicatively and a tint of white shows an image as it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6168df41f8
commit
5a6af74e44
47 files changed
+1995
-110
No files matched your search
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Getting a picture from the user's machine into the project: pick, drop or paste a file,
|
||||
* shrink it if it is too big to send, upload the bytes, add a texture asset. The three ways in
|
||||
* share every step after the file – so a dropped and a picked image behave identically.
|
||||
*/
|
||||
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 phone photo has no business being a
|
||||
* sprite texture: it costs 48 MB of video memory to draw something a fraction of that size.
|
||||
*/
|
||||
export const MAX_TEXTURE_SIDE = 2048;
|
||||
|
||||
/**
|
||||
* Byte budget for one upload. The API would take 4 MB, but nginx cuts every request off at
|
||||
* 1 MB and answers with its own HTML page, so nginx is the wall we have to stay under.
|
||||
*/
|
||||
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 };
|
||||
|
||||
/**
|
||||
* How to get an image within both limits. Re-encoding is a last resort – it would turn crisp
|
||||
* pixel art into mush – so anything that is already small enough in both senses is sent byte
|
||||
* for byte as it lies on disk. `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 and stops at the first result within `MAX_UPLOAD_BYTES`. When
|
||||
* none fits, the last (lowest quality) attempt comes back, so the caller can report how far
|
||||
* off it was. `encode` is a parameter to keep the ladder testable without a canvas.
|
||||
*/
|
||||
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 for one picked file: the file itself whenever it already fits (see
|
||||
* `planImageFit`), otherwise a downscaled WebP. Rejects when the file is not a decodable image
|
||||
* or stays too large even at the lowest quality.
|
||||
*/
|
||||
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') {
|
||||
// Untouched on purpose: re-encoding costs quality and would break pixel art.
|
||||
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, which is all the renderer ever
|
||||
// draws – a texture is one still picture.
|
||||
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 one after another – prepare, upload, create the asset – selects the last
|
||||
* one that worked and sums the result up as a notice. Sequential on purpose: each asset is
|
||||
* named against the project as it stands *after* the previous one. A file that fails is
|
||||
* reported and 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 importing a picture the project already has gives
|
||||
// back the file it already references. Making a second asset for it would be clutter with a
|
||||
// trap in it: two assets on one file can disagree about `filter`, while the scale mode
|
||||
// belongs to the single shared source. Reuse instead – and say so, rather than silently
|
||||
// doing nothing visible.
|
||||
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: how many arrived, how many were already there, and why the
|
||||
* first casualty did not. Reuse gets its own words – "Imported 1 image" would be a lie about a
|
||||
* picture the project already had, and the user would look for a new asset that never appeared.
|
||||
*/
|
||||
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}`;
|
||||
}
|
||||
|
||||
/** "player_ship.png" → "player_ship"; a pasted screenshot arrives as "image.png". */
|
||||
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 and resolves with what the user chose – empty when the
|
||||
* dialog was cancelled. The `<input>` lives only for this one call.
|
||||
*/
|
||||
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 would leave both the element and this promise behind.
|
||||
input.addEventListener('cancel', () => finish([]));
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/** The `+` menu's Texture entry: pick files and import them, reporting anything that goes wrong. */
|
||||
export function pickAndImport(deps: TextureImportDeps): void {
|
||||
void pickImageFiles()
|
||||
.then((files) => importImages(files, deps))
|
||||
.catch((error: unknown) => deps.state.setNotice(String(error)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the two ways of importing an image that need no menu: dropping files onto `target`
|
||||
* (the Pixi canvas) and pasting one from the clipboard. Returns the function that removes both
|
||||
* again.
|
||||
*
|
||||
* Paste is bound to the window because the canvas cannot hold focus, and is ignored while a
|
||||
* DOM text field has it (the `KeyboardBridge` input): Ctrl+V belongs to the text being edited.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user