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>
142 lines
5.2 KiB
PHP
142 lines
5.2 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* Content-addressed image store: `data/textures/{sha256}.{ext}`.
|
||
*
|
||
* The name of a file is the hash of its bytes, which buys four things at once:
|
||
* uploading the same image twice costs one file, a stored file can be cached forever
|
||
* (its URL cannot ever mean different bytes), projects reference a short string instead
|
||
* of embedding megabytes of base64, and deleting a texture asset is undoable for free
|
||
* because nothing here is removed when it stops being referenced.
|
||
*
|
||
* The flip side is that files outlive the projects that used them; `tools/gc-textures.mjs`
|
||
* collects the unreferenced ones.
|
||
*/
|
||
final class TextureStore
|
||
{
|
||
/** Accepted formats. SVG is deliberately absent: it can carry script. */
|
||
private const EXTENSIONS = [
|
||
IMAGETYPE_PNG => 'png',
|
||
IMAGETYPE_JPEG => 'jpg',
|
||
IMAGETYPE_WEBP => 'webp',
|
||
IMAGETYPE_GIF => 'gif',
|
||
];
|
||
|
||
private const MIME_TYPES = [
|
||
'image/png' => IMAGETYPE_PNG,
|
||
'image/jpeg' => IMAGETYPE_JPEG,
|
||
'image/webp' => IMAGETYPE_WEBP,
|
||
'image/gif' => IMAGETYPE_GIF,
|
||
];
|
||
|
||
/**
|
||
* Refuses images no display can use anyway. Dimensions come from the header, so a
|
||
* "decompression bomb" (tiny file, gigantic canvas) is rejected before anything
|
||
* allocates memory for it.
|
||
*/
|
||
private const MAX_DIMENSION = 8192;
|
||
|
||
public function __construct(private readonly string $dir)
|
||
{
|
||
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
|
||
throw new RuntimeException("Cannot create texture directory {$this->dir}");
|
||
}
|
||
}
|
||
|
||
/** A stored file name, as it appears in a project document. */
|
||
public static function isValidName(string $name): bool
|
||
{
|
||
return (bool) preg_match('/^[a-f0-9]{64}\.(png|jpg|webp|gif)$/', $name);
|
||
}
|
||
|
||
/**
|
||
* Stores the raw bytes of an image and returns its record.
|
||
*
|
||
* The extension is derived from the sniffed content, never from anything the client
|
||
* says, so no request can choose the name or the type of the file it creates.
|
||
*
|
||
* @return array{file:string,width:int,height:int,bytes:int}
|
||
* @throws InvalidImageException when the bytes are not a supported image
|
||
*/
|
||
public function store(string $bytes): array
|
||
{
|
||
$size = @getimagesizefromstring($bytes);
|
||
if ($size === false) {
|
||
throw new InvalidImageException('Not a readable image');
|
||
}
|
||
[$width, $height, $type] = $size;
|
||
$extension = self::EXTENSIONS[$type] ?? '';
|
||
if ($extension === '') {
|
||
throw new InvalidImageException('Unsupported image format – use PNG, JPEG, WebP or GIF');
|
||
}
|
||
|
||
// Two independent sniffs must agree; a file that reads as one type by header and as
|
||
// another by content is a polyglot, and we would rather not find out what it is.
|
||
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($bytes);
|
||
if ((self::MIME_TYPES[$mime] ?? 0) !== $type) {
|
||
throw new InvalidImageException('Image content does not match its format');
|
||
}
|
||
|
||
if ($width < 1 || $height < 1 || $width > self::MAX_DIMENSION || $height > self::MAX_DIMENSION) {
|
||
throw new InvalidImageException('Image must be between 1 and ' . self::MAX_DIMENSION . ' pixels per side');
|
||
}
|
||
|
||
$file = hash('sha256', $bytes) . '.' . $extension;
|
||
$path = $this->dir . '/' . $file;
|
||
if (!is_file($path)) {
|
||
$this->writeFile($path, $bytes);
|
||
}
|
||
|
||
return ['file' => $file, 'width' => $width, 'height' => $height, 'bytes' => strlen($bytes)];
|
||
}
|
||
|
||
/** Every stored file, newest first – the input of the garbage collector. */
|
||
/** @return list<array{file:string,bytes:int,updatedAt:string}> */
|
||
public function list(): array
|
||
{
|
||
$items = [];
|
||
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
|
||
$file = basename($path);
|
||
if (!self::isValidName($file)) {
|
||
continue;
|
||
}
|
||
$items[] = [
|
||
'file' => $file,
|
||
'bytes' => (int) filesize($path),
|
||
'updatedAt' => gmdate('c', (int) filemtime($path)),
|
||
];
|
||
}
|
||
usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt']));
|
||
return $items;
|
||
}
|
||
|
||
public function delete(string $file): bool
|
||
{
|
||
if (!self::isValidName($file)) {
|
||
return false;
|
||
}
|
||
$path = $this->dir . '/' . $file;
|
||
return is_file($path) && unlink($path);
|
||
}
|
||
|
||
/**
|
||
* Writes via a temp file in the same directory, so a reader never sees a half-written
|
||
* image: the name only ever appears once the bytes behind it are complete.
|
||
*/
|
||
private function writeFile(string $path, string $bytes): void
|
||
{
|
||
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
|
||
if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) {
|
||
@unlink($tmp);
|
||
throw new RuntimeException('Cannot write texture file');
|
||
}
|
||
@chmod($path, 0664);
|
||
}
|
||
}
|
||
|
||
/** Thrown by TextureStore::store when the uploaded bytes are not a usable image. */
|
||
final class InvalidImageException extends RuntimeException
|
||
{
|
||
}
|