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>
179 lines
7.5 KiB
TypeScript
179 lines
7.5 KiB
TypeScript
/**
|
||
* Pictures, typefaces and sounds all arrive the same way: a file goes up to the server, comes back
|
||
* as a content-addressed name, and an asset is made for it – or the asset already naming those
|
||
* bytes is used. What differs between them is an `ImportKind`; the pipeline is this file.
|
||
*/
|
||
import type { Command } from '../core/commands/Command';
|
||
import { nextAssetName } from '../core/commands/naming';
|
||
import type { AssetType } from '../core/model/AssetTypes';
|
||
import type { FileStore, UploadedFile } from '../core/store/FileStore';
|
||
import type { EditorState } from './state';
|
||
|
||
/** One file's outcome; exactly one of `assetId` and `error` is set. */
|
||
export interface ImportOutcome {
|
||
name: string;
|
||
assetId: string | null;
|
||
error: string | null;
|
||
/** The project already had this exact file, so its existing asset was used instead. */
|
||
reused?: boolean;
|
||
}
|
||
|
||
export interface ImportDeps {
|
||
state: EditorState;
|
||
}
|
||
|
||
export interface ImportKind<U extends UploadedFile, D extends ImportDeps> {
|
||
type: AssetType;
|
||
/** What the notices call one: "image", "font", "sound". */
|
||
noun: string;
|
||
/** What an asset is called when the file name gives nothing to go on. */
|
||
fallbackName: string;
|
||
store(deps: D): FileStore<U>;
|
||
/** The bytes to send, and their Content-Type; throws with the reason when the file cannot go up. */
|
||
prepare(file: File): Promise<{ blob: Blob; type: string }>;
|
||
create(name: string, uploaded: U): Command & { asset: { id: string } };
|
||
}
|
||
|
||
/**
|
||
* Imports the files sequentially, so each asset is named against the project as the previous one
|
||
* left it. A file that fails is reported in the notice; the remaining ones are still imported.
|
||
*/
|
||
export async function importFiles<U extends UploadedFile, D extends ImportDeps>(
|
||
kind: ImportKind<U, D>,
|
||
files: readonly File[],
|
||
deps: D,
|
||
): Promise<ImportOutcome[]> {
|
||
if (files.length === 0) return [];
|
||
const outcomes: ImportOutcome[] = [];
|
||
for (const file of files) outcomes.push(await importFile(kind, file, deps));
|
||
// The asset the batch created last, so the panel shows what just arrived.
|
||
const created = outcomes.filter((outcome) => outcome.assetId !== null);
|
||
const last = created[created.length - 1];
|
||
if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId });
|
||
deps.state.setNotice(importNotice(outcomes, kind.noun));
|
||
return outcomes;
|
||
}
|
||
|
||
async function importFile<U extends UploadedFile, D extends ImportDeps>(
|
||
kind: ImportKind<U, D>,
|
||
file: File,
|
||
deps: D,
|
||
): Promise<ImportOutcome> {
|
||
try {
|
||
const { blob, type } = await kind.prepare(file);
|
||
const uploaded = await kind.store(deps).upload(blob, type, file.name);
|
||
return adopt(kind, uploaded, assetNameForFile(file.name, kind.fallbackName), file.name, deps);
|
||
} catch (error) {
|
||
return { name: file.name, assetId: null, error: error instanceof Error ? error.message : String(error) };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Makes an asset for a file the server holds. `label` names the source in messages, `nameBase` is
|
||
* what the asset is called. The server stores by content hash, so a re-imported file comes back
|
||
* as the one the project already references; two assets on one file would disagree about it.
|
||
*/
|
||
function adopt<U extends UploadedFile, D extends ImportDeps>(
|
||
kind: ImportKind<U, D>,
|
||
uploaded: U,
|
||
nameBase: string,
|
||
label: string,
|
||
deps: D,
|
||
): ImportOutcome {
|
||
const existing = deps.state.project.assets.find(
|
||
(a) => a.type === kind.type && 'file' in a && a.file === uploaded.file,
|
||
);
|
||
if (existing) return { name: label, assetId: existing.id, error: null, reused: true };
|
||
|
||
const command = kind.create(nextAssetName(deps.state.project, nameBase), uploaded);
|
||
// A rejected command has already put its reason into the notice; that is the message here.
|
||
if (!deps.state.execute(command)) return { name: label, assetId: null, error: deps.state.notice };
|
||
return { name: label, assetId: command.asset.id, error: null, reused: false };
|
||
}
|
||
|
||
/**
|
||
* Adopts a file the server already holds – no upload, the bytes are there. Nothing collects
|
||
* unreferenced files, so one project's dropped picture is still there for the next to pick up.
|
||
* Chosen in the picker, so this is "+ → Texture": the tab opens like for any created asset (a kind
|
||
* without a tab is just selected). A dropped file only selects – it usually goes into the scene.
|
||
*/
|
||
export function importStored<U extends UploadedFile, D extends ImportDeps>(
|
||
kind: ImportKind<U, D>,
|
||
uploaded: U,
|
||
deps: D,
|
||
): ImportOutcome {
|
||
// Named after the file it was uploaded as, like a dropped one; a file from before names were
|
||
// kept has none and falls back to the kind's own word.
|
||
const nameBase = assetNameForFile(uploaded.name, kind.fallbackName);
|
||
const outcome = adopt(kind, uploaded, nameBase, uploaded.name || uploaded.file, deps);
|
||
if (outcome.assetId) deps.state.openAsset(outcome.assetId);
|
||
deps.state.setNotice(
|
||
outcome.error ??
|
||
(outcome.reused
|
||
? `That ${kind.noun} is already in the project.`
|
||
: `Added the ${kind.noun} to the project.`),
|
||
);
|
||
return outcome;
|
||
}
|
||
|
||
/** One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie. */
|
||
export function importNotice(outcomes: readonly ImportOutcome[], noun = 'image'): string {
|
||
const added = outcomes.filter((outcome) => outcome.assetId !== null && !outcome.reused).length;
|
||
const reused = outcomes.filter((outcome) => outcome.reused).length;
|
||
const failed = outcomes.filter((outcome) => outcome.error !== null);
|
||
const first = failed[0];
|
||
|
||
const known = reused === 0 ? '' : ` ${reused === 1 ? 'One was' : `${reused} were`} already in the project.`;
|
||
|
||
if (!first) {
|
||
if (added === 0 && reused > 0)
|
||
return reused === 1
|
||
? `That ${noun} is already in the project.`
|
||
: `Those ${reused} ${noun}s are already in the project.`;
|
||
return `Imported ${added} ${added === 1 ? noun : `${noun}s`}.${known}`;
|
||
}
|
||
const more = failed.length > 1 ? ` (and ${failed.length - 1} more)` : '';
|
||
const problem = `Could not import "${first.name}": ${first.error ?? ''}${more}`;
|
||
if (added + reused === 0) return problem;
|
||
return `Imported ${added + reused} of ${outcomes.length}.${known} ${problem}`;
|
||
}
|
||
|
||
/**
|
||
* A file name without its extension, as an asset is called: "hero-idle.png" reads better than
|
||
* "Texture 3". The picker gets the same name back from the server.
|
||
*/
|
||
export function assetNameForFile(fileName: string, fallback: string): string {
|
||
return fileName.replace(/\.[^.]+$/, '').trim() || fallback;
|
||
}
|
||
|
||
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
|
||
export function pickFiles(accept: string): Promise<File[]> {
|
||
return new Promise((resolve) => {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = accept;
|
||
input.multiple = true;
|
||
input.style.display = 'none';
|
||
document.body.appendChild(input);
|
||
const finish = (files: File[]) => {
|
||
input.remove();
|
||
resolve(files);
|
||
};
|
||
input.addEventListener('change', () => finish([...(input.files ?? [])]));
|
||
// Without `cancel` a dismissed dialog leaves both the element and this promise behind.
|
||
input.addEventListener('cancel', () => finish([]));
|
||
input.click();
|
||
});
|
||
}
|
||
|
||
/** The *Upload…* button: the file dialog, then the batch import of whatever it returns. */
|
||
export function pickAndImport<U extends UploadedFile, D extends ImportDeps>(
|
||
kind: ImportKind<U, D>,
|
||
accept: string,
|
||
deps: D,
|
||
): void {
|
||
void pickFiles(accept)
|
||
.then((files) => importFiles(kind, files, deps))
|
||
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
|
||
}
|