Three stores, three importers and three pickers that differ only in their type are one of each (#85)
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m46s

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>
This commit is contained in:
adminandClaude Fable 5.1 committed 2026-09-16 17:38:54 +02:00
1 parent 15bdfdf07a
commit cc6db5f350
42 files changed
+1375 -1650

No files matched your search

+5 -17
View File
@@ -15,7 +15,7 @@ import type { Project } from '../model/Project';
import { coerceValue, defaultsOf } from '../properties/PropertySchema';
import { type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireGraph, requireNode, requireNodeDef } from './require';
import { requireEdge, requireGraph, requireNode, requireNodeDef } from './require';
import { SetBagValueCommand } from './SetBagValueCommand';
export class AddNodeCommand implements Command {
@@ -221,19 +221,13 @@ export class FoldEdgeCommand implements Command {
}
execute(project: Project): void {
const edge = this.edge(project);
const edge = requireEdge(project, this.assetId, this.edgeId);
this.previous = edge.folded === true;
this.apply(edge, this.folded);
}
undo(project: Project): void {
this.apply(this.edge(project), this.previous);
}
private edge(project: Project): GraphEdge {
const edge = requireGraph(project, this.assetId).edges.find((e) => e.id === this.edgeId);
if (!edge) throw new CommandError(`Edge ${this.edgeId} not found`);
return edge;
this.apply(requireEdge(project, this.assetId, this.edgeId), this.previous);
}
private apply(edge: GraphEdge, folded: boolean): void {
@@ -254,19 +248,13 @@ export class NameTransitionCommand implements Command {
) {}
execute(project: Project): void {
const edge = this.edge(project);
const edge = requireEdge(project, this.assetId, this.edgeId);
this.previous = edge.label ?? '';
this.apply(edge, this.name.trim());
}
undo(project: Project): void {
this.apply(this.edge(project), this.previous);
}
private edge(project: Project): GraphEdge {
const edge = requireGraph(project, this.assetId).edges.find((e) => e.id === this.edgeId);
if (!edge) throw new CommandError(`Edge ${this.edgeId} not found`);
return edge;
this.apply(requireEdge(project, this.assetId, this.edgeId), this.previous);
}
private apply(edge: GraphEdge, name: string): void {
+7 -1
View File
@@ -1,5 +1,5 @@
/** Lookups that throw a `CommandError` when the target does not exist. */
import { findNode, type Graph, type GraphNode } from '../graph/Graph';
import { findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph';
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
import type { Asset, BehaviorAsset, StateMachineAsset, VariablesAsset } from '../model/Asset';
import { findObject, type ObjectTree } from '../model/hierarchy';
@@ -98,6 +98,12 @@ export function requireNode(project: Project, assetId: string, nodeId: string):
return node;
}
export function requireEdge(project: Project, assetId: string, edgeId: string): GraphEdge {
const edge = requireGraph(project, assetId).edges.find((e) => e.id === edgeId);
if (!edge) throw new CommandError(`Edge ${edgeId} not found`);
return edge;
}
/** A `CommandError`, so an unknown type becomes a notice instead of a crash. */
export function requireNodeDef(type: string): NodeDef {
const def = nodeDef(type);
+12
View File
@@ -139,6 +139,18 @@ export function edgesOfNode(graph: Graph, nodeId: string): GraphEdge[] {
return graph.edges.filter((e) => e.from.node === nodeId || e.to.node === nodeId);
}
/**
* What a compiled graph (shader, particles) depends on and nothing else: node ids, types, params
* and declared ports, and which port feeds which. Positions, collapsing, folding and the boxes are
* the editor's; they stay out so a moved node does not compile again (#65).
*/
export function graphFingerprint(graph: Graph): string {
return JSON.stringify({
nodes: graph.nodes.map((n) => [n.id, n.type, n.params, n.ports ?? null]),
edges: graph.edges.map((e) => [e.from.node, e.from.port, e.to.node, e.to.port]),
});
}
export function portsCompatible(from: PortType, to: PortType): boolean {
if (from === 'exec' || to === 'exec') return from === to;
return from === to || from === 'any' || to === 'any';
-5
View File
@@ -1,4 +1,3 @@
import type { FontAsset } from './Asset';
import { findAsset, type Project } from './Project';
/** Mirrors `FontStore::isValidName` on the server, where the name is derived from the bytes. */
@@ -23,7 +22,3 @@ export function fontFileOf(project: Project, id: string): string {
const asset = findAsset(project, id);
return asset?.type === 'font' ? asset.file : '';
}
export function isFontAsset(asset: { type: string }): asset is FontAsset {
return asset.type === 'font';
}
-5
View File
@@ -1,4 +1,3 @@
import type { SoundAsset } from './Asset';
import { findAsset, type Project } from './Project';
/** Mirrors `SoundStore::isValidName` on the server, where the name is derived from the bytes. */
@@ -14,7 +13,3 @@ export function soundFileOf(project: Project, id: string): string {
const asset = findAsset(project, id);
return asset?.type === 'sound' ? asset.file : '';
}
export function isSoundAsset(asset: { type: string }): asset is SoundAsset {
return asset.type === 'sound';
}
-12
View File
@@ -38,18 +38,6 @@ export interface ParticlesBuild {
reason: string;
}
/**
* What the motion depends on and nothing else: node ids, types, params and declared ports, and
* which port feeds which. Positions and folding are the editor's, so a moved node does not compile
* again – the same rule the shader fingerprint follows (#65).
*/
export function particlesFingerprint(graph: Graph): string {
return JSON.stringify({
nodes: graph.nodes.map((n) => [n.id, n.type, n.params, n.ports ?? null]),
edges: graph.edges.map((e) => [e.from.node, e.from.port, e.to.node, e.to.port]),
});
}
/**
* A particle graph to one function per output: every node reachable from the Output becomes one
* local per output, in dependency order, so a node feeding two others is evaluated once per
-4
View File
@@ -74,7 +74,3 @@ export function liveRange(settings: EmitterSettings, time: number, emitUntil = t
if (oldest > newest) return NO_PARTICLES;
return { from: Math.max(oldest, newest - Math.floor(max) + 1), to: newest };
}
export function liveCount(range: LiveRange): number {
return Math.max(0, range.to - range.from + 1);
}
+57
View File
@@ -0,0 +1,57 @@
import type { Graph, GraphNode } from '../graph/Graph';
import type { PropertyBag } from '../model/Properties';
import type { FlatDef } from './PropertySchema';
/** The params of a `Property` node in a shader or particle graph: its name and the row it declares. */
export const PROPERTY_NAME_PARAM = 'name';
export const PROPERTY_DEFAULT_PARAM = 'default';
export const PROPERTY_MIN_PARAM = 'min';
export const PROPERTY_MAX_PARAM = 'max';
export interface Declaration {
isDeclaration(node: GraphNode): boolean;
nameOf(node: GraphNode): string;
isValidName(name: string): boolean;
}
/**
* The number rows a graph declares, in node order: one per declaring node with a valid name. A
* second node of the same name declares nothing new – both read the one value.
*/
export function declaredNumberRows(graph: Graph, of: Declaration): FlatDef<'number'>[] {
const rows: FlatDef<'number'>[] = [];
const seen = new Set<string>();
for (const node of graph.nodes) {
if (!of.isDeclaration(node)) continue;
const name = of.nameOf(node);
if (!of.isValidName(name) || seen.has(name)) continue;
seen.add(name);
rows.push(numberRow(name, node.params));
}
return rows;
}
function numberRow(name: string, params: PropertyBag): FlatDef<'number'> {
const min = numberParam(params, PROPERTY_MIN_PARAM, 0);
const max = numberParam(params, PROPERTY_MAX_PARAM, 1);
const row: FlatDef<'number'> = {
key: name,
label: name,
kind: 'number',
default: numberParam(params, PROPERTY_DEFAULT_PARAM, 0),
min,
step: niceStep(max > min ? max - min : 1),
};
// A max at or below the min would pin the value; treated as "no max" instead.
return max > min ? { ...row, max } : row;
}
function numberParam(params: PropertyBag, key: string, fallback: number): number {
const n = Number(params[key]);
return Number.isFinite(n) ? n : fallback;
}
/** A twentieth of the range's power of ten: 0.05 for 0-4, 0.05 for 0-1, 5 for 0-100. */
function niceStep(range: number): number {
return 10 ** Math.floor(Math.log10(range)) / 20;
}
+14 -42
View File
@@ -1,13 +1,17 @@
import type { Graph, GraphNode } from '../graph/Graph';
import { type PropertyBag, textOf } from '../model/Properties';
import { declaredNumberRows, PROPERTY_NAME_PARAM } from './declaredRows';
import { type FlatDef, isValidFieldName } from './PropertySchema';
export {
PROPERTY_DEFAULT_PARAM,
PROPERTY_MAX_PARAM,
PROPERTY_MIN_PARAM,
PROPERTY_NAME_PARAM,
} from './declaredRows';
/** The node of a particle graph that declares a parameter: a row on every source placed from the asset. */
export const PARTICLE_PROPERTY = 'ParticleProperty';
export const PROPERTY_NAME_PARAM = 'name';
export const PROPERTY_DEFAULT_PARAM = 'default';
export const PROPERTY_MIN_PARAM = 'min';
export const PROPERTY_MAX_PARAM = 'max';
/** The rows a particle source has of its own, before the ones its graph declares. */
export const EMITTING_KEY = 'emitting';
@@ -65,43 +69,11 @@ const RESERVED_KEYS: ReadonlySet<string> = new Set([
MOVE_WITH_SOURCE_KEY,
]);
/**
* The parameters a particle graph declares, in node order: one number row per `Property` node with
* a usable name. A second node of the same name declares nothing new – both read the one value.
*/
/** The parameters a particle graph declares: a row on every source placed from the asset. */
export function particleParamDefs(graph: Graph): FlatDef<'number'>[] {
const rows: FlatDef<'number'>[] = [];
const seen = new Set<string>();
for (const node of graph.nodes) {
if (!isParticlePropertyNode(node)) continue;
const name = propertyNameOf(node);
if (!isValidParticlePropertyName(name) || seen.has(name)) continue;
seen.add(name);
rows.push(propertyRow(name, node.params));
}
return rows;
}
function propertyRow(name: string, params: PropertyBag): FlatDef<'number'> {
const min = numberParam(params, PROPERTY_MIN_PARAM, 0);
const max = numberParam(params, PROPERTY_MAX_PARAM, 1);
const row: FlatDef<'number'> = {
key: name,
label: name,
kind: 'number',
default: numberParam(params, PROPERTY_DEFAULT_PARAM, 0),
min,
step: niceStep(max > min ? max - min : 1),
};
// A max at or below the min would pin the value; treated as "no max" instead.
return max > min ? { ...row, max } : row;
}
function numberParam(params: PropertyBag, key: string, fallback: number): number {
const n = Number(params[key]);
return Number.isFinite(n) ? n : fallback;
}
function niceStep(range: number): number {
return 10 ** Math.floor(Math.log10(range)) / 20;
return declaredNumberRows(graph, {
isDeclaration: isParticlePropertyNode,
nameOf: propertyNameOf,
isValidName: isValidParticlePropertyName,
});
}
+14 -43
View File
@@ -1,7 +1,15 @@
import type { Graph, GraphNode } from '../graph/Graph';
import { type PropertyBag, textOf } from '../model/Properties';
import { declaredNumberRows, PROPERTY_NAME_PARAM } from './declaredRows';
import { type FlatDef, isSpriteProp, isValidFieldName } from './PropertySchema';
export {
PROPERTY_DEFAULT_PARAM,
PROPERTY_MAX_PARAM,
PROPERTY_MIN_PARAM,
PROPERTY_NAME_PARAM,
} from './declaredRows';
/** The sprite property naming the shader asset it wears; `''` is no shader and no render pass. */
export const SHADER_KEY = 'shader';
@@ -10,10 +18,6 @@ export const SHADER_KEY = 'shader';
* GLSL uniform, `default`/`min`/`max` are the row the inspector shows.
*/
export const SHADER_PROPERTY = 'ShaderProperty';
export const PROPERTY_NAME_PARAM = 'name';
export const PROPERTY_DEFAULT_PARAM = 'default';
export const PROPERTY_MIN_PARAM = 'min';
export const PROPERTY_MAX_PARAM = 'max';
export function shaderOf(bag: PropertyBag): string {
return textOf(bag[SHADER_KEY]);
@@ -36,46 +40,13 @@ export function isValidPropertyName(name: string): boolean {
return isValidFieldName(name) && !isSpriteProp(name);
}
/**
* The properties a shader graph declares, in node order: one number row per `Property` node with
* a valid name. A second node with the same name declares nothing new – both read one uniform.
*/
/** The properties a shader graph declares: one row on the sprite and one uniform each. */
export function shaderParamDefs(graph: Graph): FlatDef<'number'>[] {
const rows: FlatDef<'number'>[] = [];
const seen = new Set<string>();
for (const node of graph.nodes) {
if (!isPropertyNode(node)) continue;
const name = propertyName(node);
if (!isValidPropertyName(name) || seen.has(name)) continue;
seen.add(name);
rows.push(propertyRow(name, node.params));
}
return rows;
}
function propertyRow(name: string, params: PropertyBag): FlatDef<'number'> {
const min = numberParam(params, PROPERTY_MIN_PARAM, 0);
const max = numberParam(params, PROPERTY_MAX_PARAM, 1);
const row: FlatDef<'number'> = {
key: name,
label: name,
kind: 'number',
default: numberParam(params, PROPERTY_DEFAULT_PARAM, 0),
min,
step: niceStep(max > min ? max - min : 1),
};
// A max at or below the min would pin the value; treated as "no max" instead.
return max > min ? { ...row, max } : row;
}
function numberParam(params: PropertyBag, key: string, fallback: number): number {
const n = Number(params[key]);
return Number.isFinite(n) ? n : fallback;
}
/** A twentieth of the range's power of ten: 0.05 for 0-4, 0.05 for 0-1, 5 for 0-100. */
function niceStep(range: number): number {
return 10 ** Math.floor(Math.log10(range)) / 20;
return declaredNumberRows(graph, {
isDeclaration: isPropertyNode,
nameOf: propertyName,
isValidName: isValidPropertyName,
});
}
/** What a property placed from the palette is called: `value`, then `value2`, `value3` … */
-4
View File
@@ -99,10 +99,6 @@ export function worldPosition(project: Project, tree: ObjectTree, object: SceneO
return positionOf(worldTransform(project, tree, object));
}
export function editorPosition(project: Project, tree: ObjectTree, object: SceneObject): Position {
return positionOf(editorTransform(project, tree, object));
}
/** The world transform of a parent ('' = top level, the identity). */
export function parentTransform(project: Project, tree: ObjectTree, parentId: string): ChainTransform {
const parent = parentId ? findObject(tree, parentId) : undefined;
-12
View File
@@ -54,18 +54,6 @@ export interface ShaderBuild {
* becomes one local per output, in dependency order, and every Property a float uniform. The texel
* arrives premultiplied, so the prelude unpremultiplies and the epilogue multiplies back.
*/
/**
* What the fragment depends on and nothing else: node ids, types, params and declared ports, and
* which port feeds which. Positions, collapsing and folding are the editor's; they stay out so a
* moved node does not compile again (#65).
*/
export function shaderFingerprint(graph: Graph): string {
return JSON.stringify({
nodes: graph.nodes.map((n) => [n.id, n.type, n.params, n.ports ?? null]),
edges: graph.edges.map((e) => [e.from.node, e.from.port, e.to.node, e.to.port]),
});
}
export function compileShader(graph: Graph): ShaderBuild {
const problems: Record<string, string> = {};
const problem = (node: GraphNode, text: string) => {
+28
View File
@@ -0,0 +1,28 @@
/**
* What the server answers for an upload: the content-addressed name it stored the bytes under
* (`{sha256}.{ext}`, see `isTextureFile`/`isFontFile`/`isSoundFile`) and the caption kept beside it.
*/
export interface UploadedFile {
file: string;
/** The name the file was uploaded under, '' when the server knows none. Never a path. */
name: string;
bytes: number;
}
/** A file the server holds, whether any project references it or not. */
export interface StoredFile extends UploadedFile {
updatedAt: string;
}
/**
* Raw bytes rather than a multipart form: the server derives name and format from the file itself,
* so there is nothing else to send. `type` only fills the Content-Type header – the server trusts
* the bytes, not the label – and uploading the same bytes twice yields the same file, the hash of
* its content. `name` is the caption kept beside it; uploading the same bytes under a new name
* renames what the pickers show.
*/
export interface FileStore<U extends UploadedFile = UploadedFile, S extends StoredFile = StoredFile> {
upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<U>;
list(): Promise<S[]>;
delete(file: string): Promise<void>;
}
+6 -23
View File
@@ -1,26 +1,9 @@
export interface UploadedFont {
/** Content-addressed name in `data/fonts/`: `{sha256}.{ttf|otf|woff|woff2}` (see `isFontFile`). */
file: string;
/** The name the file was uploaded under, '' when the server knows none. Never a path. */
name: string;
bytes: number;
}
/** A typeface the server holds, whether any project references it or not. */
export interface StoredFont {
file: string;
name: string;
bytes: number;
updatedAt: string;
}
import type { FileStore, StoredFile, UploadedFile } from './FileStore';
/**
* Raw bytes, like an image upload: the server derives name and format from the file itself. Unlike
* an image a font is never re-encoded on the way, so what the server stores is what was picked.
* Stored in `data/fonts/` as `{sha256}.{ttf|otf|woff|woff2}`. Unlike an image a font is never
* re-encoded on the way, so what the server stores is what was picked.
*/
export interface FontStore {
/** `name` is kept beside the file as its caption; the stored file is always the hash. */
upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<UploadedFont>;
list(): Promise<StoredFont[]>;
delete(file: string): Promise<void>;
}
export type UploadedFont = UploadedFile;
export type StoredFont = StoredFile;
export type FontStore = FileStore<UploadedFont, StoredFont>;
+31
View File
@@ -0,0 +1,31 @@
import type { FileStore, StoredFile, UploadedFile } from './FileStore';
import { apiRequest } from './http';
/** The API's three file collections, one route each; textures, fonts and sounds differ in nothing else. */
export type FileRoute = 'textures' | 'fonts' | 'sounds';
export class HttpFileStore<
U extends UploadedFile = UploadedFile,
S extends StoredFile = StoredFile,
> implements FileStore<U, S> {
constructor(
private readonly endpoint: string,
private readonly route: FileRoute,
) {}
async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise<U> {
return apiRequest<U>(this.endpoint, 'POST', this.route, {
headers: { 'Content-Type': type },
body: bytes,
query: { name },
});
}
async list(): Promise<S[]> {
return apiRequest<S[]>(this.endpoint, 'GET', this.route, {});
}
async delete(file: string): Promise<void> {
await apiRequest<void>(this.endpoint, 'DELETE', `${this.route}/${encodeURIComponent(file)}`, {});
}
}
-30
View File
@@ -1,30 +0,0 @@
import type { FontStore, StoredFont, UploadedFont } from './FontStore';
import { apiRequest } from './http';
export class HttpFontStore implements FontStore {
constructor(private readonly endpoint: string) {}
async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise<UploadedFont> {
return this.request<UploadedFont>('POST', 'fonts', { bytes, type, name });
}
async list(): Promise<StoredFont[]> {
return this.request<StoredFont[]>('GET', 'fonts');
}
async delete(file: string): Promise<void> {
await this.request<void>('DELETE', `fonts/${encodeURIComponent(file)}`);
}
private async request<T>(
method: string,
route: string,
payload?: { bytes: Blob | ArrayBuffer; type: string; name: string },
): Promise<T> {
return apiRequest<T>(this.endpoint, method, route, {
headers: payload ? { 'Content-Type': payload.type } : {},
body: payload?.bytes,
query: payload ? { name: payload.name } : undefined,
});
}
}
-30
View File
@@ -1,30 +0,0 @@
import { apiRequest } from './http';
import type { SoundStore, StoredSound, UploadedSound } from './SoundStore';
export class HttpSoundStore implements SoundStore {
constructor(private readonly endpoint: string) {}
async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise<UploadedSound> {
return this.request<UploadedSound>('POST', 'sounds', { bytes, type, name });
}
async list(): Promise<StoredSound[]> {
return this.request<StoredSound[]>('GET', 'sounds');
}
async delete(file: string): Promise<void> {
await this.request<void>('DELETE', `sounds/${encodeURIComponent(file)}`);
}
private async request<T>(
method: string,
route: string,
payload?: { bytes: Blob | ArrayBuffer; type: string; name: string },
): Promise<T> {
return apiRequest<T>(this.endpoint, method, route, {
headers: payload ? { 'Content-Type': payload.type } : {},
body: payload?.bytes,
query: payload ? { name: payload.name } : undefined,
});
}
}
-30
View File
@@ -1,30 +0,0 @@
import { apiRequest } from './http';
import type { StoredTexture, TextureStore, UploadedTexture } from './TextureStore';
export class HttpTextureStore implements TextureStore {
constructor(private readonly endpoint: string) {}
async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise<UploadedTexture> {
return this.request<UploadedTexture>('POST', 'textures', { bytes, type, name });
}
async list(): Promise<StoredTexture[]> {
return this.request<StoredTexture[]>('GET', 'textures');
}
async delete(file: string): Promise<void> {
await this.request<void>('DELETE', `textures/${encodeURIComponent(file)}`);
}
private async request<T>(
method: string,
route: string,
payload?: { bytes: Blob | ArrayBuffer; type: string; name: string },
): Promise<T> {
return apiRequest<T>(this.endpoint, method, route, {
headers: payload ? { 'Content-Type': payload.type } : {},
body: payload?.bytes,
query: payload ? { name: payload.name } : undefined,
});
}
}
+5 -23
View File
@@ -1,27 +1,9 @@
export interface UploadedSound {
/** Content-addressed name in `data/sounds/`: `{sha256}.{mp3|ogg|wav|m4a}` (see `isSoundFile`). */
file: string;
/** The name the file was uploaded under, '' when the server knows none. Never a path. */
name: string;
bytes: number;
}
/** A sound the server holds, whether any project references it or not. */
export interface StoredSound {
file: string;
name: string;
bytes: number;
updatedAt: string;
}
import type { FileStore, StoredFile, UploadedFile } from './FileStore';
/**
* Raw bytes, like a font upload: the server derives name and format from the file itself. A sound
* is never re-encoded on the way, so what the server stores is what was picked – and how long it
* Stored in `data/sounds/` as `{sha256}.{mp3|ogg|wav|m4a}`, never re-encoded. How long a sound
* plays is nowhere in here, because only a decoder knows that.
*/
export interface SoundStore {
/** `name` is kept beside the file as its caption; the stored file is always the hash. */
upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<UploadedSound>;
list(): Promise<StoredSound[]>;
delete(file: string): Promise<void>;
}
export type UploadedSound = UploadedFile;
export type StoredSound = StoredFile;
export type SoundStore = FileStore<UploadedSound, StoredSound>;
+9 -29
View File
@@ -1,38 +1,18 @@
export interface UploadedTexture {
/** Content-addressed name in `data/textures/`: `{sha256}.{png|jpg|webp|gif}` (see `isTextureFile`). */
file: string;
/**
* The name the file was uploaded under, '' when the server knows none. A picture may be
* re-encoded on the way, so this can be a `.png` name on a file stored as `.webp`.
*/
name: string;
import type { FileStore, StoredFile, UploadedFile } from './FileStore';
/**
* Stored in `data/textures/` as `{sha256}.{png|jpg|webp|gif}`. A picture may be re-encoded on the
* way, so `name` can be a `.png` name on a file stored as `.webp`.
*/
export interface UploadedTexture extends UploadedFile {
width: number;
height: number;
bytes: number;
}
/** A file the server holds, whether any project references it or not. */
export interface StoredTexture {
file: string;
name: string;
export interface StoredTexture extends StoredFile {
/** 0 × 0 when the server could not read the header; such a file cannot become an asset. */
width: number;
height: number;
bytes: number;
updatedAt: string;
}
/**
* An upload is raw bytes rather than a multipart form: name, size and format are derived from the
* image itself on the server, so there is nothing else to send.
*/
export interface TextureStore {
/**
* `type` only fills the Content-Type header – the server trusts the bytes, not the label – and
* uploading the same picture twice yields the same file, the hash of its content. `name` is the
* caption kept beside it; uploading the same bytes under a new name renames what pickers show.
*/
upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<UploadedTexture>;
list(): Promise<StoredTexture[]>;
delete(file: string): Promise<void>;
}
export type TextureStore = FileStore<UploadedTexture, StoredTexture>;