Files
feuerwerk/src/core/store/serialize.ts
T
adminandClaude Opus 5.5 2764919bb8 Stream long sounds from an Opus copy instead of decoding them (#349)
A sound was decoded whole as soon as the project opened, in the editor
and in a released game: five minutes of stereo held some 115 MB for as
long as the page was open. A sound with Stream on now plays from a copy
as Opus in Ogg, fetched a piece at a time and decoded a few seconds
ahead by WebCodecs, so a game holds a few megabytes for it.

- The Stream switch is a row of the sound, off by default. The Sound
  tab suggests it from a minute on and says how far the copy has got.
- StreamKeeper makes the copies in the browser, a moment after the
  last change, one at a time: the sound's own and one per clip of its
  cut, fades and Reverse baked in. A copy that is no longer wanted is
  taken off and deleted once the project is saved without it.
- streamTable says which played names stream from which copy. A clip
  without a fitting copy, a Sample and a Reverb impulse stay decoded.
  A sound with Stream on is never decoded ahead.
- The page fetches by Range and takes the whole file from a server
  that ignores it, as the CI's PHP server does. It keeps the bytes
  and the first two seconds decoded, the rest goes four seconds ahead.
- A jump decodes 32 packets before the place first. Measured in
  Chrome, what follows is then the same to the bit as decoding from
  the start. With 4 packets it was 0.06 off.
- The stream-player on the FX worklet reads the samples at the rate
  of the context along a Catmull-Rom curve. What comes late is silent
  and skipped once it is there, so a clip keeps its beat.
- The opening stretch goes with the player as it is made. Sent by
  message it came a few milliseconds late, and 557 samples of the
  attack were lost.
- Pitch on a streamed sound is a shifter after the player, as on a
  bus: the length stays and it is heard 42.7 ms late.
- Bakes of patterns and songs (#340) always stream now.
- The song timeline draws a streamed sound from its copy decoded at
  8 kHz, keeping only the peaks.

Tests:
- Gate: CHECK EXIT 0, 280 files, 4312 tests (31 new: ogg +3,
  stream-queue 6, streams 11, streaming 6, sound-library +5).
- e2e 34: ALL PASSED, 106 checks (7 new). Five minutes of mono,
  4.8 MB as WAV, 3.6 MB as its copy: streamed the renderer grows by
  5.8 MB, decoded by 69.6 MB. Offline at 48 kHz, start, loop seam and
  a reversed clip are the copy decoded whole to the sample (0).
- Counter-checks red as they should be: no pre-roll, no catch-up of
  late samples at the end, no opening stretch, a streamed sound
  decoded ahead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:27:32 +02:00

1068 lines
44 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.
// Importing the graph package index is what registers the built-in nodes.
import {
ANNOTATION_KINDS,
annotationList,
createEmptyGraph,
type DeclaredPort,
type GraphAnnotation,
MIN_ANNOTATION_SIZE,
type DeclaredPortKind,
type Graph,
type GraphEdge,
type GraphNode,
type PortSplit,
isValidPortName,
fixedPortKeys,
type NodeDef,
nodeDef,
STATE,
} from '../graph';
import { isParticleNodeDef } from '../particles/nodes';
import { isShaderNodeDef } from '../shader/nodes';
import { isSoundFxNodeDef } from '../soundfx/nodes';
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
import { heldBake } from '../music/bake';
import { heldStream } from '../music/streams';
import { heldPatternNotes } from '../music/pattern';
import { isEmbeddable } from '../music/embedded';
import { heldSongItems, heldSongTracks } from '../music/song';
import { heldSend, heldSongGraph } from '../music/routing';
import { isMasterRoutingDef, isSongRoutingDef } from '../music/routingNodes';
import { heldRouting } from '../music/projectRouting';
import { heldLoop, type LoopRange } from '../music/sequence';
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
import type {
AnimationKey,
AnimationTrack,
Asset,
BehaviorAsset,
ContainerAsset,
PictureCut,
PixelArtAsset,
PixelFrame,
PixelLayer,
SceneAsset,
SoundClip,
StateMachineAsset,
TextureFrame,
GradientStop,
TilemapLayer,
VariablesAsset,
VectorArtAsset,
VectorGradient,
VectorPaint,
VectorPoint,
VectorRing,
VectorItem,
VectorLayer,
VectorShape,
} from '../model/Asset';
import { parseCellKey } from '../model/tilemap';
import { pluginsOf } from './projectPlugins';
import { collisionLayersOf } from '../model/collisionLayers';
import { renderLayersOf } from '../model/renderLayers';
import { gameLinkZoomOf, gameScaleOf, playFromOf, whilePlayingOf } from '../model/playSettings';
import { sceneVisibilityOf } from '../model/sceneVisibility';
import { symbolScaleOf } from '../model/symbolScale';
import { masterFxOf, masterVolumeOf } from '../model/master';
import { decodeCells, encodeCells } from './tilemapCells';
import { isAssetType } from '../model/AssetTypes';
import { isFontFile } from '../model/font';
import { isSoundFile } from '../model/sound';
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
import { type PropertyBag, type PropertyValue, tagOf, type Vec2 } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import { readScope, readSharedScope } from '../model/Scope';
import type { ObjectGroup, SceneObject } from '../model/SceneObject';
import {
type CutSize,
DEFAULT_FILL,
isTextureFile,
normalizeFrameRect,
setCutSlice,
setCutSortLine,
setFrameCollision,
} from '../model/texture';
import { drawnCutSize } from '../model/cutTarget';
import { pointsOf } from '../model/polygon';
import { canvasSide, PALETTE_MAX } from '../model/pixelart';
import { isCelFile } from '../model/pixelCel';
import { newId } from '../ids';
import { SHAPE_FILL_DEFAULT } from '../model/vectorart';
import { sanitizedCurve } from '../model/curve';
import { hasParentCycle } from '../model/walk';
import { syncStructTypes } from '../model/structTypes';
import { Recorder } from '../util/recorder';
import {
DECLARED_PORT_KINDS,
FIELD_KINDS,
type FieldKind,
isTaggedKind,
VARIABLE_KINDS,
type VariableKind,
} from '../properties/kinds';
import { makeItem } from '../properties/listItems';
import {
coerceValue,
initialOf,
isValidFieldName,
isValidVariableName,
type PropertyDef,
type PropertySchema,
rowsNotClaimedBy,
schemaFor,
sourceRows,
type StructFieldDef,
variableDef,
} from '../properties/PropertySchema';
import { FIELD_SHADER_KEY, particleParamDefs } from '../properties/particles';
import { CLIP_ROWS } from '../properties/sounds';
import { SHADER_KEY, shaderParamDefs } from '../properties/shaders';
/**
* Turns untrusted JSON (server file, import) into a well-formed Project: unknown keys are dropped,
* missing ones get defaults, values are coerced against the schema, dangling objects are removed.
*/
export function deserializeProject(raw: unknown, fallbackId = ''): Project {
const r = asRecord(raw);
const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord);
// Pass 1: asset ids and their effective schemas, needed to coerce overrides in pass 2.
const schemas = new Map<string, PropertySchema>();
const variables = new Map<string, PropertyDef[]>();
const rawProps = new Map<string, Record<string, unknown>>();
const shaderGraphs = new Map<string, Graph>();
const shaderRows = new Map<string, PropertyDef[]>();
const particleGraphs = new Map<string, Graph>();
const wearers = new Map<string, 'sprite' | 'particles' | 'text' | 'camera'>();
for (const a of rawAssets) {
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
rawProps.set(a.id, asRecord(a.props));
if (a.type === 'sprite' || a.type === 'particles' || a.type === 'text' || a.type === 'camera')
wearers.set(a.id, a.type);
if (a.type === 'behavior' || a.type === 'variables' || a.type === 'struct') {
const vars = normalizeVariables(a.variables);
variables.set(a.id, vars);
// The full schema, not just the common rows: this is what pass 2 coerces `props` against, and
// a behavior's own `runInEditor` would be dropped as undeclared.
schemas.set(a.id, [...schemaFor(a.type), ...vars]);
} else if (a.type === 'particles') {
// A source's rows end with its own graph's parameters, so the graph is needed before the
// overrides of the objects placed from it can be coerced.
const graph = normalizeGraph(a.graph, isParticleNodeDef);
particleGraphs.set(a.id, graph);
schemas.set(a.id, [...schemaFor('particles'), ...particleParamDefs(graph)]);
} else {
if (a.type === 'shader') {
const graph = normalizeGraph(a.graph, isShaderNodeDef);
shaderGraphs.set(a.id, graph);
shaderRows.set(a.id, shaderParamDefs(graph));
}
if (a.type === 'shaderfunction') shaderGraphs.set(a.id, normalizeGraph(a.graph, isShaderNodeDef));
schemas.set(a.id, schemaFor(a.type));
}
}
/**
* A sprite's, source's, text's or camera's rows end with the parameters of the shader its bag names –
* an override's, else the asset's. Only a sprite's rows yield to none: a name the others have stays
* theirs.
*/
const schemaOf = (assetId: string, bag: Record<string, unknown>): PropertySchema | undefined => {
const schema = schemas.get(assetId);
const wearer = wearers.get(assetId);
if (!schema || !wearer) return schema;
const rowsOf = (key: string): PropertySchema => {
const own = bag[key];
const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[key];
return shaderRows.get(typeof shader === 'string' ? shader : '') ?? [];
};
const rows = rowsOf(SHADER_KEY);
if (wearer === 'sprite') return [...schema, ...rows];
if (wearer === 'particles') return sourceRows(schema, rows, rowsOf(FIELD_SHADER_KEY));
return [...schema, ...rowsNotClaimedBy(rows, schema)];
};
// Pass 2: build assets. Frame ids share the reference space with asset ids, so `normalizeTexture`
// is handed the ids already spoken for.
const assets: Asset[] = [];
const takenIds = new Set(schemas.keys());
for (const a of rawAssets) {
const id = typeof a.id === 'string' ? a.id : '';
const schema = schemaOf(id, asRecord(a.props));
if (!schema || !isAssetType(a.type)) continue;
const type = a.type;
const name = typeof a.name === 'string' ? a.name : type;
const props = normalizeBag(a.props, schema, false);
if (type === 'scene' || type === 'container') {
const holder: SceneAsset | ContainerAsset = {
id,
type,
name,
props,
objects: normalizeObjects(a.objects, schemaOf),
};
normalizeGroups(holder, a.groups);
assets.push(holder);
} else if (type === 'behavior') {
const declared = variables.get(id) ?? [];
const behavior: BehaviorAsset = {
id,
type,
name,
props,
variables: declared,
graph: normalizeGraph(a.graph),
};
// Only names that are declared, each once, in declaration order.
const declaredOf = (list: unknown): string[] => {
const named = new Set(Array.isArray(list) ? list : []);
return declared.map((v) => v.key).filter((key) => named.has(key));
};
const keptVariables = declaredOf(a.keptVariables);
if (keptVariables.length > 0) behavior.keptVariables = keptVariables;
const privateVariables = declaredOf(a.privateVariables);
if (privateVariables.length > 0) behavior.privateVariables = privateVariables;
assets.push(behavior);
} else if (type === 'variables') {
const shared: VariablesAsset = { id, type, name, props, variables: variables.get(id) ?? [] };
// Scope first: a `scene` asset is never persistent, so the switch that cannot apply is dropped.
if (readSharedScope(a.scope) === 'scene') shared.scope = 'scene';
else if (a.persistent === true) shared.persistent = true;
assets.push(shared);
} else if (type === 'struct') {
assets.push({ id, type, name, props, variables: variables.get(id) ?? [] });
} else if (type === 'statemachine') {
const machine: StateMachineAsset = {
id,
type,
name,
props,
graph: normalizeGraph(a.graph, (def) => def.type === STATE),
};
const scope = readScope(a.scope);
if (scope !== 'local') machine.scope = scope;
// Kept as written even where it names no state any more: the same as a deleted start.
if (typeof a.start === 'string' && a.start !== '') machine.start = a.start;
assets.push(machine);
} else if (type === 'shader' || type === 'shaderfunction') {
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
} else if (type === 'particles') {
assets.push({ id, type, name, props, graph: particleGraphs.get(id) ?? createEmptyGraph() });
} else if (type === 'soundfx') {
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, isSoundFxNodeDef) });
} else if (type === 'instrument') {
const keep = (def: NodeDef) => isInstrumentNodeDef(def) || isSoundFxNodeDef(def);
const graph = normalizeGraph(a.graph, keep);
for (const node of graph.nodes)
if (node.type === INSTRUMENT_SAMPLE) node.params = withSamplePlay(node.params);
assets.push({ id, type, name, props, graph });
} else if (type === 'texture') {
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
} else if (type === 'font') {
const file = typeof a.file === 'string' && isFontFile(a.file) ? a.file : '';
assets.push({ id, type, name, props, file });
} else if (type === 'sound') {
const file = typeof a.file === 'string' && isSoundFile(a.file) ? a.file : '';
const clips = normalizeClips(a.clips, takenIds);
const stream = heldStream(a.stream);
assets.push({
id,
type,
name,
props,
file,
...(clips.length > 0 && { clips }),
...(stream && { stream }),
});
} else if (type === 'tile') {
// A picture naming nothing is kept, like an animation's dangling root: it reads as missing
// in the tab and comes back when the texture it named is restored by an undo.
const pictures = (Array.isArray(a.pictures) ? a.pictures : []).filter(
(id): id is string => typeof id === 'string' && id !== '',
);
assets.push({ id, type, name, props, pictures });
} else if (type === 'tilemap') {
assets.push({ id, type, name, props, layers: normalizeLayers(a.layers) });
} else if (type === 'vectorart') {
const drawing: VectorArtAsset = { id, type, name, props, layers: normalizeVectorLayers(a) };
readCut(drawing, drawnCutSize(drawing), a);
assets.push(drawing);
} else if (type === 'pixelart') {
const drawing: PixelArtAsset = { id, type, name, props, ...normalizePixelArt(a, takenIds) };
readCut(drawing, drawnCutSize(drawing), a);
assets.push(drawing);
} else if (type === 'palette') {
assets.push({ id, type, name, props, colors: normalizePalette(a.colors) });
} else if (type === 'pattern') {
const loop = readLoop(a.loop);
const bake = heldBake(a.bake);
assets.push({
id,
type,
name,
props,
notes: heldPatternNotes(a.notes),
...(loop && { loop }),
...(bake && { bake }),
});
} else if (type === 'song') {
const loop = readLoop(a.loop);
const bake = heldBake(a.bake);
const tracks = heldSongTracks(a.tracks);
assets.push({
id,
type,
name,
props,
tracks,
items: heldSongItems(a.items, tracks),
...(loop && { loop }),
graph: heldSongGraph(normalizeGraph(a.graph, isSongRoutingDef), tracks),
...(bake && { bake }),
});
} else if (type === 'curve') {
assets.push({ id, type, name, props, points: sanitizedCurve(normalizePoints(a.points)) });
} else if (type === 'animation') {
// A root or row aiming at something gone is kept: it reads as missing and keeps its keys.
const root = typeof a.root === 'string' ? a.root : '';
assets.push({ id, type, name, props, root, tracks: normalizeTracks(a.tracks) });
} else {
assets.push({ id, type, name, props });
}
const owner = typeof a.owner === 'string' ? a.owner : '';
if (owner && isEmbeddable(type)) assets[assets.length - 1]!.owner = owner;
}
// One whose song is gone is kept as a project asset rather than lost (#355).
const songs = new Set(assets.filter((a) => a.type === 'song').map((a) => a.id));
for (const asset of assets) if (asset.owner && !songs.has(asset.owner)) delete asset.owner;
let activeSceneId = typeof r.activeSceneId === 'string' ? r.activeSceneId : '';
if (!assets.some((a) => a.type === 'scene' && a.id === activeSceneId)) {
const first = assets.find((a) => a.type === 'scene');
activeSceneId = first ? first.id : '';
}
// A dangling start scene reads as none at all – the run then falls back to the active scene,
// which is also what every project written before there was such a field does.
const startSceneId =
typeof r.startSceneId === 'string' && assets.some((a) => a.type === 'scene' && a.id === r.startSceneId)
? r.startSceneId
: '';
const project: Project = {
formatVersion: PROJECT_FORMAT_VERSION,
id: typeof r.id === 'string' && r.id ? r.id : fallbackId,
name: typeof r.name === 'string' ? r.name : 'Untitled',
assets,
activeSceneId,
startSceneId,
// The same reading the editor did before it loaded the plugins (#119), so the model says what
// is actually running.
plugins: pluginsOf(r),
collisionLayers: collisionLayersOf(r),
renderLayers: renderLayersOf(r),
masterVolume: masterVolumeOf(r),
masterFx: masterFxOf(r),
routing: heldRouting(normalizeGraph(r.routing, isMasterRoutingDef), assets, false),
symbolScale: symbolScaleOf(r),
playFrom: playFromOf(r),
whilePlaying: whilePlayingOf(r),
gameScale: gameScaleOf(r),
gameLinkZoom: gameLinkZoomOf(r),
sceneVisibility: sceneVisibilityOf(r),
};
// A hand-edited file may have copies that no longer match their Struct asset.
syncStructTypes(project, new Recorder());
return project;
}
/**
* Plain JSON-ready copy of the project (deep clone, stable key order for readable diffs). The cells
* of a tilemap are the one thing written differently from how they are held: see `tilemapCells.ts`.
*/
export function serializeProject(project: Project): unknown {
const copy = JSON.parse(JSON.stringify(project)) as Project;
for (const asset of copy.assets)
if (asset.type === 'tilemap')
(asset as { layers: unknown[] }).layers = asset.layers.map(({ cells, ...layer }) => ({
...layer,
...encodeCells(cells),
}));
return copy;
}
function normalizeObjects(
raw: unknown,
schemaOf: (assetId: string, overrides: Record<string, unknown>) => PropertySchema | undefined,
): SceneObject[] {
const objects: SceneObject[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const o = asRecord(item);
if (typeof o.id !== 'string' || !o.id || typeof o.assetId !== 'string') continue;
const schema = schemaOf(o.assetId, asRecord(o.overrides));
if (!schema || schema === schemaFor('scene')) continue; // dangling reference or illegal scene-in-scene
const p = asRecord(o.position);
const offset = asRecord(o.editorOffset);
objects.push({
id: o.id,
assetId: o.assetId,
name: typeof o.name === 'string' ? o.name : '',
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
editorOffset: { x: toNumber(offset.x), y: toNumber(offset.y) },
overrides: normalizeBag(o.overrides, schema, true),
parentId: typeof o.parentId === 'string' ? o.parentId : '',
instanceEdits: normalizeInstanceEdits(o.instanceEdits),
...(typeof o.groupId === 'string' && o.groupId ? { groupId: o.groupId } : {}),
});
}
// Parents must exist in the same scene, differ from the object itself and not form cycles.
const byId = new Map(objects.map((o) => [o.id, o]));
for (const o of objects) if (o.parentId && (o.parentId === o.id || !byId.has(o.parentId))) o.parentId = '';
// Cut one link per cycle: an object already unlinked here is a root for the objects checked after it.
const parentOf = (o: SceneObject) => (o.parentId ? byId.get(o.parentId) : undefined);
for (const o of objects) if (hasParentCycle(o, parentOf)) o.parentId = '';
return objects;
}
/**
* Hierarchy folders (#241): only those with a member are kept, and a member is a top-level object
* naming one of them – anything else loses its `groupId`.
*/
function normalizeGroups(holder: SceneAsset | ContainerAsset, raw: unknown): void {
const groups = new Map<string, ObjectGroup>();
for (const item of Array.isArray(raw) ? raw : []) {
const g = asRecord(item);
if (typeof g.id !== 'string' || !g.id || groups.has(g.id)) continue;
groups.set(g.id, { id: g.id, name: typeof g.name === 'string' ? g.name : '' });
}
const used = new Set<string>();
for (const object of holder.objects) {
if (object.groupId === undefined) continue;
if (!object.parentId && groups.has(object.groupId)) used.add(object.groupId);
else delete object.groupId;
}
const kept = [...groups.values()].filter((g) => used.has(g.id));
if (kept.length > 0) holder.groups = kept;
}
/**
* Deviations of container content, keyed by template path. Values are not coerced against a schema:
* which asset a path leads to depends on container assets that may not be built yet, and resolving
* drops what the schema does not declare anyway.
*/
function normalizeInstanceEdits(raw: unknown): Record<string, InstanceEdit> {
const edits: Record<string, InstanceEdit> = {};
for (const [path, value] of Object.entries(asRecord(raw))) {
if (!path) continue;
const e = asRecord(value);
const edit = emptyEdit();
if (typeof e.name === 'string') edit.name = e.name;
const position = asRecord(e.position);
for (const axis of ['x', 'y', 'z'] as const)
if (typeof position[axis] === 'number' && Number.isFinite(position[axis]))
edit.position[axis] = position[axis];
const offset = asRecord(e.editorOffset);
if (typeof offset.x === 'number' || typeof offset.y === 'number')
edit.editorOffset = { x: toNumber(offset.x), y: toNumber(offset.y) };
for (const [key, v] of Object.entries(asRecord(e.overrides))) {
const value = asStoredValue(v);
if (value !== undefined) edit.overrides[key] = value;
}
if (!isEmptyEdit(edit)) edits[path] = edit;
}
return edits;
}
function normalizeVariables(raw: unknown): PropertyDef[] {
const defs: PropertyDef[] = [];
const seen = new Set<string>();
for (const item of Array.isArray(raw) ? raw : []) {
const v = asRecord(item);
const key = typeof v.key === 'string' ? v.key : '';
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind))
? (v.kind as VariableKind)
: 'number';
// Common property keys are reserved – a variable of that name would shadow the switch.
if (!isValidVariableName(key) || seen.has(key)) continue;
seen.add(key);
const fields = normalizeStructFields(v.fields);
const def = variableDef(key, kind, undefined, fields);
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
if (def.kind === 'struct') readStructType(def, v.structType);
if (def.kind === 'list') readItemType(def, v.itemType);
defs.push(def);
}
return defs;
}
/** An unreadable field is dropped rather than defaulted: an invented key would claim a value. */
function normalizeStructFields(raw: unknown): StructFieldDef[] {
const fields: StructFieldDef[] = [];
const seen = new Set<string>();
for (const item of Array.isArray(raw) ? raw : []) {
const f = asRecord(item);
const key = typeof f.key === 'string' ? f.key : '';
if (!isValidFieldName(key) || seen.has(key)) continue;
const field = readField(key, f);
if (!field) continue;
seen.add(key);
fields.push(field);
}
return fields;
}
function readField(key: string, f: Record<string, unknown>): StructFieldDef | null {
const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value));
if (f.kind === 'list') {
const field: StructFieldDef = { key, kind: 'list' };
readItemType(field, f.itemType);
return field;
}
if (f.kind === 'struct') {
const field: StructFieldDef = { key, kind: 'struct', fields: normalizeStructFields(f.fields) };
readStructType(field, f.structType);
return field;
}
return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null;
}
/** A dangling id is kept, as a curve's is: the struct keeps the fields it last had (#253). */
function readStructType(def: { structType?: string }, raw: unknown): void {
if (typeof raw === 'string' && raw !== '') def.structType = raw;
}
/** The same for a typed list (#254): gone, it is a mixed list whose items keep their marks. */
function readItemType(def: { itemType?: string }, raw: unknown): void {
if (typeof raw === 'string' && raw !== '') def.itemType = raw;
}
function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolean): DeclaredPort[] {
const ports: DeclaredPort[] = [];
const seen = new Set(fixed);
for (const item of Array.isArray(raw) ? raw : []) {
const p = asRecord(item);
const key = typeof p.key === 'string' ? p.key : '';
if (!isValidPortName(key) || seen.has(key)) continue;
seen.add(key);
const kind = (DECLARED_PORT_KINDS as readonly string[]).includes(String(p.kind))
? (p.kind as DeclaredPortKind)
: 'number';
const port: DeclaredPort = writable && p.write === true ? { key, kind, write: true } : { key, kind };
// A machine that is gone is kept, as a struct type is: the port then offers no states.
if (kind === 'statemachine' && typeof p.machine === 'string' && p.machine !== '')
port.machine = p.machine;
if (kind === 'number' && typeof p.default === 'number' && Number.isFinite(p.default))
port.default = p.default;
ports.push(port);
}
return ports;
}
/** One split per output; its fields are checked like declared ports, the type is kept even when gone. */
function normalizeSplits(raw: unknown): PortSplit[] {
const splits: PortSplit[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const s = asRecord(item);
const port = typeof s.port === 'string' ? s.port : '';
if (!port || splits.some((other) => other.port === port)) continue;
const structType = typeof s.structType === 'string' ? s.structType : '';
splits.push({ port, structType, fields: normalizePorts(s.fields, [], false) });
}
return splits;
}
/** `keep` restricts the node types kept: a state machine's graph holds states, a shader's shader nodes, and nothing else. */
function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph {
const g = asRecord(raw);
const graph = createEmptyGraph();
const nodeIds = new Set<string>();
for (const item of Array.isArray(g.nodes) ? g.nodes : []) {
const n = asRecord(item);
const id = typeof n.id === 'string' ? n.id : '';
const type = typeof n.type === 'string' ? n.type : '';
const def = nodeDef(type);
if (!id || nodeIds.has(id) || !def || (keep !== undefined && !keep(def))) continue;
nodeIds.add(id);
// Params carry both settings and inline input values, so there is no schema to coerce against.
const params: PropertyBag = {};
for (const [key, value] of Object.entries(asRecord(n.params))) {
const stored = asStoredValue(value);
if (stored !== undefined) params[key] = stored;
}
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
const ports = normalizePorts(n.ports, fixedPortKeys(def), def.declaresPorts === 'both');
if (ports.length > 0) node.ports = ports;
const splits = normalizeSplits(n.splits);
if (splits.length > 0) node.splits = splits;
if (n.collapsed === true) node.collapsed = true;
graph.nodes.push(node);
}
const edgeIds = new Set<string>();
for (const item of Array.isArray(g.edges) ? g.edges : []) {
const e = asRecord(item);
const from = asRecord(e.from);
const to = asRecord(e.to);
const id = typeof e.id === 'string' ? e.id : '';
if (!id || edgeIds.has(id)) continue;
if (
typeof from.node !== 'string' ||
typeof from.port !== 'string' ||
typeof to.node !== 'string' ||
typeof to.port !== 'string'
)
continue;
if (!nodeIds.has(from.node) || !nodeIds.has(to.node)) continue;
edgeIds.add(id);
const edge: GraphEdge = {
id,
from: { node: from.node, port: from.port },
to: { node: to.node, port: to.port },
};
if (e.folded === true) edge.folded = true;
const label = typeof e.label === 'string' ? e.label.trim() : '';
if (label) edge.label = label;
const send = heldSend(e.send);
if (send) edge.send = send;
graph.edges.push(edge);
}
for (const kind of ANNOTATION_KINDS) {
const list = normalizeAnnotations(g[kind === 'group' ? 'groups' : 'notes'], nodeIds);
if (list.length > 0) annotationList(graph, kind).push(...list);
}
return graph;
}
/**
* Annotations share the id space with nodes and with each other: a duplicate would make one
* selection mean two boxes. A size below the minimum is raised rather than dropped – a box nobody
* can see is worse than one that is a little larger than it was written.
*/
function normalizeAnnotations(raw: unknown, takenIds: Set<string>): GraphAnnotation[] {
const list: GraphAnnotation[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const a = asRecord(item);
const id = typeof a.id === 'string' ? a.id : '';
if (!id || takenIds.has(id)) continue;
takenIds.add(id);
const annotation: GraphAnnotation = {
id,
x: toNumber(a.x),
y: toNumber(a.y),
w: Math.max(toNumber(a.w), MIN_ANNOTATION_SIZE.w),
h: Math.max(toNumber(a.h), MIN_ANNOTATION_SIZE.h),
text: typeof a.text === 'string' ? a.text : '',
};
if (typeof a.color === 'number' && Number.isFinite(a.color))
annotation.color = Math.max(0, Math.min(0xffffff, Math.floor(a.color)));
list.push(annotation);
}
return list;
}
/**
* Layers need a unique id and keep their order; a frame id shares the reference space with asset ids,
* as a texture frame's does. A cel naming a layer that is gone, or no cel file, falls away. An asset
* left without a layer or a frame gets an empty one, the way a new asset starts.
*/
function normalizePixelArt(
raw: Record<string, unknown>,
takenIds: Set<string>,
): { width: number; height: number; layers: PixelLayer[]; frames: PixelFrame[] } {
const layers: PixelLayer[] = [];
const layerIds = new Set<string>();
for (const item of Array.isArray(raw.layers) ? raw.layers : []) {
const l = asRecord(item);
const id = typeof l.id === 'string' ? l.id : '';
if (!id || layerIds.has(id)) continue;
layerIds.add(id);
const opacity = asFinite(l.opacity);
const layer: PixelLayer = {
id,
name: typeof l.name === 'string' ? l.name : '',
opacity: opacity === null ? 1 : Math.max(0, Math.min(1, opacity)),
};
if (l.hidden === true) layer.hidden = true;
layers.push(layer);
}
if (layers.length === 0) {
const layer = { id: newId(), name: 'Layer 1', opacity: 1 };
layers.push(layer);
layerIds.add(layer.id);
}
const frames: PixelFrame[] = [];
for (const item of Array.isArray(raw.frames) ? raw.frames : []) {
const f = asRecord(item);
const id = typeof f.id === 'string' ? f.id : '';
if (!id || takenIds.has(id)) continue;
takenIds.add(id);
const cels: Record<string, string> = {};
for (const [layerId, file] of Object.entries(asRecord(f.cels)))
if (layerIds.has(layerId) && typeof file === 'string' && isCelFile(file)) cels[layerId] = file;
frames.push({ id, cels });
}
if (frames.length === 0) {
let id = newId();
while (takenIds.has(id)) id = newId();
takenIds.add(id);
frames.push({ id, cels: {} });
}
return { width: canvasSide(raw.width), height: canvasSide(raw.height), layers, frames };
}
/** A loop of a pattern (#333) or song (#334): two numbers of ticks with room between them, else none. */
function readLoop(raw: unknown): LoopRange | null {
const r = asRecord(raw);
const start = asFinite(r.start);
const end = asFinite(r.end);
return start === null || end === null ? null : heldLoop({ start: Math.round(start), end: Math.round(end) });
}
/** Colours as 0xRRGGBB, at most `PALETTE_MAX`; anything that is no colour falls away. */
function normalizePalette(raw: unknown): number[] {
const colors: number[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const color = asFinite(item);
if (color !== null && colors.length < PALETTE_MAX)
colors.push(Math.max(0, Math.min(0xffffff, Math.floor(color))));
}
return colors;
}
/**
* A file name that is not the server's content-addressed form is dropped: a request built from it
* would 404, and the texture then loads as "no picture".
*/
function normalizeTexture(
raw: Record<string, unknown>,
takenIds: Set<string>,
): { file: string; width: number; height: number; frames: TextureFrame[] } {
const file = typeof raw.file === 'string' && isTextureFile(raw.file) ? raw.file : '';
const size = (value: unknown): number =>
typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
const width = size(raw.width);
const height = size(raw.height);
const frames: TextureFrame[] = [];
for (const item of Array.isArray(raw.frames) ? raw.frames : []) {
const f = asRecord(item);
const id = typeof f.id === 'string' ? f.id : '';
// Frame ids share the reference space with asset ids, so a duplicate would make one
// reference mean two pictures.
if (!id || takenIds.has(id)) continue;
takenIds.add(id);
const rect = normalizeFrameRect(
{ x: toNumber(f.x), y: toNumber(f.y), w: toNumber(f.w), h: toNumber(f.h) },
width,
height,
);
const frame: TextureFrame = { id, name: typeof f.name === 'string' ? f.name : '', ...rect };
// Against the frame, not the image: the borders are distances inside the cut-out.
readCut(frame, rect, f);
// Corners that are no numbers fall away; what is left is kept only as an outline (#141).
const outlines = collisionOutlines(f.collision).filter((outline) => outline.length >= 3);
if (outlines.length > 0) setFrameCollision(frame, outlines);
frames.push(frame);
}
return { file, width, height, frames };
}
/** The clips of a sound (#357); like a frame's, a clip's id may not repeat one the document has. */
function normalizeClips(raw: unknown, takenIds: Set<string>): SoundClip[] {
const clips: SoundClip[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const c = asRecord(item);
const id = typeof c.id === 'string' ? c.id : '';
if (!id || takenIds.has(id)) continue;
takenIds.add(id);
const stream = heldStream(c.stream);
clips.push({
id,
name: typeof c.name === 'string' ? c.name : '',
props: normalizeBag(c.props, CLIP_ROWS, true),
...(stream && { stream }),
});
}
return clips;
}
/**
* The nine-slice and the sort line of a texture frame or a drawing (#308), measured against `size`.
* A picture without them is one nobody sliced, and keeps saying so.
*/
function readCut(cut: PictureCut, size: CutSize, raw: Record<string, unknown>): void {
const border = asRecord(raw.border);
setCutSlice(cut, size, {
border: {
left: toNumber(border.left),
top: toNumber(border.top),
right: toNumber(border.right),
bottom: toNumber(border.bottom),
},
fill: raw.fill === 'stretch' ? 'stretch' : DEFAULT_FILL,
});
setCutSortLine(cut, size, toNumber(raw.sortLine));
}
/**
* A frame's stored outlines: a list of corner lists, or – as saved before a frame held several –
* one corner list, which is one outline.
*/
function collisionOutlines(raw: unknown): Vec2[][] {
if (!Array.isArray(raw)) return [];
if (raw.some((item) => !Array.isArray(item))) return [pointsOf(raw as PropertyValue)];
return raw.map((outline) => pointsOf(outline as PropertyValue));
}
/**
* The rows of an animation. Keys hold flat values only – a row animates one property – and are
* sorted onto whole frames; a second row on the same target and property, or with an id already
* taken, is dropped like a doubled node.
*/
function normalizeTracks(raw: unknown): AnimationTrack[] {
const tracks: AnimationTrack[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const t = asRecord(item);
if (typeof t.id !== 'string' || !t.id || typeof t.property !== 'string' || !t.property) continue;
const target = typeof t.target === 'string' ? t.target : '';
if (tracks.some((o) => o.id === t.id || (o.target === target && o.property === t.property))) continue;
const keys: AnimationKey[] = [];
for (const k of Array.isArray(t.keys) ? t.keys : []) {
const key = asRecord(k);
const frame = Number(key.frame);
if (!Number.isFinite(frame) || !isPrimitive(key.value)) continue;
const kept: AnimationKey = { frame, value: key.value };
// `linear` is what no field means, so it is not kept; an unknown one reads as linear, too.
if (isKeyInterpolation(key.interpolation) && key.interpolation !== 'linear')
kept.interpolation = key.interpolation;
keys.push(kept);
}
const track: AnimationTrack = { id: t.id, target, property: t.property, keys: normalizeKeys(keys) };
if (t.relative === true) track.relative = true;
tracks.push(track);
}
return tracks;
}
/**
* The layers of a tilemap. A cell whose key is not a pair of whole numbers, or which names nothing,
* is dropped; one naming a deleted tile is kept, like an animation's dangling row, so an undo
* brings the drawing back. A doubled layer id is dropped as a doubled node is.
*/
function normalizeLayers(raw: unknown): TilemapLayer[] {
const layers: TilemapLayer[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const l = asRecord(item);
if (typeof l.id !== 'string' || !l.id || layers.some((o) => o.id === l.id)) continue;
const stored = decodeCells(l);
const cells: Record<string, string> = {};
for (const [key, value] of Object.entries(stored))
if (parseCellKey(key) && typeof value === 'string' && value !== '') cells[key] = value;
const layer: TilemapLayer = { id: l.id, name: typeof l.name === 'string' ? l.name : 'Layer', cells };
if (l.hidden === true) layer.hidden = true;
if (typeof l.z === 'number' && Number.isFinite(l.z) && l.z !== 0) layer.z = l.z;
if (l.sortByY === true) layer.sortByY = true;
layers.push(layer);
}
return layers;
}
/**
* The layers of a vector art asset (#190). A drawing saved before there were layers holds a flat
* list of `shapes`; they become its one layer. A layer or an item whose id is taken is dropped, as a
* doubled tilemap layer is, and so is a group with nothing in it – a group is never empty. A
* drawing left without a layer gets one, since there has to be somewhere to draw.
*/
function normalizeVectorLayers(asset: Record<string, unknown>): VectorLayer[] {
const taken = new Set<string>();
const layers: VectorLayer[] = [];
for (const item of Array.isArray(asset.layers) ? asset.layers : []) {
const l = asRecord(item);
if (typeof l.id !== 'string' || !l.id || taken.has(l.id)) continue;
taken.add(l.id);
const layer: VectorLayer = {
id: l.id,
name: typeof l.name === 'string' && l.name ? l.name : 'Layer',
items: normalizeVectorItems(l.items, taken),
};
if (l.hidden === true) layer.hidden = true;
layers.push(layer);
}
if (layers.length === 0)
layers.push({ id: newId(), name: 'Layer 1', items: normalizeVectorItems(asset.shapes, taken) });
return layers;
}
/** Shapes and groups; whatever carries `items` is a group. */
function normalizeVectorItems(raw: unknown, taken: Set<string>): VectorItem[] {
const items: VectorItem[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const record = asRecord(item);
if (typeof record.id !== 'string' || !record.id || taken.has(record.id)) continue;
taken.add(record.id);
if (!Array.isArray(record.items)) {
items.push(normalizeShape(record, record.id));
continue;
}
const inner = normalizeVectorItems(record.items, taken);
if (inner.length > 0)
items.push({
id: record.id,
name: typeof record.name === 'string' ? record.name : 'Group',
items: inner,
});
}
return items;
}
/**
* One shape of a vector art asset (#129). A point whose coordinates are no numbers falls away, as
* a frame's outline corners do, and so does a handle that is not a pair of numbers – the point then
* reads as a corner, which is what a shape drawn without handles says anyway. A shape left without
* any drawable ring is kept: it is an outline someone is still drawing, and the rasteriser passes
* over it.
*/
function normalizeShape(s: Record<string, unknown>, id: string): VectorShape {
const rings: VectorRing[] = [];
for (const r of Array.isArray(s.rings) ? s.rings : []) {
const ring = asRecord(r);
// Closed is what a shape usually is, and what every ring written by the editor says.
rings.push({ points: normalizePoints(ring.points), closed: ring.closed !== false });
}
const shape: VectorShape = {
id,
name: typeof s.name === 'string' ? s.name : 'Shape',
rings,
fill: normalizeColor(s.fill, SHAPE_FILL_DEFAULT),
opacity: normalizeOpacity(s.opacity),
};
const paint = normalizePaint(s.paint);
if (paint) shape.paint = paint;
const stroke = asRecord(s.stroke);
const width = asFinite(stroke.width);
if (width !== null && width > 0) {
shape.stroke = { color: normalizeColor(stroke.color, 0), width };
const line = normalizePaint(stroke.paint);
if (line && line.type !== 'texture') shape.stroke.paint = line;
}
return shape;
}
/** The points of a ring or of a curve (#247); the rules are those of `normalizeShape`. */
function normalizePoints(raw: unknown): VectorPoint[] {
const points: VectorPoint[] = [];
for (const p of Array.isArray(raw) ? raw : []) {
const point = asRecord(p);
const x = asFinite(point.x);
const y = asFinite(point.y);
if (x === null || y === null) continue;
const kept: VectorPoint = { x, y };
for (const side of ['in', 'out'] as const) {
const handle = normalizeHandle(point[side]);
if (handle) kept[side] = handle;
}
if (point.mode === 'smooth' || point.mode === 'symmetric') kept.mode = point.mode;
points.push(kept);
}
return points;
}
/**
* A number as written, or null – unlike `toNumber`, which reads anything unusable as 0. A coordinate
* of 0 is a place on the canvas, so "broken" and "the left edge" must not come out the same.
*/
function asFinite(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
/**
* A gradient or picture fill (#186). One that cannot be drawn as written – no ends, an unknown type,
* fewer than two stops – is dropped and the shape reads as the solid colour it kept all along. A
* texture id is kept even when nothing answers to it: the drawing falls back to its colour and
* shows it, as a sprite shows a texture that has gone.
*/
function normalizePaint(raw: unknown): VectorPaint | null {
const p = asRecord(raw);
const from = normalizeHandle(p.from);
const to = normalizeHandle(p.to);
if (!from || !to) return null;
if (p.type === 'texture')
return typeof p.texture === 'string' ? { type: 'texture', from, to, texture: p.texture } : null;
if (p.type !== 'linear' && p.type !== 'radial') return null;
const stops: GradientStop[] = [];
for (const item of Array.isArray(p.stops) ? p.stops : []) {
const stop = asRecord(item);
const offset = asFinite(stop.offset);
if (offset === null) continue;
stops.push({
offset: Math.max(0, Math.min(1, offset)),
color: normalizeColor(stop.color, 0),
alpha: normalizeOpacity(stop.alpha),
});
}
const gradient: VectorGradient = { type: p.type, from, to, stops };
return stops.length >= 2 ? gradient : null;
}
function normalizeHandle(raw: unknown): Vec2 | undefined {
const handle = asRecord(raw);
const x = asFinite(handle.x);
const y = asFinite(handle.y);
return x !== null && y !== null ? { x, y } : undefined;
}
function normalizeColor(raw: unknown, fallback: number): number {
const value = asFinite(raw);
return value === null ? fallback : Math.max(0, Math.min(0xffffff, Math.floor(value)));
}
function normalizeOpacity(raw: unknown): number {
const value = asFinite(raw);
return value === null ? 1 : Math.max(0, Math.min(1, value));
}
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
const r = asRecord(raw);
const bag: PropertyBag = {};
for (const def of schema) {
const value = r[def.key];
if (value === undefined || value === null) {
// A key with its own `initial` stays missing: written out, the old reading would count as
// a deviation from what a new asset starts with, and mark every older asset gold.
if (!sparse && initialOf(def) === def.default) bag[def.key] = def.default;
continue;
}
bag[def.key] = coerceValue(def, value as PropertyValue);
}
return bag;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
}
/**
* A value from an untyped slot (node params, instance edits): lists and structs at any depth, each
* item as the kind it says it is. Only what is no value at all (`null`, a bad tag) is dropped.
*/
function asStoredValue(value: unknown): PropertyValue | undefined {
if (isPrimitive(value)) return value;
if (Array.isArray(value))
return value.map(asStoredValue).filter((item): item is PropertyValue => item !== undefined);
if (!value || typeof value !== 'object') return undefined;
const tag = tagOf(value);
if (tag !== undefined) return isTaggedKind(tag) ? makeItem(tag, value as PropertyValue) : undefined;
const fields: Record<string, PropertyValue> = {};
for (const [key, item] of Object.entries(value)) {
const stored = asStoredValue(item);
if (stored !== undefined) fields[key] = stored;
}
return fields;
}
const isPrimitive = (value: unknown): value is number | string | boolean =>
typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean';
function toNumber(value: unknown): number {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}