Edit a composition's tracks, items and clips in its own tab (#417)

A composition could be placed and played, but not filled. Its tab
puts the picture at the playhead above a timeline of tracks, so a cut
or a cutscene is laid out by dragging assets onto tracks.

- The timeline takes the song tab's geometry with frames for ticks:
  ruler in seconds, scrubbing, play and pause, + Track. Zoom and pan
  glide through viewTween like the other tabs.
- A placeable asset dropped on a track becomes an item, 3 s long or
  as long as its sprite's video. Dropped on an item it lands behind.
- Items move without overlapping. The right edge stops at the next
  item. The left edge trims into a video and not before its start.
- An animation dropped on a track becomes a narrower clip bar above
  the item it plays on. The asset panel now starts a drag for
  animations too.
- The picture is a scene view over compositionHolder. A click picks
  an item or its child, a drag moves the real object. It takes no
  assets, since an object there would have no item.
- The hierarchy lists the items alive at the playhead. Reparenting is
  off there, items stay at the top level.
- The inspector shows a TIMING block on an item (start, length,
  offset for a video, Fit to media) and edits tracks and clips.
- Delete, the context menu and Remove item take item and object in
  one undo step.
- fitMedia: an override that gives an item another video sets its
  length in the same undo step, capped at the next item. Dragging an
  edge or typing a length turns it off.
- A composition inside a composition is refused.

Tests:
- Unit composition-edit (new): 19 tests. Four counter-checks each
  turned their test red. The "back to 3 s" test got a step in
  between after one stayed green.
- e2e 69 (new): 11 checks, 9 own. The hierarchy check reads the rows
  shown, after a counter-check on the model's rows stayed green.
- Gate on melektaus-atx: CHECK EXIT 0, 302 files, 4662 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-12 07:47:22 +02:00
1 parent 0334d2508d
commit e435c54afb
24 files changed
+2534 -19

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2023 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2032 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+175
View File
@@ -0,0 +1,175 @@
/**
* Editing a composition (#417). As with a song, one gesture is one command: tracks, items and clips
* are written whole once the pointer lets go. An item comes and goes together with its object.
*/
import type { CompositionAsset, CompositionClip, CompositionItem, CompositionTrack } from '../model/Asset';
import { heldCompositionClips, heldCompositionItems, heldCompositionTracks } from '../model/composition';
import { mediaKey, refittedLength } from '../model/compositionEdit';
import type { Project } from '../model/Project';
import type { Command } from './Command';
import { oneStep } from './CompoundCommand';
import { requireComposition } from './require';
import { PlaceAssetCommand, RemoveSceneObjectsCommand } from './sceneObjectCommands';
export interface CompositionParts {
tracks?: readonly CompositionTrack[];
items?: readonly CompositionItem[];
clips?: readonly CompositionClip[];
}
/** Writes the tracks, items and clips whole, held to what each may be. */
export class SetCompositionCommand implements Command {
private previous: {
tracks: CompositionTrack[];
items: CompositionItem[];
clips: CompositionClip[];
} | null = null;
constructor(
private readonly assetId: string,
private readonly parts: CompositionParts,
readonly label = 'Edit composition',
) {}
/** Nothing to do when the composition already reads exactly like this – no empty undo step. */
static changes(project: Project, assetId: string, parts: CompositionParts): boolean {
const asset = requireComposition(project, assetId);
return JSON.stringify(held(asset, parts)) !== JSON.stringify([asset.tracks, asset.items, asset.clips]);
}
execute(project: Project): void {
const asset = requireComposition(project, this.assetId);
this.previous = { tracks: asset.tracks, items: asset.items, clips: asset.clips };
[asset.tracks, asset.items, asset.clips] = held(asset, this.parts);
}
undo(project: Project): void {
if (!this.previous) return;
const asset = requireComposition(project, this.assetId);
({ tracks: asset.tracks, items: asset.items, clips: asset.clips } = this.previous);
}
}
function held(
asset: CompositionAsset,
parts: CompositionParts,
): [CompositionTrack[], CompositionItem[], CompositionClip[]] {
const tracks = heldCompositionTracks(parts.tracks ?? asset.tracks);
const items = heldCompositionItems(parts.items ?? asset.items, tracks, asset.objects);
return [tracks, items, heldCompositionClips(parts.clips ?? asset.clips, tracks)];
}
/**
* An asset laid on a track as a new item: its object at the composition's origin and the item
* spanning `start` and `length`. `objectId` is the item's id, to pick it afterwards.
*/
export function addCompositionItem(
project: Project,
assetId: string,
placed: string,
item: Omit<CompositionItem, 'id'>,
): { command: Command; objectId: string } {
const place = new PlaceAssetCommand(assetId, placed, { x: 0, y: 0 });
const comp = requireComposition(project, assetId);
const set = new SetCompositionCommand(assetId, { items: [...comp.items, { ...item, id: place.objectId }] });
return { command: oneStep('Add item', [place, set]), objectId: place.objectId };
}
/** Items and clips taken off together, the items' objects with them. */
export function removeFromComposition(
project: Project,
assetId: string,
itemIds: readonly string[],
clipIds: readonly string[],
): Command {
const comp = requireComposition(project, assetId);
const gone = new Set([...itemIds, ...clipIds]);
const objects = comp.objects.filter((o) => gone.has(o.id)).map((o) => o.id);
const set = new SetCompositionCommand(assetId, {
items: comp.items.filter((i) => !gone.has(i.id)),
clips: comp.clips.filter((c) => !gone.has(c.id)),
});
const count = itemIds.length + clipIds.length;
const label = count === 1 ? (itemIds.length === 1 ? 'Remove item' : 'Remove clip') : 'Remove items';
return oneStep(label, objects.length > 0 ? [new RemoveSceneObjectsCommand(assetId, objects), set] : [set]);
}
/** A track taken off with everything on it. */
export function removeCompositionTrack(project: Project, assetId: string, trackId: string): Command {
const comp = requireComposition(project, assetId);
const items = comp.items.filter((i) => i.track === trackId).map((i) => i.id);
const clips = comp.clips.filter((c) => c.track === trackId).map((c) => c.id);
const objects = comp.objects.filter((o) => items.includes(o.id)).map((o) => o.id);
const set = new SetCompositionCommand(assetId, {
tracks: comp.tracks.filter((t) => t.id !== trackId),
items: comp.items.filter((i) => !items.includes(i.id)),
clips: comp.clips.filter((c) => !clips.includes(c.id)),
});
return oneStep(
'Remove track',
objects.length > 0 ? [new RemoveSceneObjectsCommand(assetId, objects), set] : [set],
);
}
interface Refit {
assetId: string;
itemId: string;
from: number;
to: number;
}
/**
* Any edit, with every item that follows its media fitted to it again where the edit changed what it
* wears (#417): a texture turned to a video takes the video's length, one turned back the default.
* In the same undo step as the edit, and done again with it.
*/
export class FitMediaCommand implements Command {
private refits: Refit[] | null = null;
constructor(readonly inner: Command) {}
get label(): string {
return this.inner.label;
}
execute(project: Project): void {
const before = this.refits ? null : mediaKeys(project);
this.inner.execute(project);
this.refits ??= refitsAfter(project, before!);
for (const r of this.refits) setLength(project, r, r.to);
}
undo(project: Project): void {
for (const r of [...(this.refits ?? [])].reverse()) setLength(project, r, r.from);
this.inner.undo(project);
}
}
function mediaKeys(project: Project): Map<string, string> {
const keys = new Map<string, string>();
for (const comp of project.assets) {
if (comp.type !== 'composition') continue;
for (const item of comp.items)
if (item.fitMedia) keys.set(`${comp.id}/${item.id}`, mediaKey(project, comp, item));
}
return keys;
}
function refitsAfter(project: Project, before: ReadonlyMap<string, string>): Refit[] {
const refits: Refit[] = [];
for (const comp of project.assets) {
if (comp.type !== 'composition') continue;
for (const item of comp.items) {
const was = before.get(`${comp.id}/${item.id}`);
if (!item.fitMedia || was === undefined || was === mediaKey(project, comp, item)) continue;
const to = refittedLength(project, comp, item);
if (to !== item.length) refits.push({ assetId: comp.id, itemId: item.id, from: item.length, to });
}
}
return refits;
}
function setLength(project: Project, refit: Refit, length: number): void {
const comp = requireComposition(project, refit.assetId);
comp.items = comp.items.map((i) => (i.id === refit.itemId ? { ...i, length } : i));
}
+7
View File
@@ -3,6 +3,7 @@ import { findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/G
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
import type {
Asset,
CompositionAsset,
CurveAsset,
PatternAsset,
SongAsset,
@@ -155,6 +156,12 @@ export function requireSong(project: Project, assetId: string): SongAsset {
return asset;
}
export function requireComposition(project: Project, assetId: string): CompositionAsset {
const asset = findAssetOf(project, assetId, 'composition');
if (!asset) throw new CommandError(`Composition ${assetId} not found`);
return asset;
}
export function requireVariablesAsset(project: Project, assetId: string): VariablesAsset {
const asset = findAssetOf(project, assetId, 'variables');
if (!asset) throw new CommandError(`Variables ${assetId} not found`);
+188
View File
@@ -0,0 +1,188 @@
/**
* What the Composition tab does to items and clips (#417), as plain functions on lists so a test can
* check them. Items of a track never overlap, nor do clips of a track; an edit that would make them
* is held at the last place it fits, a drop lands in the gap it falls into.
*/
import type { Asset, CompositionAsset, CompositionClip, CompositionItem } from './Asset';
import { compositionFps, DEFAULT_ITEM_SECONDS, fittedLength, itemEnd, itemVideo } from './composition';
import type { Project } from './Project';
import { findVideo, videoLengthOf } from './video';
/** A stretch of a track, in frames. */
export interface Span {
start: number;
length: number;
}
interface Placed {
id: string;
track: string;
start: number;
}
/**
* Where something `length` frames long lands when dropped at `at`: there, or right after whatever
* of `taken` covers `at`, and cut short where the next begins. Null where not a frame is free.
*/
export function droppedSpan(taken: readonly Span[], at: number, length: number): Span | null {
let start = Math.max(0, Math.round(at));
for (const span of [...taken].sort((a, b) => a.start - b.start))
if (span.start <= start && start < span.start + span.length) start = span.start + span.length;
const next = Math.min(...taken.filter((s) => s.start >= start).map((s) => s.start));
const room = Number.isFinite(next) ? next - start : Infinity;
return room >= 1 ? { start, length: Math.min(Math.max(1, length), room) } : null;
}
/** The stretches the items of a track cover, all but `except`. */
export function itemSpans(
items: readonly CompositionItem[],
track: string,
except: ReadonlySet<string> = new Set(),
): Span[] {
return items.filter((i) => i.track === track && !except.has(i.id));
}
/** The stretches the clips of a track cover, all but `except`. */
export function clipSpans(
clips: readonly CompositionClip[],
track: string,
lengthOf: (clip: CompositionClip) => number,
except: ReadonlySet<string> = new Set(),
): Span[] {
return clips
.filter((c) => c.track === track && !except.has(c.id))
.map((c) => ({ start: c.start, length: Math.max(1, lengthOf(c)) }));
}
/** Whether no two of `spans` on the same track share a frame. */
function apart<T extends Placed>(list: readonly T[], lengthOf: (t: T) => number): boolean {
const byTrack = new Map<string, T[]>();
for (const t of list) byTrack.set(t.track, [...(byTrack.get(t.track) ?? []), t]);
for (const on of byTrack.values()) {
const sorted = [...on].sort((a, b) => a.start - b.start);
for (let i = 1; i < sorted.length; i++)
if (sorted[i]!.start < sorted[i - 1]!.start + Math.max(1, lengthOf(sorted[i - 1]!))) return false;
}
return true;
}
/**
* The picked ones moved by `frames` and `tracks`; null where one would start before 0, leave the
* tracks there are, or come to lie over another.
*/
function moved<T extends Placed>(
list: readonly T[],
picked: ReadonlySet<string>,
trackIds: readonly string[],
frames: number,
tracks: number,
lengthOf: (t: T) => number,
): T[] | null {
const out: T[] = [];
for (const t of list) {
if (!picked.has(t.id)) {
out.push(t);
continue;
}
const track = trackIds[trackIds.indexOf(t.track) + tracks];
if (track === undefined || t.start + frames < 0) return null;
out.push({ ...t, start: t.start + frames, track });
}
return apart(out, lengthOf) ? out : null;
}
export function movedItems(
items: readonly CompositionItem[],
picked: ReadonlySet<string>,
trackIds: readonly string[],
frames: number,
tracks: number,
): CompositionItem[] | null {
return moved(items, picked, trackIds, frames, tracks, (i) => i.length);
}
export function movedClips(
clips: readonly CompositionClip[],
picked: ReadonlySet<string>,
trackIds: readonly string[],
frames: number,
tracks: number,
lengthOf: (clip: CompositionClip) => number,
): CompositionClip[] | null {
return moved(clips, picked, trackIds, frames, tracks, lengthOf);
}
/** The first frame after `item` another item of its track takes; Infinity when none follows. */
function nextStart(items: readonly CompositionItem[], item: CompositionItem): number {
return Math.min(
...items
.filter((i) => i.track === item.track && i.id !== item.id && i.start >= item.start)
.map((i) => i.start),
);
}
/** The end of the item before `item` on its track, 0 when none comes before. */
function previousEnd(items: readonly CompositionItem[], item: CompositionItem): number {
return Math.max(
0,
...items.filter((i) => i.track === item.track && i.id !== item.id && i.start < item.start).map(itemEnd),
);
}
/**
* The item with its end edge dragged to make it `wanted` frames long: at least a frame, and no
* further than where the next item of its track begins. Its length is its own from then on.
*/
export function resizedItem(
items: readonly CompositionItem[],
item: CompositionItem,
wanted: number,
): CompositionItem {
const most = nextStart(items, item) - item.start;
const { fitMedia: _, ...rest } = item;
return { ...rest, length: Math.max(1, Math.min(Math.round(wanted), most)) };
}
/**
* The item with its start edge dragged to `wanted`: never onto its end, before 0 or into the item
* before. What it shows stays where it was in time – the video starts that much later or sooner –
* so an item wearing a video stops at the video's beginning.
*/
export function trimmedItem(
items: readonly CompositionItem[],
item: CompositionItem,
wanted: number,
fps: number,
video: boolean,
): CompositionItem {
let start = Math.min(Math.round(wanted), itemEnd(item) - 1);
start = Math.max(start, previousEnd(items, item));
if (video) start = Math.max(start, item.start - Math.floor(item.offset * fps + 1e-6));
const by = start - item.start;
const { fitMedia: _, ...rest } = item;
const offset = video ? Math.max(0, item.offset + by / fps) : item.offset;
return { ...rest, start, length: item.length - by, offset };
}
/**
* The length an item that follows its media takes now, held to where the next item of its track
* begins: the items after it stay where they are.
*/
export function refittedLength(project: Project, comp: CompositionAsset, item: CompositionItem): number {
return Math.max(1, Math.min(fittedLength(project, comp, item), nextStart(comp.items, item) - item.start));
}
/** How long an item of `asset` is when it is dropped in: as long as the video its sprite wears, else 3 s. */
export function droppedLength(project: Project, comp: CompositionAsset, asset: Asset): number {
const texture = asset.type === 'sprite' ? asset.props.texture : undefined;
const video = typeof texture === 'string' ? findVideo(project, texture) : null;
const fps = compositionFps(comp);
const seconds = video ? videoLengthOf(video) : 0;
return seconds > 0 ? Math.max(1, Math.round(seconds * fps)) : Math.round(DEFAULT_ITEM_SECONDS * fps);
}
/** What an item wears that its length follows: the video or clip's id, '' for none. */
export function mediaKey(project: Project, comp: CompositionAsset, item: CompositionItem): string {
const video = itemVideo(project, comp, item);
return video ? (video.clip?.id ?? video.asset.id) : '';
}
+2
View File
@@ -14,5 +14,7 @@ export function placementRejection(project: Project, holder: ObjectHolder, asset
return `A ${ASSET_TYPE_LABELS[asset.type].toLowerCase()} cannot be placed into a scene`;
if (asset.type === 'container' && containerReaches(project, asset, holder.id))
return `${asset.name} cannot contain ${holder.name}: that nests forever`;
if (asset.type === 'composition' && holder.type === 'composition')
return 'A composition cannot be placed into a composition';
return '';
}
+26 -3
View File
@@ -55,6 +55,7 @@ import { dialogSlot } from './FormDialog';
import { generalSettings, keepGeneralSettings } from './generalSettings';
import { GeneralSettingsDialog } from './GeneralSettingsDialog';
import { ContainerEditor } from './container/ContainerEditor';
import { CompositionEditor } from './composition/CompositionEditor';
import { EditorRun } from './EditorRun';
import { GraphEditor } from './graph/GraphEditor';
import { Inspector } from './inspector/Inspector';
@@ -126,6 +127,7 @@ export type CentreTab =
| 'curve'
| 'tile'
| 'container'
| 'composition'
| 'variables'
| 'routing'
| 'eq'
@@ -186,6 +188,7 @@ const PANE_ORDER = [
'curve',
'tile',
'container',
'composition',
'variables',
'routing',
'eq',
@@ -215,6 +218,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
curve: 'Curve',
tile: 'Tile',
container: 'Container',
composition: 'Composition',
variables: 'Variables',
routing: 'Master routing',
eq: 'Parametric EQ',
@@ -286,6 +290,7 @@ export class Editor implements PlayHost {
readonly curveEditor: CurveEditor;
readonly tileEditor: TileEditor;
readonly containerEditor: ContainerEditor;
readonly compositionEditor: CompositionEditor;
readonly variablesEditor: VariablesEditor;
readonly routingEditor: RoutingEditor;
readonly eqEditor: EqEditor;
@@ -395,12 +400,16 @@ export class Editor implements PlayHost {
this.curveEditor = new CurveEditor(state);
this.tileEditor = new TileEditor(state);
this.containerEditor = new ContainerEditor(state);
this.compositionEditor = new CompositionEditor(state);
this.variablesEditor = new VariablesEditor(state);
this.routingEditor = new RoutingEditor(state);
this.eqEditor = new EqEditor(state);
this.compressorEditor = new CompressorEditor(state);
this.filterEditor = new EqEditor(state, 'filter');
this.hierarchy = new HierarchyPanel(state);
this.compositionEditor.onLiveItems = () => {
if (this.tab === 'composition') this.hierarchy.refresh();
};
this.inspector = new Inspector(state);
this.tilemapPanel = new TilemapPanel(state);
this.recordings = new RecordingUploads({ state, videos });
@@ -517,6 +526,7 @@ export class Editor implements PlayHost {
curve: this.curveEditor,
tile: this.tileEditor,
container: this.containerEditor,
composition: this.compositionEditor,
};
this.panes = {
scene: this.scenePane,
@@ -614,9 +624,16 @@ export class Editor implements PlayHost {
state.on('selection', () => {
const s = state.selection;
if (s?.kind === 'node' && s.assetId === ROUTING_ID) this.openDocument('routing');
else if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'clip' || s?.kind === 'song')
else if (
s?.kind === 'node' ||
s?.kind === 'frame' ||
s?.kind === 'clip' ||
s?.kind === 'song' ||
s?.kind === 'composition'
)
this.openDocument(`asset:${s.assetId}`);
if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container')
const holderType = s?.kind === 'object' ? findAsset(state.project, s.holderId)?.type : undefined;
if (s?.kind === 'object' && (holderType === 'container' || holderType === 'composition'))
this.openDocument(`asset:${s.holderId}`);
});
state.on('open', () => {
@@ -718,6 +735,8 @@ export class Editor implements PlayHost {
return this.viewport.removePickedCorner();
case 'container':
return this.containerEditor.canvas.removePickedCorner();
case 'composition':
return this.compositionEditor.preview.removePickedCorner();
case 'texture':
return this.textureEditor.canvas.removePickedPoint();
case 'vectorart':
@@ -1233,7 +1252,9 @@ export class Editor implements PlayHost {
this.hierarchy.showObjectsOf(
tab === 'container'
? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
: () => this.state.activeScene,
: tab === 'composition'
? () => this.compositionEditor.shownHolder()
: () => this.state.activeScene,
);
// A pane in a window shows there whatever the row shows.
for (const key of PANE_ORDER) if (!this.floats.has(key)) this.panes[key].visible = key === tab;
@@ -1348,6 +1369,7 @@ export class Editor implements PlayHost {
this.play.recordFrame();
this.console.refresh();
if (this.shows('animation')) this.animationEditor.tick(step);
if (this.shows('composition')) this.compositionEditor.tick(dt);
if (this.shows('tile')) this.tileEditor.tick(step);
// The preview is the editor's own, so it runs on while the game is paused.
if (this.shows('curve')) this.curveEditor.tick(dt);
@@ -1403,6 +1425,7 @@ export class Editor implements PlayHost {
this.shows('pixelart') ||
(this.shows('shader') && this.shaderEditor.animates) ||
(this.shows('animation') && this.animationEditor.isPlaying) ||
(this.shows('composition') && this.compositionEditor.isPlaying) ||
(this.shows('tile') && this.tileEditor.isPlaying) ||
(this.shows('curve') && this.curveEditor.isPlaying) ||
(this.shows('pattern') && this.patternEditor.isPlaying) ||
+15
View File
@@ -15,6 +15,7 @@ import {
} from '../core/commands/colliderCommands';
import { oneStep } from '../core/commands/CompoundCommand';
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { removeCompositionTrack, removeFromComposition } from '../core/commands/compositionCommands';
import { RemoveClipCommand } from '../core/commands/clipCommands';
import { RemoveFrameCommand } from '../core/commands/frameCommands';
import { DisconnectCommand, FoldEdgeCommand, RemoveNodeCommand } from '../core/commands/graphCommands';
@@ -105,6 +106,9 @@ export function addJoint(state: EditorState, holderId: string, objectId: string,
/** Removes the objects (with their subtrees) as one undo step. */
export function removeObjects(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
// An object of a composition is an item's (#417): the item goes with it.
if (findAsset(state.project, holderId)?.type === 'composition')
return state.execute(removeFromComposition(state.project, holderId, objectIds, []));
return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds));
}
@@ -233,6 +237,17 @@ export function removeSelection(state: EditorState): boolean {
if (s.itemIds.length > 0) removeSongItems(state, s.assetId, s.itemIds);
else removeTrack(state, s.assetId, s.trackId);
return true;
case 'composition':
// The clips when some are picked, else the track and what lies on it.
if (
state.execute(
s.clipIds.length > 0
? removeFromComposition(state.project, s.assetId, [], s.clipIds)
: removeCompositionTrack(state.project, s.assetId, s.trackId),
)
)
state.select({ kind: 'asset', assetId: s.assetId });
return true;
}
}
+1
View File
@@ -22,6 +22,7 @@ export const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
curve: 'curve',
tile: 'tile',
container: 'container',
composition: 'composition',
};
/**
@@ -0,0 +1,80 @@
import { compositionHolder } from '../../core/model/composition';
import type { CompositionAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import type { EditorState } from '../state';
import { SceneViewport } from '../viewport/SceneViewport';
/**
* The picture above a composition's timeline (#417): what is alive at the playhead, with its clips
* and videos at that time, drawn and edited like a container's template. What it shows comes from
* `compositionHolder`, a copy with the items' own ids, so a click picks the item's object, a drag
* moves it and the inspector edits it in the composition itself. Behaviors do not run here.
*/
export class CompositionCanvas extends SceneViewport {
private assetId = '';
/** The copy shown, built once per frame and edit: a drag moves its objects until the pointer lets go. */
private shown: { frame: number; version: number; holder: CompositionAsset } | null = null;
private version = 0;
constructor(
state: EditorState,
private readonly playhead: () => number,
) {
super(state);
state.on('project', () => this.rebuildPicture());
state.on('load', () => this.rebuildPicture());
}
get currentAssetId(): string {
return this.assetId;
}
show(assetId: string): void {
if (this.assetId === assetId) return;
this.assetId = assetId;
this.rebuildPicture();
}
/** Built again for the next draw: the document changed, or the playhead stands somewhere else. */
rebuildPicture(): void {
this.version += 1;
this.redraw();
}
/** The composition as drawn at the playhead – the hierarchy lists the same. */
shownHolder(): CompositionAsset | undefined {
const comp = this.assetId ? findAssetOf(this.state.project, this.assetId, 'composition') : undefined;
if (!comp) return undefined;
const frame = Math.max(0, Math.floor(this.playhead() + 1e-6));
if (this.shown?.frame !== frame || this.shown.version !== this.version)
this.shown = {
frame,
version: this.version,
holder: compositionHolder(this.state.project, comp, frame),
};
return this.shown.holder;
}
protected override holder(): CompositionAsset | undefined {
return this.shownHolder();
}
/** Each composition keeps the view it was left with, as a container does (#171). */
protected override framesOnOpen(): boolean {
return false;
}
protected override paintsBackground(): boolean {
return false;
}
/** An asset goes on a track of the timeline, not into the picture: here it would have no time. */
protected override takesAssets(): boolean {
return false;
}
/** Heard while it plays at about 1x, so a cut can be timed by its sound (#404). */
protected override hearsVideo(): boolean {
return true;
}
}
+128
View File
@@ -0,0 +1,128 @@
import type { Asset, CompositionAsset } from '../../core/model/Asset';
import { compositionFps, compositionLength } from '../../core/model/composition';
import { Button, formatNumber, VStack, type Widget } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import type { EditorState } from '../state';
import { CompositionCanvas } from './CompositionCanvas';
import { CompositionTimeline } from './CompositionTimeline';
/** Room kept for the "3 tracks · 5 items · 12.5 s · 60 fps" readout. */
const INFO_WIDTH = 230;
/**
* The Composition tab (#417): the picture at the playhead above, the timeline below. Items are
* objects of the composition, so the hierarchy lists the ones alive at the playhead and the
* inspector edits them; `fps` and `loop` are properties of the asset, edited in the inspector.
*/
export class CompositionEditor extends AssetTabEditor<CompositionAsset, CompositionTimeline> {
/** Public for the check scripts. */
readonly addTrackButton: Button;
readonly playButton: Button;
readonly preview: CompositionCanvas;
constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH });
this.addTrackButton = new Button('+ Track', { onClick: () => this.canvas.addTrack() });
this.playButton = new Button('Play', { width: 64, onClick: () => this.togglePlay() });
const timeline = new CompositionTimeline(state);
this.preview = new CompositionCanvas(state, () => timeline.playhead);
timeline.onPlayhead = () => {
this.preview.redraw();
this.followLiveItems();
};
timeline.onPlaying = () => this.syncPlayButton();
this.mount(timeline, [this.addTrackButton, this.playButton]);
}
override showAsset(assetId: string): void {
super.showAsset(assetId);
this.preview.show(assetId);
}
protected override closed(assetId: string): void {
if (this.currentAssetId === assetId) this.canvas.pause();
}
/** Fit frames the picture; the timeline has its own on the wheel. */
protected override fit(): void {
this.preview.fit();
this.canvas.fit();
}
protected override saveView(): unknown {
return { timeline: this.canvas.saveView(), preview: this.preview.saveView() };
}
protected override restoreView(view: unknown): void {
const saved = view as { timeline: unknown; preview: unknown };
this.canvas.restoreView(saved.timeline);
this.preview.show(this.currentAssetId);
this.preview.restoreView(saved.preview);
}
/** The picture above, the timeline below, each taking half. */
protected override body(canvas: CompositionTimeline): Widget {
const stack = new VStack();
stack.add(this.preview, 1);
stack.add(canvas, 1);
return stack;
}
/** Told when other items come alive or go at the playhead: the hierarchy lists those. */
onLiveItems: () => void = () => undefined;
private liveKey = '';
private followLiveItems(): void {
const key = (this.shownHolder()?.objects ?? []).map((o) => o.id).join(',');
if (key === this.liveKey) return;
this.liveKey = key;
this.onLiveItems();
}
/** The composition as drawn at the playhead – what the hierarchy lists. */
shownHolder(): CompositionAsset | undefined {
return this.preview.shownHolder();
}
/** Only called while the tab is visible. */
tick(dt: number): void {
const drawn = this.preview.redrawCount;
if (this.canvas.isPlaying) this.canvas.tick(dt);
this.preview.animate(drawn);
}
get isPlaying(): boolean {
return this.canvas.isPlaying;
}
override selectedId(): string {
return this.state.selectedCompositionId ?? '';
}
override isMine(asset: Asset): asset is CompositionAsset {
return asset.type === 'composition';
}
protected override infoText(asset: CompositionAsset): string {
const tracks = asset.tracks.length;
const items = asset.items.length;
const fps = compositionFps(asset);
const seconds = formatNumber(compositionLength(this.state.project, asset) / fps, 1);
return `${tracks} ${tracks === 1 ? 'track' : 'tracks'} · ${items} ${items === 1 ? 'item' : 'items'} · ${seconds} s · ${fps} fps`;
}
protected override syncExtras(asset: CompositionAsset | null): void {
this.addTrackButton.setEnabled(asset !== null);
this.playButton.setEnabled(asset !== null && compositionLength(this.state.project, asset) > 0);
this.syncPlayButton();
}
private syncPlayButton(): void {
this.playButton.setText(this.canvas.isPlaying ? 'Pause' : 'Play');
}
private togglePlay(): void {
if (this.canvas.isPlaying) this.canvas.pause();
else this.canvas.play();
}
}
File diff suppressed because it is too large. Load diff
+97
View File
@@ -0,0 +1,97 @@
import type { CompositionClip, CompositionItem, CompositionTrack } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { formatNumber } from '../../ui';
import { tickToX } from '../music/timeView';
import { type Box, EDGE_REACH, type SongView, trackTop } from '../song/songView';
/**
* The geometry of the Composition tab's timeline (#417), apart from Pixi so a test can drive it. It is
* the Song tab's (#334) with frames for ticks: the tracks stacked down the lanes, `y` from the top of
* the lanes, `x` from their left edge as on the ruler.
*/
export type CompositionView = SongView;
export const HEAD_WIDTH = 150;
export const RULER_HEIGHT = 24;
export const TRACK_HEIGHT = 48;
/** Up to 8 pixels a frame: at 60 fps a second is 480 pixels wide. */
export const MAX_PX_PER_FRAME = 8;
const INSET = 2;
/** A clip is drawn over the lower part of its track, narrower than an item, so both can be seen. */
const CLIP_TOP = 0.56;
/** Labels of the ruler stand at least this far apart. */
const RULER_LABEL_MIN_PX = 56;
export function itemBox(view: CompositionView, row: number, span: { start: number; length: number }): Box {
const x = tickToX(view, span.start);
return {
x,
y: trackTop(view, row) + INSET,
w: Math.max(1, tickToX(view, span.start + span.length) - x),
h: view.trackHeight - INSET * 2,
};
}
export function clipBox(view: CompositionView, row: number, span: { start: number; length: number }): Box {
const x = tickToX(view, span.start);
const top = trackTop(view, row) + Math.round(view.trackHeight * CLIP_TOP);
return {
x,
y: top,
w: Math.max(1, tickToX(view, span.start + span.length) - x),
h: Math.max(4, trackTop(view, row) + view.trackHeight - INSET * 2 - top),
};
}
export type Part = 'start' | 'body' | 'end';
/** What lies under a point of the lanes: a clip before an item, as clips are drawn over items. */
export type Hit = { kind: 'clip'; id: string } | { kind: 'item'; id: string; part: Part };
export function hitAt(
view: CompositionView,
tracks: readonly CompositionTrack[],
items: readonly CompositionItem[],
clips: readonly CompositionClip[],
clipLength: (clip: CompositionClip) => number,
p: Vec2,
): Hit | null {
const rows = new Map(tracks.map((t, i) => [t.id, i]));
const inside = (b: Box) => p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
for (let i = clips.length - 1; i >= 0; i--) {
const clip = clips[i]!;
const row = rows.get(clip.track);
if (row !== undefined && inside(clipBox(view, row, { start: clip.start, length: clipLength(clip) })))
return { kind: 'clip', id: clip.id };
}
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i]!;
const row = rows.get(item.track);
if (row === undefined) continue;
const box = itemBox(view, row, item);
if (!inside(box)) continue;
const reach = Math.min(EDGE_REACH, box.w / 3);
const part = p.x >= box.x + box.w - reach ? 'end' : p.x <= box.x + reach ? 'start' : 'body';
return { kind: 'item', id: item.id, part };
}
return null;
}
/**
* Every how many frames the ruler gets a label: a round number of seconds or parts of one, and
* never less than a frame, so the labels stand far enough apart to be read.
*/
export function rulerStep(view: CompositionView, fps: number): number {
for (const seconds of [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600]) {
const frames = Math.max(1, Math.round(seconds * fps));
if (frames * view.pxPerTick >= RULER_LABEL_MIN_PX) return frames;
}
return Math.round(3600 * fps);
}
/** A time on the ruler: "1.5s" below a minute, "2:05" from there on. */
export function rulerText(seconds: number): string {
if (seconds < 60) return `${formatNumber(seconds, 2)}s`;
const whole = Math.round(seconds);
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;
}
+19
View File
@@ -74,6 +74,25 @@ export function documentHooks({ state, editor }: HookContext) {
fitAt: () => centreOf(editor.animationEditor.fitButton),
},
},
/** The Composition tab (#417): where tracks, items and clips sit, the playhead and the picture. */
composition: {
current: () => editor.compositionEditor.currentAssetId,
info: () => editor.compositionEditor.shownInfo,
playAt: () => ({
...centreOf(editor.compositionEditor.playButton),
text: editor.compositionEditor.playButton.text,
}),
addTrackAt: () => centreOf(editor.compositionEditor.addTrackButton),
laneAt: (trackId: string, frame: number) => editor.compositionEditor.canvas.laneGlobal(trackId, frame),
box: (id: string) => editor.compositionEditor.canvas.boxGlobal(id),
drop: () => editor.compositionEditor.canvas.dropInfo(),
view: () => editor.compositionEditor.canvas.viewInfo(),
scrub: (frame: number) => editor.compositionEditor.canvas.scrub(frame),
/** What the picture shows at the playhead, in drawing order, and where one is drawn. */
shown: () =>
(editor.compositionEditor.shownHolder()?.objects ?? []).map((o) => ({ id: o.id, x: o.position.x })),
objectScreen: (objectId: string) => editor.compositionEditor.preview.objectScreen(objectId),
},
tile: {
current: () => editor.tileEditor.currentAssetId,
info: () => editor.tileEditor.shownInfo,
+12 -1
View File
@@ -19,6 +19,11 @@ import {
} from './objectSection';
import { buildRoutingNodeSection, refreshRoutingNodeSection, routingNodeSignature } from './routingSection';
import { buildSongSection, refreshSongSection, songSignature } from './songSection';
import {
buildCompositionSection,
compositionSignature,
refreshCompositionSection,
} from './compositionSection';
import { ROUTING_ID } from '../../core/model/Project';
import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection';
import { INLINE_LIMIT } from '../variables/target';
@@ -141,6 +146,7 @@ export class Inspector extends Panel {
else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys);
else if (s.kind === 'edge') buildEdgeSection(this.host, s.assetId, s.edgeId, s.edgeIds);
else if (s.kind === 'song') buildSongSection(this.host, s.assetId, s.trackId, s.itemIds);
else if (s.kind === 'composition') buildCompositionSection(this.host, s.assetId, s.trackId, s.clipIds);
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
else buildObjectSection(this.host, s.holderId, s.objectId);
this.scroll.setContent(this.host.body);
@@ -160,6 +166,8 @@ export class Inspector extends Panel {
if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys);
if (s.kind === 'edge') return edgeSignature(this.state.project, s.assetId, s.edgeId, s.edgeIds);
if (s.kind === 'song') return songSignature(this.state.project, s.assetId, s.trackId, s.itemIds);
if (s.kind === 'composition')
return compositionSignature(this.state.project, s.assetId, s.trackId, s.clipIds);
return objectSignature(this.state, s.holderId, s.objectIds, s.objectId);
}
@@ -199,7 +207,10 @@ export class Inspector extends Panel {
? refreshEdgeSection(this.host, s.assetId, s.edgeId)
: s.kind === 'song'
? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
: s.kind === 'composition'
? refreshCompositionSection(this.host, s.assetId, s.trackId, s.clipIds)
: s.objectIds.length > 1 ||
refreshObjectSection(this.host, s.holderId, s.objectId);
if (!ok) this.rebuild();
}
}
+240
View File
@@ -0,0 +1,240 @@
import {
removeCompositionTrack,
removeFromComposition,
SetCompositionCommand,
} from '../../core/commands/compositionCommands';
import type { CompositionAsset, CompositionItem } from '../../core/model/Asset';
import { clipLength, compositionFps, itemVideo } from '../../core/model/composition';
import { refittedLength, resizedItem } from '../../core/model/compositionEdit';
import { findAssetOf, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, formatNumber } from '../../ui';
import { createFieldRow } from './fieldFactory';
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
import { assetLink } from './objectSection';
const START_ROW = 'composition:start';
const LENGTH_ROW = 'composition:length';
const OFFSET_ROW = 'composition:offset';
const CLIP_START_ROW = 'composition:clipStart';
const ANIMATION_ROW = 'composition:animation';
const seconds = (key: string, label: string): PropertyDef => ({
key,
label,
kind: 'number',
default: 0,
min: 0,
step: 0.1,
decimals: 3,
});
/** Structure-defining facts of a track or clip selection: which it is, and whether it is still there. */
export function compositionSignature(
project: Project,
assetId: string,
trackId: string,
clipIds: string[],
): string {
const comp = findAssetOf(project, assetId, 'composition');
if (!comp?.tracks.some((t) => t.id === trackId)) return 'missing';
return clipIds.length > 0 ? `clips:${clipIds.join(',')}` : 'track';
}
/** A track: its name and the way to remove it. Clips: what they play, from when, and the way to remove them. */
export function buildCompositionSection(
host: InspectorHost,
assetId: string,
trackId: string,
clipIds: string[],
): void {
const comp = findAssetOf(host.state.project, assetId, 'composition');
const track = comp?.tracks.find((t) => t.id === trackId);
if (!comp || !track) return;
if (clipIds.length === 0) {
addTitle(host, 'TRACK');
addNameField(host, track.name, {
onChange: (name) => {
const tracks = comp.tracks.map((t) => (t.id === trackId ? { ...t, name: name.trim() } : t));
if (name.trim()) host.state.execute(new SetCompositionCommand(assetId, { tracks }, 'Rename track'));
else host.nameField?.setValue(track.name);
},
});
addNote(host, 'An upper track lies in front of the ones below it.');
addGap(host);
host.body.add(
new Button('Remove track', {
variant: 'danger',
onClick: () => host.state.execute(removeCompositionTrack(host.state.project, assetId, trackId)),
}),
);
return;
}
const clip = clipIds.length === 1 ? comp.clips.find((c) => c.id === clipIds[0]) : undefined;
if (clip) {
addTitle(host, 'ANIMATION CLIP');
const animation = findAssetOf(host.state.project, clip.animation, 'animation');
if (animation) assetLink(host, ANIMATION_ROW, 'Animation', animation);
else addNote(host, 'Its animation is gone.', 'warning');
const fps = compositionFps(comp);
const row = createFieldRow(host, seconds(CLIP_START_ROW, 'Start'), clip.start / fps, {
onInput: () => undefined,
onChange: (value) => {
const start = Math.max(0, Math.round(Number(value) * fps));
const current = findAssetOf(host.state.project, assetId, 'composition');
if (!current) return;
const clips = current.clips.map((c) => (c.id === clip.id ? { ...c, start } : c));
if (SetCompositionCommand.changes(host.state.project, assetId, { clips }))
host.state.execute(new SetCompositionCommand(assetId, { clips }, 'Move clip'));
},
});
host.fields.set(CLIP_START_ROW, row);
host.body.add(row.row);
const length = clipLength(host.state.project, comp, clip);
addNote(
host,
`One pass: ${formatNumber(length / fps, 2)} s. It plays on the item of its track it overlaps.`,
);
} else addTitle(host, `${clipIds.length} ANIMATION CLIPS`);
addGap(host);
host.body.add(
new Button(clipIds.length === 1 ? 'Remove clip' : 'Remove clips', {
variant: 'danger',
onClick: () => {
if (host.state.execute(removeFromComposition(host.state.project, assetId, [], clipIds)))
host.state.select({ kind: 'asset', assetId });
},
}),
);
}
export function refreshCompositionSection(
host: InspectorHost,
assetId: string,
trackId: string,
clipIds: string[],
): boolean {
const comp = findAssetOf(host.state.project, assetId, 'composition');
const track = comp?.tracks.find((t) => t.id === trackId);
if (!comp || !track) return false;
if (clipIds.length === 0) host.nameField?.setValue(track.name);
const clip = clipIds.length === 1 ? comp.clips.find((c) => c.id === clipIds[0]) : undefined;
if (clip) host.fields.get(CLIP_START_ROW)?.setValue(clip.start / compositionFps(comp));
return true;
}
/** The item an object of a composition stands for; undefined for any other object. */
export function compositionItemOf(
project: Project,
holderId: string,
objectId: string,
): { comp: CompositionAsset; item: CompositionItem } | undefined {
const comp = findAssetOf(project, holderId, 'composition');
const item = comp?.items.find((i) => i.id === objectId);
return comp && item ? { comp, item } : undefined;
}
/**
* When the object's item lives in the composition, and how far into its video it begins (#417). A
* length typed in or an edge moved is the item's own from then on; "Fit to media" hands it back.
*/
export function addTimingRows(host: InspectorHost, holderId: string, objectId: string): void {
const found = compositionItemOf(host.state.project, holderId, objectId);
if (!found) return;
const { comp, item } = found;
const fps = compositionFps(comp);
const video = itemVideo(host.state.project, comp, item) !== null;
addTitle(host, 'TIMING');
const write = (
label: string,
edit: (current: CompositionItem, all: CompositionItem[]) => CompositionItem,
) => {
const current = compositionItemOf(host.state.project, holderId, objectId);
if (!current) return;
const items = current.comp.items.map((i) => (i.id === objectId ? edit(i, current.comp.items) : i));
if (SetCompositionCommand.changes(host.state.project, holderId, { items }))
host.state.execute(new SetCompositionCommand(holderId, { items }, label));
};
const frames = (value: unknown) => Math.round(Number(value) * fps);
const rows: [string, string, number, (value: unknown) => void][] = [
[
START_ROW,
'Start',
item.start / fps,
(value) => write('Move item', (i, all) => moveTo(all, i, frames(value))),
],
[
LENGTH_ROW,
'Length',
item.length / fps,
(value) => write('Resize item', (i, all) => resizedItem(all, i, frames(value))),
],
];
if (video)
rows.push([
OFFSET_ROW,
'Offset',
item.offset,
(value) => write('Set item offset', (i) => withOffset(i, Number(value))),
]);
for (const [key, label, value, onChange] of rows) {
const row = createFieldRow(host, seconds(key, label), value, { onInput: () => undefined, onChange });
host.fields.set(key, row);
host.body.add(row.row);
}
addNote(
host,
item.fitMedia
? video
? 'Its length follows its video.'
: 'Its length follows a video it is given, 3 s while it has none.'
: 'Its length is its own.',
'small',
);
const fit = new Button('Fit to media', { onClick: () => fitToMedia(host, holderId, objectId) });
fit.setEnabled(!(item.fitMedia === true && item.length === refittedLength(host.state.project, comp, item)));
host.body.add(fit);
}
export function refreshTimingRows(host: InspectorHost, holderId: string, objectId: string): void {
const found = compositionItemOf(host.state.project, holderId, objectId);
if (!found) return;
const fps = compositionFps(found.comp);
host.fields.get(START_ROW)?.setValue(found.item.start / fps);
host.fields.get(LENGTH_ROW)?.setValue(found.item.length / fps);
host.fields.get(OFFSET_ROW)?.setValue(found.item.offset);
}
/** What shapes the timing rows: whether there is a video, and whether the length still follows it. */
export function timingSignature(project: Project, holderId: string, objectId: string): string {
const found = compositionItemOf(project, holderId, objectId);
if (!found) return '';
const video = itemVideo(project, found.comp, found.item) !== null;
const fitted = found.item.length === refittedLength(project, found.comp, found.item);
return `|timing:${video}:${found.item.fitMedia === true}:${fitted}`;
}
/** The item's length taken from its video again, and following it from then on (#417). */
export function fitToMedia(host: Pick<InspectorHost, 'state'>, holderId: string, objectId: string): void {
const found = compositionItemOf(host.state.project, holderId, objectId);
if (!found) return;
const length = refittedLength(host.state.project, found.comp, found.item);
const items = found.comp.items.map((i) => (i.id === objectId ? { ...i, length, fitMedia: true } : i));
if (SetCompositionCommand.changes(host.state.project, holderId, { items }))
host.state.execute(new SetCompositionCommand(holderId, { items }, 'Fit to media'));
}
/** The item moved to `start`, as far as the items around it let it: a clash keeps it where it was. */
function moveTo(all: readonly CompositionItem[], item: CompositionItem, start: number): CompositionItem {
const at = Math.max(0, start);
const clash = all.some(
(i) =>
i.id !== item.id && i.track === item.track && i.start < at + item.length && at < i.start + i.length,
);
return clash ? item : { ...item, start: at };
}
function withOffset(item: CompositionItem, offset: number): CompositionItem {
const { fitMedia: _, ...rest } = item;
return { ...rest, offset: Number.isFinite(offset) && offset > 0 ? offset : 0 };
}
+6 -2
View File
@@ -76,6 +76,7 @@ import {
viewOf,
} from './objectQueries';
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
import { addTimingRows, refreshTimingRows, timingSignature } from './compositionSection';
/**
* The rows a placed object shows for what it was placed from: a behavior's declared variables but
@@ -112,7 +113,8 @@ export function objectSignature(
const placed = asset.type === 'tilemap' ? `|placed:${findUsages(project, asset.id).length}` : '';
// A variable turned private leaves the rows, with nothing else about it changed (#257).
const hidden = asset.type === 'behavior' ? `|private:${(asset.privateVariables ?? []).join(',')}` : '';
return `${object.parentId}|${kind}${placed}${hidden}|` + rows.map(defSignature).join(',');
const timing = timingSignature(project, holderId, objectId);
return `${object.parentId}|${kind}${placed}${hidden}${timing}|` + rows.map(defSignature).join(',');
}
/** Several objects: what they are, and the actions that apply to all of them at once. */
@@ -165,6 +167,7 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
host.body.add(parentRow(host, view, object, target !== undefined));
// Content of a placed container: values compare against the template, not just the asset.
if (target) assetLink(host, PART_OF_ROW, 'Part of', target.asset);
addTimingRows(host, holderId, objectId);
// The switches in a group of their own (#290), above everything type-specific.
addTitle(host, 'SWITCHES');
@@ -316,7 +319,7 @@ const PART_OF_ROW = '#part-of';
* What the object was placed from: the label is a link like on any reference row (click pings,
* double-click opens), the name next to it only reads – changing it would mean another object.
*/
function assetLink(host: InspectorHost, key: string, text: string, asset: Asset): void {
export function assetLink(host: InspectorHost, key: string, text: string, asset: Asset): void {
const { row, label } = labelledRow(text);
linkLabel(host, label)(asset.id);
const name = new TextField(asset.name, { readOnly: true });
@@ -361,6 +364,7 @@ export function refreshObjectSection(host: InspectorHost, holderId: string, obje
host.fields.get(ASSET_ROW)?.setValue(asset.name);
const target = targetOf(project, holderId, objectId);
if (target) host.fields.get(PART_OF_ROW)?.setValue(target.asset.name);
refreshTimingRows(host, holderId, objectId);
for (const axis of POSITION_AXES) host.fields.get(axis)?.setValue(object.position[axis]);
for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setValue(object.editorOffset[axis]);
const values = resolveSceneObjectProps(project, asset, object);
+27 -2
View File
@@ -8,7 +8,7 @@ import { findAsset, type ObjectHolder } from '../core/model/Project';
import { type PropertyDef, schemaForObject } from '../core/properties/PropertySchema';
import { referencesOf } from '../core/properties/references';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import { Menu, type MenuItem } from '../ui';
import { Menu, type MenuEntry, type MenuItem } from '../ui';
import {
activateAsset,
addCollider,
@@ -22,6 +22,7 @@ import {
} from './actions';
import { hasAssetEditor } from './assetTabs';
import { canPasteObjects, copyObjectIds, pasteObjects } from './clipboard';
import { fitToMedia } from './inspector/compositionSection';
import { groupMenuItems } from './objectGroups';
import type { EditorState } from './state';
@@ -51,7 +52,7 @@ export function openObjectMenu(
// Inside a multi-selection the menu acts on all selected objects.
const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id];
const many = selected.length > 1;
const menu = new Menu([
const head: MenuEntry[] = [
{
label: 'Select asset',
onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }),
@@ -70,6 +71,30 @@ export function openObjectMenu(
resolveSceneObjectProps(state.project, asset, object),
)
: []),
];
// An object of a composition is an item's (#417): nothing may join it that has no time of its own.
if (holder.type === 'composition') {
const items = selected.filter((oid) => !isTemplateContent(source, oid));
const menu = new Menu([
...head,
{ separator: true },
{
label: 'Fit to media',
disabled: many || fromTemplate,
onSelect: () => fitToMedia({ state }, holder.id, id),
},
{
label: items.length > 1 ? `Remove ${items.length} items` : 'Remove item',
danger: true,
disabled: items.length === 0,
onSelect: () => removeObjects(state, holder.id, items),
},
]);
menu.open(x, y);
return menu;
}
const menu = new Menu([
...head,
{ separator: true },
{
label: many ? `Copy ${selected.length} objects` : 'Copy',
+3 -3
View File
@@ -444,12 +444,12 @@ export class AssetPanel extends Panel {
/**
* Hands the asset to the view under the pointer: a scene places it, a song lays out a pattern or a
* sound (#334). A scene has nowhere to go.
* sound (#334), a composition an animation as a clip (#417). A scene has nowhere to go.
*/
private startPlacementDrag(assetId: string): void {
const asset = findAsset(this.state.project, assetId);
if (!asset || !(isPlaceableType(asset.type) || asset.type === 'pattern' || asset.type === 'sound'))
return;
const laid = asset?.type === 'pattern' || asset?.type === 'sound' || asset?.type === 'animation';
if (!asset || !(isPlaceableType(asset.type) || laid)) return;
startDrag({ kind: 'asset', id: assetId }, asset.name);
}
+6 -3
View File
@@ -93,7 +93,8 @@ export class HierarchyPanel extends Panel {
*/
private drop(draggedId: string, spot: DropSpot): void {
const holder = this.holder();
if (!holder) return;
// The items of a composition stand at its top level in the order of their tracks (#417).
if (!holder || holder.type === 'composition') return;
const s = this.state.selection;
const selected = s?.kind === 'object' && s.holderId === holder.id ? s.objectIds : [];
const isOpen = (rowId: string) => this.expanded.has(rowId);
@@ -283,10 +284,12 @@ export class HierarchyPanel extends Panel {
return changed;
}
private refresh(): void {
/** Lists the rows anew – the Composition tab asks whenever other items come alive (#417). */
refresh(): void {
const holder = this.holder();
this.shownHolderId = holder?.id ?? '';
const kind = holder?.type === 'container' ? 'Container' : 'Scene';
const kind =
holder?.type === 'container' ? 'Container' : holder?.type === 'composition' ? 'Composition' : 'Scene';
this.setTitle(holder ? `${kind}: ${holder.name}` : 'Scene objects');
// From the stored holder, not the resolved one: what an object follows depends on where it
// lives, and in a resolved view container content looks stored.
+31 -1
View File
@@ -31,6 +31,7 @@ import type { PlaceableAssetType } from '../core/model/AssetTypes';
import type { VisibilityPart } from '../core/model/sceneVisibility';
import type { ValuePath } from '../core/properties/valuePath';
import { KeepRoutingCommand } from '../core/commands/routingCommands';
import { FitMediaCommand } from '../core/commands/compositionCommands';
import { settleRouting } from '../core/music/projectRouting';
export type Selection =
@@ -79,6 +80,11 @@ export type Selection =
* empty when the track itself was clicked. The inspector shows the item picked last, else the track.
*/
| { kind: 'song'; assetId: string; trackId: string; itemIds: string[] }
/**
* A track of a composition (#417) and the animation clips picked on it or on others: `clipIds` is
* empty when the track itself was clicked. Its items are objects, picked as an object selection.
*/
| { kind: 'composition'; assetId: string; trackId: string; clipIds: string[] }
| null;
/**
@@ -466,7 +472,7 @@ export class EditorState {
*/
execute(command: Command): boolean {
return this.runEdit(() => {
this.commands.execute(new KeepRoutingCommand(command));
this.commands.execute(new KeepRoutingCommand(new FitMediaCommand(command)));
return true;
});
}
@@ -842,6 +848,17 @@ export class EditorState {
else if (items.length !== s.itemIds.length) this.select({ ...s, itemIds: items });
}
}
if (s.kind === 'composition') {
const asset = findAssetOf(this.project, s.assetId, 'composition');
if (!asset) this.select(null);
else {
// The same as a song's: a clip that died leaves, a track that died hands over to the composition.
const clips = s.clipIds.filter((id) => asset.clips.some((clip) => clip.id === id));
const trackAlive = asset.tracks.some((track) => track.id === s.trackId);
if (!trackAlive) this.select({ kind: 'asset', assetId: s.assetId });
else if (clips.length !== s.clipIds.length) this.select({ ...s, clipIds: clips });
}
}
}
/** The song the selection refers to (#334): the asset, or a track or items of it. */
@@ -851,6 +868,17 @@ export class EditorState {
return findAssetOf(this.project, s.assetId, 'song')?.id ?? null;
}
/**
* The composition the selection refers to (#417): the asset, a track or clips of it, or an item's
* object – so picking an item keeps the Composition tab on it.
*/
get selectedCompositionId(): string | null {
const s = this.selection;
if (s?.kind === 'object') return findAssetOf(this.project, s.holderId, 'composition')?.id ?? null;
if (s?.kind !== 'asset' && s?.kind !== 'composition') return null;
return findAssetOf(this.project, s.assetId, 'composition')?.id ?? null;
}
/** The texture asset the current selection refers to (the asset itself or one of its frames). */
get selectedTextureId(): string | null {
const s = this.selection;
@@ -1055,6 +1083,8 @@ export function sameSelection(a: Selection, b: Selection): boolean {
return a.assetId === b.assetId && a.edgeId === b.edgeId && sameIds(a.edgeIds, b.edgeIds);
if (a.kind === 'song' && b.kind === 'song')
return a.assetId === b.assetId && a.trackId === b.trackId && sameIds(a.itemIds, b.itemIds);
if (a.kind === 'composition' && b.kind === 'composition')
return a.assetId === b.assetId && a.trackId === b.trackId && sameIds(a.clipIds, b.clipIds);
return false;
}
+8 -3
View File
@@ -331,7 +331,12 @@ export class SceneViewport extends FreeViewport {
/** Where Ctrl+V puts objects: under the pointer while it is over this view, else nowhere in particular. */
pasteHere(): boolean {
const holder = this.holder();
return holder ? pasteObjects(this.state, holder.id, this.pointerWorld()) : false;
return holder && this.takesAssets() ? pasteObjects(this.state, holder.id, this.pointerWorld()) : false;
}
/** Whether an asset dropped or an object pasted lands here; a composition's picture takes neither (#417). */
protected takesAssets(): boolean {
return true;
}
/** The pointer in world units, or null when it is not over this view. */
@@ -351,7 +356,7 @@ export class SceneViewport extends FreeViewport {
new Menu([
{
label: 'Paste',
disabled: !canPasteObjects(this.state),
disabled: !this.takesAssets() || !canPasteObjects(this.state),
onSelect: () => pasteObjects(this.state, holder.id, at),
},
]).open(e.global.x, e.global.y);
@@ -446,7 +451,7 @@ export class SceneViewport extends FreeViewport {
const holder = this.holder();
const asset = assetId ? findAsset(this.state.project, assetId) : undefined;
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h;
if (!asset || !holder || !inside) {
if (!asset || !holder || !inside || !this.takesAssets()) {
this.setDropPreview(null);
return;
}
+262
View File
@@ -0,0 +1,262 @@
/**
* Editing a composition (#417): drops land in the gap they fall into, nothing of a track overlaps,
* the edges of an item keep to its neighbours, and an item that follows its media is fitted to it
* again – in the same undo step – when an override gives it another video.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, CreateVideoCommand } from '../../src/core/commands/assetCommands';
import {
addCompositionItem,
FitMediaCommand,
removeCompositionTrack,
removeFromComposition,
SetCompositionCommand,
} from '../../src/core/commands/compositionCommands';
import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import type { CompositionClip, CompositionItem } from '../../src/core/model/Asset';
import {
droppedLength,
droppedSpan,
movedClips,
movedItems,
resizedItem,
trimmedItem,
} from '../../src/core/model/compositionEdit';
import { placementRejection } from '../../src/core/model/placement';
import { findAsset, findAssetOf } from '../../src/core/model/Project';
import { makeProject } from '../helpers';
const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`;
const item = (
id: string,
track: string,
start: number,
length: number,
extra: Partial<CompositionItem> = {},
) => ({ id, track, start, length, offset: 0, ...extra }) satisfies CompositionItem;
describe('a drop', () => {
const taken = [
{ start: 60, length: 60 },
{ start: 200, length: 30 },
];
it('lands where it is let go when that is free, as long as it is', () => {
expect(droppedSpan(taken, 0, 50)).toEqual({ start: 0, length: 50 });
});
it('lands right after an item it falls on, and is cut short where the next begins', () => {
expect(droppedSpan(taken, 90, 180)).toEqual({ start: 120, length: 80 });
});
it('is cut short before the item after the gap it lands in', () => {
expect(droppedSpan(taken, 30, 180)).toEqual({ start: 30, length: 30 });
});
it('lands behind two items that touch when it falls on the first', () => {
expect(
droppedSpan(
[
{ start: 0, length: 10 },
{ start: 10, length: 10 },
],
5,
60,
),
).toEqual({ start: 20, length: 60 });
});
});
describe('moving', () => {
const items = [item('a', 't1', 0, 60), item('b', 't1', 100, 60), item('c', 't2', 0, 60)];
const tracks = ['t1', 't2'];
it('moves the picked items by frames and tracks', () => {
expect(movedItems(items, new Set(['a']), tracks, 30, 0)?.find((i) => i.id === 'a')?.start).toBe(30);
expect(movedItems(items, new Set(['b']), tracks, 0, 1)?.find((i) => i.id === 'b')?.track).toBe('t2');
});
it('refuses a move onto another item, before 0 or off the tracks', () => {
expect(movedItems(items, new Set(['a']), tracks, 50, 0)).toBeNull();
expect(movedItems(items, new Set(['b']), tracks, -150, 0)).toBeNull();
expect(movedItems(items, new Set(['c']), tracks, 0, 1)).toBeNull();
expect(movedItems(items, new Set(['a']), tracks, 0, 1)).toBeNull();
});
it('lets a picked item go where another picked one was', () => {
const moved = movedItems(items, new Set(['a', 'b']), tracks, 40, 0);
expect(moved?.map((i) => i.start)).toEqual([40, 140, 0]);
});
it('keeps clips of a track apart by their own lengths', () => {
const clips: CompositionClip[] = [
{ id: 'x', track: 't1', animation: 'm', start: 0 },
{ id: 'y', track: 't1', animation: 'm', start: 50 },
];
const lengthOf = () => 40;
expect(movedClips(clips, new Set(['x']), tracks, 5, 0, lengthOf)).not.toBeNull();
expect(movedClips(clips, new Set(['x']), tracks, 20, 0, lengthOf)).toBeNull();
});
});
describe('the edges of an item', () => {
const items = [
item('a', 't1', 0, 60),
item('b', 't1', 100, 60, { fitMedia: true, offset: 1 }),
item('c', 't1', 200, 10),
];
it('a dragged end stops where the next item begins, and the length is the item’s own from then on', () => {
const b = items[1]!;
expect(resizedItem(items, b, 500)).toEqual({ id: 'b', track: 't1', start: 100, length: 100, offset: 1 });
expect(resizedItem(items, b, 0).length).toBe(1);
});
it('a dragged start stops at the item before, and moves where the video begins with it', () => {
const b = items[1]!;
const trimmed = trimmedItem(items, b, 130, 60, true);
expect([trimmed.start, trimmed.length, trimmed.offset, trimmed.fitMedia]).toEqual([
130,
30,
1.5,
undefined,
]);
expect(trimmedItem(items, b, 70, 60, false).start).toBe(70);
expect(trimmedItem(items, b, 10, 60, false).start).toBe(60);
});
it('with a video, a dragged start stops at the video’s beginning', () => {
const b = items[1]!;
const trimmed = trimmedItem(items, b, 70, 60, true);
expect([trimmed.start, trimmed.offset]).toEqual([70, 0.5]);
// Alone on its track, one second into its video: it can reach back one second, no further.
expect(trimmedItem([b], b, 0, 60, true)).toMatchObject({ start: 40, length: 120, offset: 0 });
expect(trimmedItem([b], b, 0, 60, false)).toMatchObject({ start: 0, length: 160, offset: 1 });
});
});
function makeCut() {
const f = makeProject();
const comp = new CreateAssetCommand('composition', 'Cut');
const box = new CreateAssetCommand('sprite', 'Box');
const video = new CreateVideoCommand('Intro', FILE, {
width: 320,
height: 180,
duration: 5,
fps: 30,
audio: false,
});
f.stack.execute(comp);
f.stack.execute(box);
f.stack.execute(video);
const current = () => findAssetOf(f.project, comp.asset.id, 'composition')!;
const track = current().tracks[0]!.id;
/** An item of the box, run through the stack as the timeline does it. */
const add = (start: number, length: number, assetId = box.asset.id) => {
const { command, objectId } = addCompositionItem(f.project, comp.asset.id, assetId, {
track,
start,
length,
offset: 0,
fitMedia: true,
});
f.stack.execute(command);
return objectId;
};
return {
...f,
compId: comp.asset.id,
boxId: box.asset.id,
videoId: video.asset.id,
comp: current,
track,
add,
};
}
describe('items and their objects', () => {
it('an item comes with its object in one undo step, and goes with it', () => {
const cut = makeCut();
const id = cut.add(0, 180);
expect(cut.comp().objects.map((o) => o.id)).toEqual([id]);
expect(cut.comp().items.map((i) => i.id)).toEqual([id]);
cut.stack.execute(removeFromComposition(cut.project, cut.compId, [id], []));
expect([cut.comp().objects, cut.comp().items]).toEqual([[], []]);
cut.stack.undo();
expect(cut.comp().items.map((i) => i.id)).toEqual([id]);
cut.stack.undo();
expect([cut.comp().objects, cut.comp().items]).toEqual([[], []]);
});
it('a track goes with what lies on it, and the last track stays', () => {
const cut = makeCut();
const second = { id: 't2', name: 'Track 2' };
cut.stack.execute(new SetCompositionCommand(cut.compId, { tracks: [...cut.comp().tracks, second] }));
cut.add(0, 60);
cut.stack.execute(removeCompositionTrack(cut.project, cut.compId, cut.track));
expect(cut.comp().tracks.map((t) => t.id)).toEqual(['t2']);
expect([cut.comp().objects, cut.comp().items]).toEqual([[], []]);
cut.stack.execute(removeCompositionTrack(cut.project, cut.compId, 't2'));
expect(cut.comp().tracks).toHaveLength(1);
});
it('a composition cannot be placed into a composition', () => {
const cut = makeCut();
expect(placementRejection(cut.project, cut.comp(), findAsset(cut.project, cut.compId)!)).not.toBe('');
});
it('a sprite wearing a video is dropped in as long as the video, anything else 3 s', () => {
const cut = makeCut();
const screen = new CreateAssetCommand('sprite', 'Screen', { texture: cut.videoId });
cut.stack.execute(screen);
expect(droppedLength(cut.project, cut.comp(), screen.asset)).toBe(300);
expect(droppedLength(cut.project, cut.comp(), findAsset(cut.project, cut.boxId)!)).toBe(180);
});
});
describe('an item that follows its media (fitMedia)', () => {
const wear = (cut: ReturnType<typeof makeCut>, id: string, texture: string) =>
cut.stack.execute(new FitMediaCommand(new SetOverrideCommand(cut.compId, id, 'texture', texture)));
it('takes the length of a video an override gives it, in the same undo step', () => {
const cut = makeCut();
const id = cut.add(0, 180);
wear(cut, id, cut.videoId);
expect(cut.comp().items[0]!.length).toBe(300);
cut.stack.undo();
expect(cut.comp().items[0]!.length).toBe(180);
expect(cut.comp().objects[0]!.overrides.texture).toBeUndefined();
cut.stack.redo();
expect(cut.comp().items[0]!.length).toBe(300);
});
it('goes back to 3 s when the video is taken off again', () => {
const cut = makeCut();
const id = cut.add(0, 180);
wear(cut, id, cut.videoId);
expect(cut.comp().items[0]!.length).toBe(300);
cut.stack.execute(new FitMediaCommand(new ClearOverrideCommand(cut.compId, id, 'texture')));
expect(cut.comp().items[0]!.length).toBe(180);
});
it('stops where the next item of its track begins, which stays where it is', () => {
const cut = makeCut();
const id = cut.add(0, 180);
const next = cut.add(240, 60);
wear(cut, id, cut.videoId);
expect(cut.comp().items.map((i) => [i.id, i.start, i.length])).toEqual([
[id, 0, 240],
[next, 240, 60],
]);
});
it('is left alone once its length is its own', () => {
const cut = makeCut();
const id = cut.add(0, 180);
const items = cut.comp().items.map((i) => resizedItem(cut.comp().items, i, 90));
cut.stack.execute(new SetCompositionCommand(cut.compId, { items }));
wear(cut, id, cut.videoId);
expect(cut.comp().items[0]!.length).toBe(90);
});
});
+189
View File
@@ -0,0 +1,189 @@
/**
* The Composition tab (#417). A sprite dragged in from the Assets panel becomes an item, 3 s long
* and picked, so the inspector shows its TIMING; a second one dropped onto the first lands right
* after it. A dragged end stops at the next item. An animation dragged in becomes a clip that moves
* the item it overlaps in the picture. The hierarchy lists what is alive at the playhead, Delete
* takes an item with its object, and Play runs the playhead to the end.
*
* Everything it adds carries a `ct69` id and goes again at the end.
*/
const CUT = 'ct69-cut';
const BOX = 'ct69-box';
const SLIDE = 'ct69-slide';
export async function compositionTab(t) {
const { check, sleep, json, evaluate, mutate, mouse, drag, press, showTab } = t;
/** Drags a row of the Assets panel to a point step by step: the drop only exists while the button is down. */
const dragAsset = async (assetId, to) => {
// A press on the row of the drag before, this soon, would read as a double-click that opens it.
await sleep(450);
const from = await json(`__gm.assetRow(${JSON.stringify(assetId)})`);
await mouse('mouseMoved', from.x, from.y);
await mouse('mousePressed', from.x, from.y);
for (let i = 1; i <= 8; i++) {
await mouse(
'mouseMoved',
from.x + ((to.x - from.x) * i) / 8,
from.y + ((to.y - from.y) * i) / 8,
'left',
{
buttons: 1,
},
);
await sleep(16);
}
const drop = await json('__gm.composition.drop()');
await mouse('mouseReleased', to.x, to.y);
await sleep(200);
return drop;
};
const model = () =>
json(`(() => {
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
return { objects: c.objects.map((o) => o.id), items: c.items, clips: c.clips };
})()`);
await press('Escape');
await showTab('scene');
// Ten frames a second keeps the numbers small: an item of 3 s is 30 frames, the slide 5.
await mutate(`
const p = __gm.state.project;
p.assets.push(
{ id: '${CUT}', type: 'composition', name: 'Cut 69', props: { fps: 10, loop: false }, objects: [], groups: [],
tracks: [{ id: 'ct69-t1', name: 'Front' }, { id: 'ct69-t2', name: 'Back' }], items: [], clips: [] },
{ id: '${BOX}', type: 'sprite', name: 'Box 69', props: { width: 100, height: 100, color: 0xff0000, texture: '' } },
{ id: '${SLIDE}', type: 'animation', name: 'Slide 69', props: { fps: 10, playback: 'once' }, root: '${BOX}',
tracks: [{ id: 'ct69-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 5, value: 100 }] }] },
);`);
await evaluate(`__gm.state.openAsset('${CUT}')`);
await sleep(300);
const opened = await json(`{ tab: __gm.tab(), current: __gm.composition.current() }`);
check(
'opening a composition brings up its tab',
opened.tab === 'composition' && opened.current === CUT,
JSON.stringify(opened),
);
// 69a. A sprite dropped on the first track at frame 5 is an item there, 3 s long, picked.
const first = await dragAsset(BOX, await json(`__gm.composition.laneAt('ct69-t1', 5)`));
let m = await model();
const a = m.items[0];
const picked = await json('__gm.state.selection');
check(
'a sprite dragged onto a track becomes an item where it is let go, 3 s long and picked',
first?.what === 'item' &&
first.rejection === '' &&
m.items.length === 1 &&
a.start === 5 &&
a.length === 30 &&
a.fitMedia === true &&
JSON.stringify(m.objects) === JSON.stringify([a.id]) &&
picked?.kind === 'object' &&
picked.holderId === CUT &&
picked.objectId === a.id,
JSON.stringify({ first, m, picked }),
);
const timing = await json(`__gm.section('TIMING')`);
check('the inspector shows the picked item’s TIMING', timing !== null, JSON.stringify(timing));
// 69b. A second one let go over the first lands right after it.
await dragAsset(BOX, await json(`__gm.composition.laneAt('ct69-t1', 20)`));
m = await model();
const b = m.items.find((i) => i.id !== a.id);
check(
'one dropped onto an item lands right after it',
m.items.length === 2 && b?.start === 35 && b.length === 30,
JSON.stringify(m.items),
);
// 69c. The first item's end dragged past the second stops where the second begins.
const box = await json(`__gm.composition.box('${a.id}')`);
const far = await json(`__gm.composition.laneAt('ct69-t1', 60)`);
await drag(box.x + box.w - 2, box.y + box.h / 2, far.x, box.y + box.h / 2);
await sleep(150);
m = await model();
const resized = m.items.find((i) => i.id === a.id);
check(
'a dragged end stops where the next item begins, and the length is the item’s own then',
resized?.length === 30 && resized.fitMedia === undefined,
JSON.stringify(resized),
);
// 69d. The animation dropped at frame 5 is a clip one pass long that moves the item in the picture.
const clipDrop = await dragAsset(SLIDE, await json(`__gm.composition.laneAt('ct69-t1', 5)`));
m = await model();
const x = async (frame) => {
await evaluate(`__gm.composition.scrub(${frame})`);
await sleep(60);
return (await json('__gm.composition.shown()')).find((o) => o.id === a.id)?.x ?? null;
};
const xs = [await x(5), await x(8), await x(20)];
check(
'an animation dragged onto a track becomes a clip, and moves the item it overlaps in the picture',
clipDrop?.what === 'clip' &&
m.clips.length === 1 &&
m.clips[0].start === 5 &&
m.clips[0].animation === SLIDE &&
xs[0] === 0 &&
xs[1] === 60 &&
xs[2] === 100,
JSON.stringify({ clipDrop, clips: m.clips, xs }),
);
// 69e. The hierarchy lists what is alive at the playhead – read off the rows it shows.
const rowsAt = async (frame) => {
await evaluate(`__gm.composition.scrub(${frame})`);
await sleep(80);
const shown = await json(`[${m.items.map((i) => `__gm.hierarchyRow('${i.id}')`).join(',')}]`);
return m.items.filter((_, n) => shown[n] !== null).map((i) => i.id);
};
const rows = [await rowsAt(2), await rowsAt(10), await rowsAt(40)];
check(
'the hierarchy lists the items alive at the playhead',
rows[0].length === 0 &&
JSON.stringify(rows[1]) === JSON.stringify([a.id]) &&
JSON.stringify(rows[2]) === JSON.stringify([b.id]),
JSON.stringify(rows),
);
// 69f. Delete takes the picked item with its object; undo brings both back.
await evaluate(`__gm.state.select({ kind: 'object', holderId: '${CUT}', objectId: '${b.id}' })`);
await press('Delete');
await sleep(150);
const gone = await model();
await press('z', { ctrl: true });
await sleep(150);
const back = await model();
check(
'Delete takes an item with its object, undo brings both back',
!gone.items.some((i) => i.id === b.id) &&
!gone.objects.includes(b.id) &&
back.items.some((i) => i.id === b.id) &&
back.objects.includes(b.id),
JSON.stringify({ gone, back }),
);
// 69g. Play runs the playhead from the start to the end of the last item, and stops there.
await evaluate(`__gm.composition.scrub(0)`);
const playAt = await json('__gm.composition.playAt()');
await mouse('mouseMoved', playAt.x, playAt.y);
await mouse('mousePressed', playAt.x, playAt.y);
await mouse('mouseReleased', playAt.x, playAt.y);
await sleep(500);
const running = await json('__gm.composition.view()');
let ended = running;
for (let i = 0; i < 40 && ended.playing; i++) {
await sleep(150);
ended = await json('__gm.composition.view()');
}
check(
'Play runs the playhead to the end and stops there',
running.playing && running.playhead > 0 && !ended.playing && ended.playhead === 65,
JSON.stringify({ running, ended }),
);
await showTab('scene');
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !a.id.startsWith('ct69-'));`);
}
+2
View File
@@ -71,6 +71,7 @@ import { song } from './65-song.mjs';
import { video } from './66-video.mjs';
import { recording } from './67-recording.mjs';
import { composition } from './68-composition.mjs';
import { compositionTab } from './69-composition-tab.mjs';
export const STEPS = [
hierarchy,
@@ -141,4 +142,5 @@ export const STEPS = [
video,
recording,
composition,
compositionTab,
];