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>
113 lines
4.2 KiB
TypeScript
113 lines
4.2 KiB
TypeScript
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);
|
||
}
|