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

+18 -76
View File
@@ -1,9 +1,7 @@
import { CreateSoundCommand } from '../core/commands/assetCommands';
import { nextAssetName } from '../core/commands/naming';
import type { SoundAsset } from '../core/model/Asset';
import type { SoundStore, UploadedSound } from '../core/store/SoundStore';
import { type ImportKind, type ImportOutcome, importFiles, importStored, pickAndImport } from './assetImport';
import type { EditorState } from './state';
import { type ImportOutcome, assetNameForFile, importNotice } from './textureImport';
/** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */
export const SOUND_ACCEPT = '.mp3,.ogg,.wav,.m4a';
@@ -23,83 +21,27 @@ export interface SoundImportDeps {
sounds: SoundStore;
}
/** Sequential like `importImages`, so each asset is named against the project as it then stands. */
export async function importSounds(files: readonly File[], deps: SoundImportDeps): Promise<ImportOutcome[]> {
if (files.length === 0) return [];
const outcomes: ImportOutcome[] = [];
for (const file of files) outcomes.push(await importSound(file, deps));
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, 'sound'));
return outcomes;
const SOUND_KIND: ImportKind<UploadedSound, SoundImportDeps> = {
type: 'sound',
noun: 'sound',
fallbackName: 'Sound',
store: (deps) => deps.sounds,
// The bytes go up untouched: nothing here could encode audio, and a re-encode would be a loss.
prepare: (file) =>
isSoundFileName(file.name)
? Promise.resolve({ blob: file, type: file.type || 'application/octet-stream' })
: Promise.reject(new Error('Not a sound file – use MP3, OGG, WAV or M4A.')),
create: (name, sound) => new CreateSoundCommand(name, sound.file),
};
export function importSounds(files: readonly File[], deps: SoundImportDeps): Promise<ImportOutcome[]> {
return importFiles(SOUND_KIND, files, deps);
}
async function importSound(file: File, deps: SoundImportDeps): Promise<ImportOutcome> {
try {
if (!isSoundFileName(file.name)) throw new Error('Not a sound file – use MP3, OGG, WAV or M4A.');
// The bytes go up untouched: nothing here could encode audio, and a re-encode would be a loss.
const uploaded = await deps.sounds.upload(file, file.type || 'application/octet-stream', file.name);
return adopt(uploaded, assetNameForFile(file.name, 'Sound'), file.name, deps);
} catch (error) {
return {
name: file.name,
assetId: null,
error: error instanceof Error ? error.message : String(error),
};
}
}
/** Makes an asset for a sound the server holds, or reuses the one already naming those bytes. */
function adopt(sound: UploadedSound, nameBase: string, label: string, deps: SoundImportDeps): ImportOutcome {
const existing = deps.state.project.assets.find(
(asset): asset is SoundAsset => asset.type === 'sound' && asset.file === sound.file,
);
if (existing) return { name: label, assetId: existing.id, error: null, reused: true };
const name = nextAssetName(deps.state.project, nameBase);
const command = new CreateSoundCommand(name, sound.file);
// 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 sound the server already holds – no upload, the bytes are there. Chosen in the picker,
* so this is "+ → Sound" and the asset is selected the way a created one would be.
*/
export function importStoredSound(sound: UploadedSound, deps: SoundImportDeps): ImportOutcome {
const outcome = adopt(sound, assetNameForFile(sound.name, 'Sound'), sound.name || sound.file, deps);
if (outcome.assetId) deps.state.select({ kind: 'asset', assetId: outcome.assetId });
deps.state.setNotice(
outcome.error ??
(outcome.reused ? 'That sound is already in the project.' : 'Added the sound to the project.'),
);
return outcome;
}
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
export function pickSoundFiles(): Promise<File[]> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = SOUND_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();
});
return importStored(SOUND_KIND, sound, deps);
}
export function pickAndImportSounds(deps: SoundImportDeps): void {
void pickSoundFiles()
.then((files) => importSounds(files, deps))
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
pickAndImport(SOUND_KIND, SOUND_ACCEPT, deps);
}