Add the composition asset with its tracks, items and clips (#417)

A composition is a timeline of scene objects for cuts and cutscenes.
This first step brings the model and the pure evaluation behind it.
The tab and placing it in a scene follow in the next steps.

- CompositionAsset holds objects, tracks, items and clips. Each item
  owns exactly one object with overrides and container edits.
- The composition is a third object holder next to scene and
  container. Delete cascade, undo, usages and references work with it.
- Duplicating gives the copy new object ids and moves the item ids
  along with them.
- compositionAt() tells which items live at a frame. The top track is
  drawn in front.
- A clip acts on the item of its own track that it overlaps. After its
  end it holds the last value until the item ends. The animation's
  play mode is ignored, a clip is one pass.
- Clip targets are found by template path, else by name path, like
  the runtime's animation system.
- The root sprite of an item plays its video driven at offset plus
  time. Children follow unless they are already driven by hand.
- fittedLength() gives the video or clip length minus the offset, or
  3 s without media.
- fps (60) and loop live in their own properties file. Keeping them in
  PropertySchema would close an import cycle.
- The type is listed under Visuals with its own icon. It is not
  placeable yet.

Tests: the gate passed with 300 files and 4625 tests. That is 25 new
tests in tests/core/composition.test.ts and 2 icon checks that run
for every icon. Four counter-checks (track order, overlap rule, child
videos, ids on duplicate) each turned exactly their test red. e2e 04
passed with 67 checks and now counts 33 asset types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-12 06:36:30 +02:00
1 parent 597367c804
commit 73e675835e
14 files changed
+1031 -12

No files matched your search

+13 -1
View File
@@ -16,6 +16,7 @@ import type { AssetType } from '../model/AssetTypes';
import { embeddedIn } from '../music/embedded';
import { defaultTracks } from '../music/song';
import { heldSongGraph } from '../music/routing';
import { newCompositionTrack } from '../model/composition';
import { linearCurve } from '../model/curve';
import { isFontFile } from '../model/font';
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
@@ -126,6 +127,15 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'bus' };
case 'particles':
return { ...base, type: 'particles', graph: createEmptyGraph() };
case 'composition':
return {
...base,
type: 'composition',
objects: [],
tracks: [newCompositionTrack(1)],
items: [],
clips: [],
};
}
}
@@ -221,13 +231,15 @@ function copyOf(original: Asset, id: string, name: string): Asset {
if (copy.type === 'texture' || copy.type === 'pixelart')
for (const frame of copy.frames) frame.id = newId();
renewClipIds(copy, new Map());
if (copy.type === 'scene' || copy.type === 'container') {
if (copy.type === 'scene' || copy.type === 'container' || copy.type === 'composition') {
const ids = new Map(copy.objects.map((o) => [o.id, newId()]));
for (const object of copy.objects) {
object.id = ids.get(object.id)!;
object.parentId = ids.get(object.parentId) ?? object.parentId;
}
swapReferences(copy.objects, (ref) => renamedReference(ref, ids), new Recorder());
// An item spawns the object of its own id.
if (copy.type === 'composition') for (const item of copy.items) item.id = ids.get(item.id) ?? item.id;
}
return copy;
}
+48
View File
@@ -260,6 +260,53 @@ export interface AnimationAsset extends AssetBase {
tracks: AnimationTrack[];
}
/** A lane of a composition (#417); the first one lies in front. */
export interface CompositionTrack {
id: string;
name: string;
}
/**
* One stretch on a track: the object of the same id lives from `start` for `length` frames.
* `offset` is how many seconds into its video the item begins, which a trim of the left edge moves.
* `fitMedia` says its length still follows that video, or the default while there is none.
*/
export interface CompositionItem {
id: string;
track: string;
start: number;
length: number;
offset: number;
fitMedia?: boolean;
}
/**
* An animation laid on a track from `start`, one pass long. It plays on whichever item of its track
* it overlaps; its rows find their targets there as `Play animation` finds them.
*/
export interface CompositionClip {
id: string;
track: string;
animation: string;
start: number;
}
/**
* Objects along a time axis (#417), for a cut or a cutscene: each item spawns its object for as long
* as it lasts, and animation clips play on the items they overlap. The objects are stored like a
* scene's, overrides and container edits included, one per item and all at the top level. Time is
* counted in frames of its own `fps` (see `model/composition.ts`).
*/
export interface CompositionAsset extends AssetBase {
type: 'composition';
objects: SceneObject[];
/** Hierarchy folders, as a scene's (#241); absent when there are none. */
groups?: ObjectGroup[];
tracks: CompositionTrack[];
items: CompositionItem[];
clips: CompositionClip[];
}
/**
* The graph holds one `State` node per state and an edge per transition; a behavior reads the
* machine's current state through `State branch` and moves it through `Set state` or `State transition`.
@@ -670,6 +717,7 @@ export type Asset =
| PixelArtAsset
| PaletteAsset
| AnimationAsset
| CompositionAsset
| CurveAsset
| TileAsset
| TilemapAsset
+6 -1
View File
@@ -31,6 +31,7 @@ export const ASSET_TYPES = [
'bus',
'particles',
'video',
'composition',
] as const;
export type AssetType = (typeof ASSET_TYPES)[number];
@@ -68,6 +69,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
bus: 'Bus',
particles: 'Particles',
video: 'Video',
composition: 'Composition',
};
/**
@@ -82,7 +84,8 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
* sound is played through (#311), heard but never seen, an instrument what a note is played
* with (#331), a pattern the notes it plays (#333) and a song what lays them out (#334). A bus is
* where sounds are mixed on their way out (#325). A video is a moving picture a sprite wears through
* `texture` (#402), as it wears an image.
* `texture` (#402), as it wears an image. A composition is not placed yet: what a placed one shows
* and plays is still to come (#417).
*/
export type PlaceableAssetType = Exclude<
AssetType,
@@ -107,6 +110,7 @@ export type PlaceableAssetType = Exclude<
| 'variables'
| 'struct'
| 'video'
| 'composition'
>;
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
@@ -131,6 +135,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'variables',
'struct',
'video',
'composition',
]);
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
+6 -4
View File
@@ -1,5 +1,6 @@
import {
type Asset,
type CompositionAsset,
type ContainerAsset,
type GraphAsset,
isGraphAsset,
@@ -151,13 +152,14 @@ export function findGraphAsset(project: Project, assetId: string): GraphHolder |
}
/**
* Everything that holds placed objects. A container's `objects` are a template, so anything
* walking "all placed objects" must cover both or it silently skips the templates.
* Everything that holds placed objects. A container's `objects` are a template, and a composition's
* live only while their items run, so anything walking "all placed objects" must cover all three or
* it silently skips some.
*/
export type ObjectHolder = SceneAsset | ContainerAsset;
export type ObjectHolder = SceneAsset | ContainerAsset | CompositionAsset;
function isObjectHolder(asset: Asset): asset is ObjectHolder {
return asset.type === 'scene' || asset.type === 'container';
return asset.type === 'scene' || asset.type === 'container' || asset.type === 'composition';
}
export function objectHolders(project: Project): ObjectHolder[] {
+4 -3
View File
@@ -41,7 +41,7 @@ export function previewHolder(
const at: ReadAt = { frame, reverse, curves: curveLookup(project) };
for (const track of reset ? [] : animation.tracks) {
if (root.type === 'scene' && track.target === '') writeScene(holder, track, at);
else writeObject(project, holder, previewObjectId(root, track.target), track, at);
else writeObjectRow(project, holder, previewObjectId(root, track.target), track, at);
}
return holder;
}
@@ -71,7 +71,7 @@ function placedHolder(root: Asset): ObjectHolder {
}
/** Where on the timeline a row is read, and the Curve assets its keys may blend along. */
interface ReadAt {
export interface ReadAt {
frame: number;
reverse: boolean;
curves: CurveLookup;
@@ -86,7 +86,8 @@ function writeScene(holder: ObjectHolder, track: AnimationTrack, at: ReadAt): vo
if (value !== undefined) holder.props[track.property] = value;
}
function writeObject(
/** A row on the object `id` of the holder, stored there or resolved from a container – also a composition's (#417). */
export function writeObjectRow(
project: Project,
holder: ObjectHolder,
id: string,
+303
View File
@@ -0,0 +1,303 @@
/**
* Compositions (#417): objects along a time axis, counted in frames of the composition's own `fps`.
* What runs at a frame is one pure function, `compositionAt`, so the tab's preview and the running
* game read the same cut; `compositionHolder` writes it out as objects for a view to draw.
*/
import { newId } from '../ids';
import {
COMPOSITION_FPS_KEY,
COMPOSITION_LOOP_KEY,
DEFAULT_COMPOSITION_FPS,
} from '../properties/compositions';
import { resolveSceneObjectProps } from '../properties/resolve';
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY, videoDrivenOf } from '../properties/videos';
import { animationDuration, fpsOf, frameCount, targetNamePath } from './animation';
import { type ReadAt, writeObjectRow } from './animationPreview';
import type {
AnimationAsset,
AnimationTrack,
CompositionAsset,
CompositionClip,
CompositionItem,
CompositionTrack,
} from './Asset';
import { curveLookup } from './curve';
import { instanceChildId, objectRef, resolveObjects, splitInstanceId } from './instance';
import { findAsset, findAssetOf, type Project } from './Project';
import { booleanOf, type PropertyBag } from './Properties';
import { objectName, type SceneObject } from './SceneObject';
import { findVideo, videoLengthOf, type VideoRef } from './video';
/** How long an item lasts while it has no video to take its length from. */
export const DEFAULT_ITEM_SECONDS = 3;
export function compositionFps(comp: CompositionAsset): number {
const fps = Number(comp.props[COMPOSITION_FPS_KEY]);
return Number.isFinite(fps) && fps > 0 ? fps : DEFAULT_COMPOSITION_FPS;
}
export function compositionLoops(comp: CompositionAsset): boolean {
return booleanOf(comp.props[COMPOSITION_LOOP_KEY]);
}
export function newCompositionTrack(index: number): CompositionTrack {
return { id: newId(), name: `Track ${index}` };
}
export function itemEnd(item: CompositionItem): number {
return item.start + item.length;
}
/** The object an item spawns; undefined once it is gone, and then the item does nothing. */
export function itemObject(comp: CompositionAsset, item: CompositionItem): SceneObject | undefined {
return comp.objects.find((o) => o.id === item.id);
}
/**
* The animation a clip plays, unless it is gone or built over a scene: a scene's rows aim at that
* scene's objects, nothing an item holds.
*/
function clipAnimation(project: Project, clip: CompositionClip): AnimationAsset | undefined {
const animation = findAssetOf(project, clip.animation, 'animation');
return animation && findAsset(project, animation.root)?.type !== 'scene' ? animation : undefined;
}
/** Frames one pass of the clip's animation takes in the composition; 0 when it plays nothing. */
export function clipLength(project: Project, comp: CompositionAsset, clip: CompositionClip): number {
const animation = clipAnimation(project, clip);
const seconds = animation ? animationDuration(animation) : 0;
return seconds > 0 ? Math.max(1, Math.ceil(seconds * compositionFps(comp) - 1e-6)) : 0;
}
/** The frame after the last item or clip ends: how long one run of the composition is. */
export function compositionLength(project: Project, comp: CompositionAsset): number {
let end = 0;
for (const item of comp.items) if (itemObject(comp, item)) end = Math.max(end, itemEnd(item));
for (const clip of comp.clips) end = Math.max(end, clip.start + clipLength(project, comp, clip));
return end;
}
/**
* The video the item's own object wears through its texture – the override's, else its sprite's. A
* video worn further down a container item runs with the item but gives it no length.
*/
export function itemVideo(project: Project, comp: CompositionAsset, item: CompositionItem): VideoRef | null {
const object = itemObject(comp, item);
const asset = object && findAsset(project, object.assetId);
if (!object || asset?.type !== 'sprite') return null;
return videoWorn(resolveSceneObjectProps(project, asset, object), project);
}
function videoWorn(props: PropertyBag, project: Project): VideoRef | null {
const texture = props.texture;
return typeof texture === 'string' ? findVideo(project, texture) : null;
}
/**
* The length an item with `fitMedia` takes: what is left of its video after `offset`, or the default
* while it wears none or the video's length is not known yet.
*/
export function fittedLength(project: Project, comp: CompositionAsset, item: CompositionItem): number {
const fps = compositionFps(comp);
const video = itemVideo(project, comp, item);
const seconds = video ? videoLengthOf(video) - item.offset : 0;
return seconds > 0 ? Math.max(1, Math.round(seconds * fps)) : Math.round(DEFAULT_ITEM_SECONDS * fps);
}
/** A clip playing on an item, at `frame` of its animation – held on the last one once it is through. */
export interface ClipAt {
clip: CompositionClip;
animation: AnimationAsset;
frame: number;
}
/** An item alive at a frame of the composition. */
export interface LiveItem {
item: CompositionItem;
object: SceneObject;
/** The index of its track, 0 the first. */
track: number;
/** Seconds since the item began. */
time: number;
/** Seconds into what its videos play: `time` past the item's offset. */
videoTime: number;
/** In the order they started; a later one writes over an earlier one. */
clips: ClipAt[];
}
/**
* Everything alive at `frame`, in drawing order: the last track first, so the first lies in front –
* the order the items take below the composition, which decides between equal `z`. An item lives
* from its start up to, not including, its end; one on a track that is gone, or whose object is gone,
* does not. A clip plays on an item of its track it overlaps once it has started, and holds its last
* frame for as long as that item lasts.
*/
export function compositionAt(project: Project, comp: CompositionAsset, frame: number): LiveItem[] {
const fps = compositionFps(comp);
const tracks = new Map(comp.tracks.map((t, index) => [t.id, index]));
const clips = comp.clips
.map((clip, order) => ({ clip, order, animation: clipAnimation(project, clip) }))
.filter((c) => c.animation !== undefined && c.clip.start <= frame)
.sort((a, b) => a.clip.start - b.clip.start || a.order - b.order);
const live: { item: LiveItem; order: number }[] = [];
comp.items.forEach((item, order) => {
const track = tracks.get(item.track);
const object = itemObject(comp, item);
if (track === undefined || !object || frame < item.start || frame >= itemEnd(item)) return;
const time = (frame - item.start) / fps;
const playing: ClipAt[] = [];
for (const { clip, animation } of clips) {
const end = clip.start + clipLength(project, comp, clip);
if (clip.track !== item.track || clip.start >= itemEnd(item) || end <= item.start) continue;
const at = ((frame - clip.start) / fps) * fpsOf(animation!);
playing.push({ clip, animation: animation!, frame: Math.min(at, frameCount(animation!) - 1) });
}
live.push({ item: { item, object, track, time, videoTime: item.offset + time, clips: playing }, order });
});
live.sort(
(a, b) => b.item.track - a.item.track || a.item.item.start - b.item.item.start || a.order - b.order,
);
return live.map((l) => l.item);
}
/**
* The composition at `frame` as objects a view draws: a copy holding only what is alive, in drawing
* order, each with its clips' rows and its video time written over it. Every sprite of an item that
* wears a video and is not driven by hand is driven at the item's video time. Nothing here touches
* the document.
*/
export function compositionHolder(project: Project, comp: CompositionAsset, frame: number): CompositionAsset {
const live = compositionAt(project, comp, frame);
const holder: CompositionAsset = {
...comp,
props: { ...comp.props },
objects: live.map((l) => copyOf(l.object)),
};
const curves = curveLookup(project);
for (const item of live) {
driveVideos(project, holder, item);
for (const clip of item.clips) {
const at: ReadAt = { frame: clip.frame, reverse: false, curves };
for (const track of clip.animation.tracks) {
const id = rowTarget(project, holder, item.object.id, clip.animation, track);
if (id) writeObjectRow(project, holder, id, track, at);
}
}
}
return holder;
}
const record = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
const text = (value: unknown) => (typeof value === 'string' ? value : '');
const finite = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : NaN);
const list = (raw: unknown): Record<string, unknown>[] => (Array.isArray(raw) ? raw.map(record) : []);
/** Held to what a track may be: ids no other has, a name, and at least one track. */
export function heldCompositionTracks(raw: unknown): CompositionTrack[] {
const tracks: CompositionTrack[] = [];
for (const t of list(raw)) {
const id = text(t.id);
if (!id || tracks.some((other) => other.id === id)) continue;
tracks.push({ id, name: text(t.name) || `Track ${tracks.length + 1}` });
}
return tracks.length > 0 ? tracks : [newCompositionTrack(1)];
}
/**
* Held to what an item may be: the id of an object it spawns and no other item does, a track of
* the composition, a start in whole frames from 0, at least a frame long, an offset from 0.
*/
export function heldCompositionItems(
raw: unknown,
tracks: readonly CompositionTrack[],
objects: readonly SceneObject[],
): CompositionItem[] {
const known = new Set(tracks.map((t) => t.id));
const placed = new Set(objects.map((o) => o.id));
const items: CompositionItem[] = [];
for (const i of list(raw)) {
const id = text(i.id);
const start = Math.round(finite(i.start));
const length = Math.round(finite(i.length));
if (!placed.has(id) || items.some((other) => other.id === id) || !known.has(text(i.track))) continue;
if (!(start >= 0) || !(length >= 1)) continue;
const offset = finite(i.offset);
const item: CompositionItem = {
id,
track: text(i.track),
start,
length,
offset: offset > 0 ? offset : 0,
};
if (i.fitMedia === true) item.fitMedia = true;
items.push(item);
}
return items;
}
/** Held like an item; an animation that is gone is kept, as an animation keeps a root that is gone. */
export function heldCompositionClips(raw: unknown, tracks: readonly CompositionTrack[]): CompositionClip[] {
const known = new Set(tracks.map((t) => t.id));
const clips: CompositionClip[] = [];
for (const c of list(raw)) {
const id = text(c.id);
const start = Math.round(finite(c.start));
if (!id || clips.some((other) => other.id === id) || !known.has(text(c.track)) || !(start >= 0)) continue;
clips.push({ id, track: text(c.track), animation: text(c.animation), start });
}
return clips;
}
function copyOf(object: SceneObject): SceneObject {
return {
...object,
position: { ...object.position },
overrides: { ...object.overrides },
instanceEdits: structuredClone(object.instanceEdits),
};
}
function driveVideos(project: Project, holder: CompositionAsset, live: LiveItem): void {
const below = resolveObjects(project, holder).filter(
(o) => splitInstanceId(o.id)?.instanceId === live.object.id,
);
for (const object of [holder.objects.find((o) => o.id === live.object.id)!, ...below]) {
const asset = findAsset(project, object.assetId);
if (asset?.type !== 'sprite') continue;
const props = resolveSceneObjectProps(project, asset, object);
const own = object.id === live.object.id;
if (!videoWorn(props, project) || (!own && videoDrivenOf(props))) continue;
const ref = objectRef(project, holder, object.id);
ref?.setOverride(VIDEO_CLOCK_KEY, 'driven');
ref?.setOverride(VIDEO_TIME_KEY, live.videoTime);
}
}
/**
* The object of the holder a row of a clip aims at on the item `rootId`: the item itself, the object
* at the row's template path below it, or else the one its names lead to – the way `Play animation`
* finds a target on an object built from another container.
*/
function rowTarget(
project: Project,
holder: CompositionAsset,
rootId: string,
animation: AnimationAsset,
track: AnimationTrack,
): string | undefined {
if (track.target === '') return rootId;
const objects = resolveObjects(project, holder);
const byPath = instanceChildId(rootId, track.target);
if (objects.some((o) => o.id === byPath)) return byPath;
const names = targetNamePath(project, findAsset(project, animation.root), track.target);
let at: string | undefined = rootId;
for (const name of names ?? []) {
const found = objects.filter(
(o) => o.parentId === at && objectName(o, findAsset(project, o.assetId)) === name,
);
at = found.length === 1 ? found[0]!.id : undefined;
if (!at) return undefined;
}
return names ? at : undefined;
}
+16
View File
@@ -23,6 +23,7 @@ import {
} from './kinds';
import { findAssetOf, type Project } from '../model/Project';
import { ALL_LAYERS, COLLIDES_WITH_KEY, COLLISION_LAYER_KEY } from '../model/collisionLayers';
import { COMPOSITION_FPS_KEY, COMPOSITION_LOOP_KEY, DEFAULT_COMPOSITION_FPS } from './compositions';
import { INHERIT_LAYER, OVERLAY_KEY, RENDER_LAYER_KEY, SEES_KEY } from '../model/renderLayers';
import {
TILE_BOUNCINESS_KEY,
@@ -597,6 +598,21 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
},
],
// Tracks, items and clips are intrinsic data on the asset (see CompositionAsset). Whole frames
// per second, so a cut lands on a frame.
composition: [
{
key: COMPOSITION_FPS_KEY,
label: 'Frames per second',
kind: 'number',
default: DEFAULT_COMPOSITION_FPS,
min: 1,
max: 120,
step: 1,
decimals: 0,
},
{ key: COMPOSITION_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
],
// The pictures are intrinsic data on the asset (see TileAsset); these three say what the
// block holds and how its steps run. There is no tile size: the block's width in tiles is known
// from the kind, so the picture already says it (see `readTile`). A new tile is a plain one; the
+4
View File
@@ -0,0 +1,4 @@
/** The rows of a composition (#417), apart from its model so the schema can read them without a cycle. */
export const COMPOSITION_FPS_KEY = 'fps';
export const COMPOSITION_LOOP_KEY = 'loop';
export const DEFAULT_COMPOSITION_FPS = 60;
+22 -2
View File
@@ -36,6 +36,7 @@ import type {
AnimationTrack,
Asset,
BehaviorAsset,
CompositionAsset,
ContainerAsset,
PictureCut,
PixelArtAsset,
@@ -72,7 +73,8 @@ import { isSoundFile } from '../model/sound';
import { isVideoFile } from '../model/video';
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 { type ObjectHolder, PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import { heldCompositionClips, heldCompositionItems, heldCompositionTracks } from '../model/composition';
import { readScope, readSharedScope } from '../model/Scope';
import type { ObjectGroup, SceneObject } from '../model/SceneObject';
import {
@@ -205,6 +207,24 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
};
normalizeGroups(holder, a.groups);
assets.push(holder);
} else if (type === 'composition') {
// One object per item, all at the top level; an object no item spawns is dropped with it.
const objects = normalizeObjects(a.objects, schemaOf).map((o) => ({ ...o, parentId: '' }));
const tracks = heldCompositionTracks(a.tracks);
const items = heldCompositionItems(a.items, tracks, objects);
const spawned = new Set(items.map((i) => i.id));
const composition: CompositionAsset = {
id,
type,
name,
props,
objects: objects.filter((o) => spawned.has(o.id)),
tracks,
items,
clips: heldCompositionClips(a.clips, tracks),
};
normalizeGroups(composition, a.groups);
assets.push(composition);
} else if (type === 'behavior') {
const declared = variables.get(id) ?? [];
const behavior: BehaviorAsset = {
@@ -447,7 +467,7 @@ function normalizeObjects(
* 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 {
function normalizeGroups(holder: ObjectHolder, raw: unknown): void {
const groups = new Map<string, ObjectGroup>();
for (const item of Array.isArray(raw) ? raw : []) {
const g = asRecord(item);