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>
109 lines
4.4 KiB
TypeScript
109 lines
4.4 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
||
import {
|
||
encodeWithinBudget,
|
||
type ImportOutcome,
|
||
importNotice,
|
||
MAX_TEXTURE_SIDE,
|
||
MAX_UPLOAD_BYTES,
|
||
planImageFit,
|
||
} from '../../src/editor/textureImport';
|
||
|
||
/** A blob of exactly `size` bytes – `encodeWithinBudget` only ever looks at the size. */
|
||
function blobOf(size: number): Blob {
|
||
return new Blob([new Uint8Array(size)]);
|
||
}
|
||
|
||
function outcome(name: string, error: string | null = null): ImportOutcome {
|
||
return { name, assetId: error ? null : `id-${name}`, error, reused: false };
|
||
}
|
||
|
||
/** A file whose picture the project already had, so no asset was created for it. */
|
||
function reused(name: string): ImportOutcome {
|
||
return { name, assetId: `id-${name}`, error: null, reused: true };
|
||
}
|
||
|
||
describe('planImageFit', () => {
|
||
it('leaves an image alone that fits in both senses', () => {
|
||
expect(planImageFit(32, 32, 900)).toEqual({ kind: 'keep' });
|
||
expect(planImageFit(MAX_TEXTURE_SIDE, 1024, MAX_UPLOAD_BYTES)).toEqual({ kind: 'keep' });
|
||
});
|
||
|
||
it('caps the longest side and keeps the aspect ratio', () => {
|
||
expect(planImageFit(4000, 3000, 3_000_000)).toEqual({ kind: 'reencode', width: 2048, height: 1536 });
|
||
expect(planImageFit(1000, 4096, 100)).toEqual({ kind: 'reencode', width: 500, height: 2048 });
|
||
});
|
||
|
||
it('re-encodes at the original size when only the byte count is over budget', () => {
|
||
expect(planImageFit(800, 600, MAX_UPLOAD_BYTES + 1)).toEqual({
|
||
kind: 'reencode',
|
||
width: 800,
|
||
height: 600,
|
||
});
|
||
});
|
||
|
||
it('never scales a picture away to nothing', () => {
|
||
expect(planImageFit(20000, 3, 9_000_000)).toEqual({ kind: 'reencode', width: 2048, height: 1 });
|
||
});
|
||
});
|
||
|
||
describe('encodeWithinBudget', () => {
|
||
it('stops at the first quality that fits', async () => {
|
||
const encode = vi.fn(() => Promise.resolve(blobOf(MAX_UPLOAD_BYTES - 1)));
|
||
const blob = await encodeWithinBudget(encode);
|
||
expect(blob.size).toBe(MAX_UPLOAD_BYTES - 1);
|
||
expect(encode).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('steps the quality down until the result fits', async () => {
|
||
const sizes = [MAX_UPLOAD_BYTES + 1000, MAX_UPLOAD_BYTES - 1000];
|
||
const encode = vi.fn((_quality: number) => Promise.resolve(blobOf(sizes.shift() ?? 0)));
|
||
const blob = await encodeWithinBudget(encode);
|
||
expect(blob.size).toBe(MAX_UPLOAD_BYTES - 1000);
|
||
expect(encode).toHaveBeenCalledTimes(2);
|
||
// Decreasing quality, so a later attempt is never larger than an earlier one.
|
||
const qualities = encode.mock.calls.map(([quality]) => quality);
|
||
expect(qualities).toEqual([...qualities].sort((a, b) => b - a));
|
||
});
|
||
|
||
it('returns the last attempt when nothing fits, so the caller can say how far off it was', async () => {
|
||
const encode = vi.fn(() => Promise.resolve(blobOf(MAX_UPLOAD_BYTES + 5)));
|
||
const blob = await encodeWithinBudget(encode);
|
||
expect(blob.size).toBe(MAX_UPLOAD_BYTES + 5);
|
||
expect(encode.mock.calls.length).toBeGreaterThan(1);
|
||
});
|
||
});
|
||
|
||
describe('importNotice', () => {
|
||
it('counts what arrived', () => {
|
||
expect(importNotice([outcome('a.png')])).toBe('Imported 1 image.');
|
||
expect(importNotice([outcome('a.png'), outcome('b.png')])).toBe('Imported 2 images.');
|
||
});
|
||
|
||
it('names the first casualty and how many followed', () => {
|
||
expect(importNotice([outcome('a.png', 'too large')])).toBe('Could not import "a.png": too large');
|
||
expect(importNotice([outcome('a.png', 'too large'), outcome('b.png', 'broken')])).toBe(
|
||
'Could not import "a.png": too large (and 1 more)',
|
||
);
|
||
});
|
||
|
||
it('reports a partial import as both', () => {
|
||
expect(importNotice([outcome('a.png'), outcome('b.png', 'too large')])).toBe(
|
||
'Imported 1 of 2. Could not import "b.png": too large',
|
||
);
|
||
});
|
||
|
||
it('says so when the picture was already in the project', () => {
|
||
// "Imported 1 image" would send the user looking for an asset that was never created.
|
||
expect(importNotice([reused('a.png')])).toBe('That image is already in the project.');
|
||
expect(importNotice([reused('a.png'), reused('b.png')])).toBe(
|
||
'Those 2 images are already in the project.',
|
||
);
|
||
expect(importNotice([outcome('a.png'), reused('b.png')])).toBe(
|
||
'Imported 1 image. One was already in the project.',
|
||
);
|
||
expect(importNotice([outcome('a.png'), reused('b.png'), outcome('c.png', 'broken')])).toBe(
|
||
'Imported 2 of 3. One was already in the project. Could not import "c.png": broken',
|
||
);
|
||
});
|
||
});
|