Files
feuerwerk/src/editor/inspector/assetSection.ts
T
adminandClaude Opus 5.5 f1f4a57589
game-maker / gate (push) Successful in 3m32s
game-maker / suite (push) Failing after 18m20s
Cut a sound to a stretch of its file in its own Sound tab (#343)
A sound asset now opens in a tab that shows its file as a waveform.
Two handles set where the sound starts and ends in the file, and that
stretch is the sample: Play sound, a song item and an instrument's
Sample all play it and nothing else. The file itself is never changed.

- The asset has three new rows: Start (s), End (s) and Edge fade (ms).
  End 0 is the end of the file, the fade is 10 ms by default. Older
  assets have no values and play the whole file as before.
- Every way of playing asks playedSoundOf() for a name instead of the
  file: the file, or <file>#<start>~<end>~<fade> for a cut.
- SoundLibrary makes the cut from the decoded file the first time that
  name plays and ramps each edge inside the file over the fade. Length
  and peaks of a cut are read from the file without a copy.
- Cut buffers can be made again at any time, so the library lets the
  oldest go past 64 Mi samples. A voice holds its buffers itself.
- The tab draws min/max per pixel from 256-sample block peaks, and the
  samples as a line when zoomed in far. Wheel zooms, the middle button
  pans, as in the music tabs. Wave, cut and ruler sit under a mask,
  so a handle panned out of view does not draw over the panels beside.
- Dragging a handle moves that edge, dragging elsewhere spans a new
  stretch. One drag is one undo step for both edges. Whole file puts
  the handles back, Play or Space plays the stretch with a playhead.
- The inspector preview and the Sound FX preview play the cut too, and
  the song lanes draw and repeat it.

Tests:
- Unit: sounds 20, wave-view 8 (new), sound-library 68, plus 258 in
  the touched areas (instrument, song, pattern, inspector, tabs)
- e2e 34 on melektaus-atx: ALL PASSED, 57 checks (7 new)
- Gate on melektaus-atx: CHECK EXIT 0, 253 files, 3936 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:42:21 +02:00

497 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
RenameAssetCommand,
ResetAssetPropertyCommand,
SetAssetPropertyCommand,
SetMachineScopeCommand,
SetVariablesPersistentCommand,
SetVariablesScopeCommand,
} from '../../core/commands/assetCommands';
import { bagWithoutObjects } from '../../core/graph/liveObjects';
import { isPersistent } from '../../core/graph/savedValues';
import { nextFieldName, nextVariableName } from '../../core/commands/naming';
import {
AddStructFieldCommand,
AddVariableCommand,
MoveStructFieldCommand,
MoveVariableCommand,
RemoveStructFieldCommand,
RemoveVariableCommand,
RenameStructFieldCommand,
RenameVariableCommand,
SetVariableKeptCommand,
SetVariablePrivateCommand,
} from '../../core/commands/variableCommands';
import type { Command } from '../../core/commands/Command';
import {
type Asset,
isKeptVariable,
isPrivateVariable,
isVariableOwner,
type StateMachineAsset,
type VariableOwnerAsset,
type VariablesAsset,
} from '../../core/model/Asset';
import { RENDER_LAYER_KEY } from '../../core/model/renderLayers';
import {
MakeListItemTypeCommand,
MakeStructTypeCommand,
SetListItemTypeCommand,
SetStructTypeCommand,
WithStructTypes,
} from '../../core/commands/structTypeCommands';
import { containsType, type FieldRename, structAssetOf } from '../../core/model/structTypes';
import { findAsset, type Project } from '../../core/model/Project';
import type { PropertyBag, PropertyValue } from '../../core/model/Properties';
import { readScope, readSharedScope, type Scope, scopeOf, sharedScopeOf } from '../../core/model/Scope';
import { type NestedKind, VARIABLE_KINDS } from '../../core/properties/kinds';
import {
COMMON_PROPS,
isRowShown,
type PropertyDef,
schemaForAsset,
type StructFieldDef,
} from '../../core/properties/PropertySchema';
import { markedAssetSchema } from '../../core/properties/deviation';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Button, Dropdown, Label, Menu, type MenuEntry, type SortBlock, sortBlocks } from '../../ui';
import { FontPreview } from '../font/FontPreview';
import { clearSaved, hasSaved } from '../saves';
import { SoundPreview } from '../sound/SoundPreview';
import type { VariablesOwner } from '../state';
import { aspectActions, sizeCommand } from './aspectAction';
import { buildCutRows, CUT_ROW_KEYS, refreshCutRows } from './cutSection';
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
import { addedRows, addNameField, addNote, addTitle, type InspectorHost, sectionHeader } from './host';
import { buildShapeListSection, shapeListSignature } from './shapeSection';
import { buildVariable, kindMenu, type StructTyping, type VariableEditing } from './variableRows';
import { listSource, previewingHandlers, structFields } from './previewHandlers';
import { addSpritePreview } from './SpritePreview';
import { assetValue } from './valueSpecs';
/** A struct field declares its own fields, starting with none. */
const newField = (key: string, kind: NestedKind): StructFieldDef =>
kind === 'struct' ? { key, kind, fields: [] } : { key, kind };
/**
* Structure-defining facts of an asset selection: the schema keys and kinds of the rows shown, plus
* the fields of a struct – those are rows of their own, so adding one has to rebuild the section.
*/
export function assetSignature(project: Project, assetId: string): string {
const asset = findAsset(project, assetId);
if (!asset) return 'missing';
// A machine's own rows are none, so its scope has to be named here or switching it rebuilds nothing.
const machine = asset.type === 'statemachine' ? `|${scopeOf(asset)}` : '';
// The same for the switch that keeps shared values between runs and for the scope beside it: the
// notes, the button and whether the switch is shown at all depend on the two.
const kept = asset.type === 'variables' ? `|${isPersistent(asset)}|${sharedScopeOf(asset)}` : '';
// A behavior's Keep and Private buttons are drawn once per build and read their switch from it.
const keeps =
asset.type === 'behavior'
? `|keep:${[...(asset.keptVariables ?? [])].sort().join(',')}` +
`|private:${[...(asset.privateVariables ?? [])].sort().join(',')}`
: '';
// Vector art shows its shapes under its rows (#129), so a shape added or renamed is a new structure.
const shapes = asset.type === 'vectorart' ? `|${shapeListSignature(asset)}` : '';
const values = resolveAssetProps(project, asset);
const rows = schemaForAsset(project, asset).filter((def) => isRowShown(def, values));
// A Type row shows the name of its Struct asset, or "Own fields" once that asset is gone (#253).
const types = `|types:${typeNames(project, rows)}`;
return rows.map(defSignature).join(',') + machine + kept + keeps + shapes + types;
}
function typeNames(project: Project, rows: readonly (PropertyDef | StructFieldDef)[]): string {
return rows
.flatMap((def): string[] =>
def.kind === 'struct'
? [structAssetOf(project, def.structType)?.name ?? '', typeNames(project, def.fields)]
: def.kind === 'list'
? [structAssetOf(project, def.itemType)?.name ?? '']
: [],
)
.join(',');
}
export function defSignature(def: PropertyDef | StructFieldDef): string {
// Only a struct has rows of its own, at any depth; a list's items rebuild themselves (#252).
const fields = def.kind === 'struct' ? def.fields.map(defSignature).join('+') : '';
// A type decides which of those rows may be changed, and names itself in a row (#253) – so does
// the item type of a list (#254).
const type =
def.kind === 'struct' && def.structType
? `@${def.structType}`
: def.kind === 'list' && def.itemType
? `@${def.itemType}`
: '';
return `${def.key}:${def.kind}${type}${fields ? `(${fields})` : ''}`;
}
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
export function buildAssetSection(host: InspectorHost, assetId: string): void {
const asset = findAsset(host.state.project, assetId);
if (!asset) return;
addTitle(host, `${asset.type.toUpperCase()} ASSET`);
addNameField(host, asset.name, {
onChange: (name) => {
if (name.trim()) host.state.execute(new RenameAssetCommand(assetId, name.trim()));
else host.nameField?.setValue(asset.name);
},
});
// The switches in a group of their own, as on a placed object (#290). A behavior's rows are all
// switches: what it declares is listed under VARIABLES.
const schema = markedAssetSchema(host.state.project, asset);
const switches = schema.filter((def) => asset.type === 'behavior' || isSwitch(def.key));
buildProps(host, asset, switches, 'SWITCHES');
buildProps(
host,
asset,
schema.filter((def) => !switches.includes(def)),
'PROPERTIES',
);
if (isVariableOwner(asset)) buildVariables(host, asset);
if (asset.type === 'variables') {
addTitle(host, 'SCOPE');
buildVariablesScope(host, asset);
buildPersistence(host, asset);
}
if (asset.type === 'statemachine') {
addTitle(host, 'SCOPE');
buildMachineScope(host, asset);
}
// A drawing is sliced and sorted as a whole, with the rows a texture frame has (#308).
if (isDrawing(asset)) buildCutRows(host, { assetId }, 'CANVAS PIXELS');
// The drawing itself: its shapes are picked here as a texture's frames are picked in its tab (#129).
if (asset.type === 'vectorart') buildShapeListSection(host, asset);
// A font has no properties at all, so without this its inspector would be the name and nothing.
if (asset.type === 'font')
host.body.add(
asset.file ? new FontPreview(asset.file) : new Label('Not uploaded.', { style: 'warning' }),
);
// A sound has its rows above this; the preview is the quickest way to find out which sound it is.
if (asset.type === 'sound')
host.body.add(
asset.file ? new SoundPreview(host.state, assetId) : new Label('Not uploaded.', { style: 'warning' }),
);
if (asset.type === 'sprite') addSpritePreview(host, { kind: 'asset', assetId });
}
/** Pushes the current asset values into the rows. False when the asset is gone (rebuild). */
export function refreshAssetSection(host: InspectorHost, assetId: string): boolean {
const asset = findAsset(host.state.project, assetId);
if (!asset) return false;
host.nameField?.setValue(asset.name);
const project = host.state.project;
const values = shownValues(host, asset);
const drawing = isDrawing(asset);
for (const [key, row] of host.fields) {
if (drawing && CUT_ROW_KEYS.has(key)) continue;
row.setValue(values[key] ?? 0);
row.setModified(isModified(project, asset, key));
}
if (drawing) refreshCutRows(host, { assetId });
return true;
}
const isDrawing = (asset: Asset): boolean => asset.type === 'pixelart' || asset.type === 'vectorart';
/** The switches, and the render layer (#302): both say whether the object is seen at all. */
const isSwitch = (key: string): boolean =>
key === RENDER_LAYER_KEY || COMMON_PROPS.some((def) => def.key === key);
/** The rows of `schema` that are shown, under `title` – which is left out with them. */
function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[], title: string): void {
const project = () => host.state.project;
const values = resolveAssetProps(project(), asset);
const shown = schema.filter((d) => isRowShown(d, values));
if (shown.length > 0) addTitle(host, title);
for (const def of shown) {
const row = createFieldRow(
host,
def,
values[def.key] ?? def.default,
assetHandlers(host, asset.id, def),
{
extra: aspectActions(
host,
asset,
def,
() => {
const target = findAsset(host.state.project, asset.id);
return target ? resolveAssetProps(project(), target) : null;
},
(values) =>
host.state.execute(
sizeCommand(values, (axis, value) => new SetAssetPropertyCommand(asset.id, axis, value)),
),
),
reset: () => {
const target = findAsset(host.state.project, asset.id);
return actionIf(target && isModified(project(), target, def.key), 'Reset to default', () =>
host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)),
);
},
},
);
row.setModified(isModified(project(), asset, def.key));
host.fields.set(def.key, row);
host.body.add(row.row);
}
}
/** Previews straight into the asset while editing; the command records the value from before the preview. */
function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers {
const spec = assetValue(host.state, assetId, def.key);
return {
...previewingHandlers<Asset>(host, spec),
...listSource(host, def, () => ({ kind: 'asset', assetId, key: def.key })),
...structFields<Asset>(host, def, spec, (path) => ({ kind: 'asset', assetId, key: def.key, path })),
};
}
/**
* What the rows show: the values of the run while one is on, else what the document declares – so a
* value a graph counts up is visible without ever being saved.
*/
export function shownValues(host: InspectorHost, asset: Asset): PropertyBag {
const values = resolveAssetProps(host.state.project, asset);
const live = asset.type === 'variables' ? host.liveVariables(asset.id) : null;
return live ? { ...values, ...bagWithoutObjects(live) } : values;
}
/** The fields declared at `path` inside a struct declaration; none where the path leads nowhere. */
function fieldsAt(fields: readonly StructFieldDef[], path: readonly string[]): readonly StructFieldDef[] {
const [step, ...rest] = path;
if (step === undefined) return fields;
const field = fields.find((f) => f.key === step);
return field?.kind === 'struct' ? fieldsAt(field.fields, rest) : [];
}
/**
* Declared variables, each with its declaration editable – plus an "add" menu, and in the inspector
* the way to the same rows in a Variables tab (#252). A Struct asset's fields are declared the same
* way (#253); every change to them is carried on to the places of that type.
*/
export function buildVariables(host: InspectorHost, asset: VariableOwnerAsset, inTab = false): void {
const isType = asset.type === 'struct';
const header = sectionHeader(host, isType ? 'FIELDS' : 'VARIABLES');
const add = new Dropdown(isType ? '+ Field' : '+ Variable', { variant: 'ghost' });
const run = (command: Command, rename?: FieldRename): void =>
void host.state.execute(isType ? new WithStructTypes(command, rename) : command);
add.onClick = () => {
const items = kindMenu(VARIABLE_KINDS, (kind) => {
const current = findAsset(host.state.project, asset.id);
if (isVariableOwner(current))
run(new AddVariableCommand(asset.id, nextVariableName(current, isType ? 'field' : 'var'), kind));
});
new Menu(items).openBelow(add);
};
header.add(add);
if (!inTab) header.add(openVariablesButton(host, { kind: 'asset', assetId: asset.id }));
host.body.add(header);
if (asset.variables.length === 0) addNote(host, isType ? 'No fields yet.' : 'No variables yet.');
const values = shownValues(host, asset);
const renamed = (path: readonly string[], from: string, to: string): FieldRename | undefined =>
isType ? { structType: asset.id, path, from, to } : undefined;
const blocks: SortBlock[] = [];
for (const def of asset.variables) {
const edit: VariableEditing = {
rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next), renamed([], def.key, next)),
remove: () => run(new RemoveVariableCommand(asset.id, def.key)),
renameField: (path, field, next) =>
run(
new RenameStructFieldCommand(asset.id, def.key, field.key, next, path),
renamed([def.key, ...path], field.key, next),
),
removeField: (path, field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key, path)),
addField: (path, kind) =>
run(
new AddStructFieldCommand(
asset.id,
def.key,
newField(nextFieldName(def.kind === 'struct' ? fieldsAt(def.fields, path) : []), kind),
path,
),
),
moveField: (path, from, to) => run(new MoveStructFieldCommand(asset.id, def.key, from, to, path)),
};
const behavior = asset.type === 'behavior';
const keep = behavior
? {
on: isKeptVariable(asset, def.key),
set: (on: boolean) => run(new SetVariableKeptCommand(asset.id, def.key, on)),
}
: undefined;
const hidden = behavior
? {
on: isPrivateVariable(asset, def.key),
set: (on: boolean) => run(new SetVariablePrivateCommand(asset.id, def.key, on)),
}
: undefined;
const value = values[def.key] ?? def.default;
const typing = structTyping(host, asset, def.key);
const { rows, built } = addedRows(host, () =>
buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit, typing, keep, hidden }),
);
host.fields.set(def.key, built);
blocks.push({ grip: built.grip, rows });
}
sortBlocks(blocks, (from, to) => run(new MoveVariableCommand(asset.id, from, to)));
}
/**
* The type picker of a struct variable and of its struct fields (#253): own fields, a Struct asset,
* or a new Struct asset made from the fields it holds now. Inside a Struct asset the types that
* hold that asset are not offered – the default of such a field would never end.
*/
function structTyping(host: InspectorHost, owner: VariableOwnerAsset, key: string): StructTyping {
const project = () => host.state.project;
const run = (command: Command): void => void host.state.execute(command);
return {
typeName: (structType) => structAssetOf(project(), structType)?.name ?? null,
menu: (path, structType) => {
const current = structAssetOf(project(), structType);
const target = { kind: 'variable', ownerId: owner.id, key, path } as const;
const types = project().assets.filter(
(a) => a.type === 'struct' && !(owner.type === 'struct' && containsType(project(), a.id, owner.id)),
);
const entries: MenuEntry[] = [
{ label: 'Own fields', current: !current, onSelect: () => run(new SetStructTypeCommand(target, '')) },
...types.map((type) => ({
label: type.name,
current: type.id === current?.id,
onSelect: () => run(new SetStructTypeCommand(target, type.id)),
})),
{ separator: true },
{
label: 'Make struct type',
disabled: current !== undefined,
onSelect: () => run(new MakeStructTypeCommand(owner.id, key, path)),
},
];
return entries;
},
listMenu: (path, itemType) => {
const current = structAssetOf(project(), itemType);
const target = { ownerId: owner.id, key, path };
// A list starts empty, so unlike a field a list may hold the type it sits in.
const types = project().assets.filter((a) => a.type === 'struct');
return [
{ label: 'Mixed', current: !current, onSelect: () => run(new SetListItemTypeCommand(target, '')) },
...types.map((type) => ({
label: type.name,
current: type.id === current?.id,
onSelect: () => run(new SetListItemTypeCommand(target, type.id)),
})),
{ separator: true },
{
label: 'Make struct type',
disabled: current !== undefined,
onSelect: () => run(new MakeListItemTypeCommand(target)),
},
];
},
};
}
/** Opens the variables of `owner` in a Variables tab of their own, or brings up the one they have. */
export function openVariablesButton(host: InspectorHost, owner: VariablesOwner): Button {
return new Button('Open', {
variant: 'ghost',
onClick: () => host.state.openValues({ kind: 'variables', owner }),
});
}
/** Not in the schema table either: a row there would share the bag with the declared values. */
const PERSISTENT: PropertyDef = { key: 'persistent', label: 'Persistent', kind: 'boolean', default: false };
/** Whether the values outlive the run (#166), what that means, and the way back to a first start. */
function buildPersistence(host: InspectorHost, asset: VariablesAsset): void {
const set = (value: PropertyValue): void =>
void host.state.execute(new SetVariablesPersistentCommand(asset.id, value === true));
// Nothing to keep between runs when every scene throws the values away – the switch is not shown.
if (sharedScopeOf(asset) === 'scene') {
addNote(host, 'One value each, built again for', 'small');
addNote(host, 'every scene that is loaded.', 'small');
return;
}
const row = createFieldRow(host, PERSISTENT, isPersistent(asset), { onInput: set, onChange: set });
host.body.add(row.row);
// Two lines rather than one: the panel is narrow enough to cut a longer note off mid-sentence.
if (!isPersistent(asset)) {
addNote(host, 'One value each for the whole run.', 'small');
addNote(host, 'Every graph pointing here shares them.', 'small');
return;
}
addNote(host, 'Kept between runs, in this browser.', 'small');
addNote(host, 'The next run starts where one ended.', 'small');
// An object is an instance of this run only: the next run has new ones, so there is nothing to keep.
if (asset.variables.some((def) => def.kind === 'object')) {
addNote(host, 'Object variables are not kept,', 'warning');
addNote(host, 'every run starts them at (null).', 'warning');
}
host.body.add(
new Button('Clear saved values', {
variant: 'ghost',
onClick: () => {
const projectId = host.state.project.id;
const had = hasSaved(projectId, asset.id);
clearSaved(projectId, asset.id);
const name = findAsset(host.state.project, asset.id)?.name ?? asset.name;
host.state.setNotice(had ? `Saved values of ${name} cleared` : 'Nothing saved yet');
},
}),
);
}
/** Not in the schema table: the scope lives on the asset itself, not in its property bag. */
const MACHINE_SCOPE: PropertyDef = {
key: 'scope',
label: 'Scope',
kind: 'enum',
default: 'local',
options: [
{ value: 'local', label: 'Local' },
{ value: 'global', label: 'Global' },
{ value: 'scene', label: 'Scene' },
],
};
const MACHINE_SCOPE_NOTES: Record<Scope, string[]> = {
local: ['Each behavior instance keeps its own current state.'],
global: ['Every behavior using this machine shares its', 'current state, for the whole run.'],
scene: ['Shared like global, but it starts over', 'whenever a scene is loaded.'],
};
/** A state machine has no properties of its own, so the inspector shows just its name and this. */
function buildMachineScope(host: InspectorHost, asset: StateMachineAsset): void {
const set = (value: PropertyValue): void =>
void host.state.execute(new SetMachineScopeCommand(asset.id, readScope(value)));
const row = createFieldRow(host, MACHINE_SCOPE, scopeOf(asset), { onInput: set, onChange: set });
host.body.add(row.row);
// Short lines rather than one: the panel is narrow enough to cut a longer note off mid-sentence.
for (const line of MACHINE_SCOPE_NOTES[scopeOf(asset)]) addNote(host, line, 'small');
}
/** The same switch for a Variables asset, which has no `local`: the asset is the shared thing. */
const VARIABLES_SCOPE: PropertyDef = {
key: 'scope',
label: 'Scope',
kind: 'enum',
default: 'global',
options: [
{ value: 'global', label: 'Global' },
{ value: 'scene', label: 'Scene' },
],
};
function buildVariablesScope(host: InspectorHost, asset: VariablesAsset): void {
const set = (value: PropertyValue): void =>
void host.state.execute(new SetVariablesScopeCommand(asset.id, readSharedScope(value)));
const row = createFieldRow(host, VARIABLES_SCOPE, sharedScopeOf(asset), {
onInput: set,
onChange: set,
});
host.body.add(row.row);
}