Files
feuerwerk/src/editor/textureImport.ts
T
adminandClaude Fable 5.1 cc6db5f350
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m46s
Three stores, three importers and three pickers that differ only in their type are one of each (#85)
Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic
HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport
with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe
only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its
own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts,
and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list,
check count 958, the two-job CI, the Scenes wiki page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:38:54 +02:00

113 lines
4.2 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 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. */
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;
}
const IMAGE_KIND: ImportKind<UploadedTexture, TextureImportDeps> = {
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<ImportOutcome[]> {
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);
}