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:
1 parent
0334d2508d
commit
e435c54afb
24 files changed
+2533
-18
No files matched your search
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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) : '';
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
+25
-2
@@ -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,6 +1252,8 @@ export class Editor implements PlayHost {
|
||||
this.hierarchy.showObjectsOf(
|
||||
tab === 'container'
|
||||
? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
|
||||
: tab === 'composition'
|
||||
? () => this.compositionEditor.shownHolder()
|
||||
: () => this.state.activeScene,
|
||||
);
|
||||
// A pane in a window shows there whatever the row shows.
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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')}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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-'));`);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
Reference in new issue
Block a user