From b16fe8c71826303b3527c964e2b3fc2deda612fc Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 12 Oct 2026 18:48:20 +0200 Subject: [PATCH] Drop a picture or video onto a composition track (#419) A track item has always carried an object, and an object needs an asset. Instead of objects without one, a dropped picture or video gets a sprite the composition owns, the way a song owns its sounds (#355). To the rest of the code it is a sprite like any other. - The asset list starts a drag for textures, vector art, pixel art and videos. Dropped on a track, they make the sprite and the item in one undo step. A video item is as long as the video and follows it. - One sprite per picture and composition. A second drop of the same video shares it. Once no item wears it, it goes in the same step. - Deleting a composition takes its sprites along. A duplicate gets sprites of its own. - Pasting into a scene or container turns the sprite into a project asset. Pasting into another composition gives that one a copy. The clipboard carries the sprite, so cut and paste loses nothing. - Owned sprites stay out of the asset list and the pickers. The inspector shows them with a dot in front. - "Make project asset" in the item's menu, on the timeline and in the hierarchy, turns the sprite into an ordinary one. - The first track of the Composition tab gets its top line, as the Song tab's has. Found on the way: loading kept an owner only for the asset types a song can hold. A composition's sprite would have lost its owner and turned up in the list after a reload. Tests: - Unit composition-sprites: 11 new. Without the rule 5 of them are red, without the loading fix the loading test is. - e2e 69 on melektaus-atx: ALL PASSED, 15 checks, 69k new - Gate on melektaus-atx: 305 files, 4714 tests Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/commands/compositionSprites.ts | 198 ++++++++++++++++ src/core/commands/pasteCommands.ts | 10 +- src/core/model/Asset.ts | 5 +- src/core/model/clipboard.ts | 8 +- src/core/model/composition.ts | 7 + src/core/model/compositionEdit.ts | 8 +- src/core/music/embedded.ts | 15 +- src/core/store/serialize.ts | 11 +- src/editor/actions.ts | 10 +- src/editor/composition/CompositionTimeline.ts | 32 +-- src/editor/inspector/compositionSection.ts | 32 ++- src/editor/inspector/objectSection.ts | 5 +- src/editor/objectMenu.ts | 3 +- src/editor/panels/AssetPanel.ts | 12 +- src/editor/state.ts | 5 +- tests/core/composition-sprites.test.ts | 215 ++++++++++++++++++ tools/e2e/69-composition-tab.mjs | 44 +++- 18 files changed, 573 insertions(+), 49 deletions(-) create mode 100644 src/core/commands/compositionSprites.ts create mode 100644 tests/core/composition-sprites.test.ts diff --git a/README.md b/README.md index 99b3ea40..5bc1b7cb 100644 --- a/README.md +++ b/README.md @@ -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, 2047 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2048 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 diff --git a/src/core/commands/compositionSprites.ts b/src/core/commands/compositionSprites.ts new file mode 100644 index 00000000..7235cd7b --- /dev/null +++ b/src/core/commands/compositionSprites.ts @@ -0,0 +1,198 @@ +/** + * What a composition keeps to itself (#419): a texture or video laid on a track wears a sprite the + * composition owns. It is an asset like any other, so everything that finds an asset by its id finds + * it, but the lists leave it out, as with the takes of a song (#355). After every edit the project is + * held to that: a composition using the sprite of another gets a copy, one placed anywhere else makes + * it a project asset, and one nothing uses any more goes. + */ +import { newId } from '../ids'; +import type { Asset, CompositionItem, SpriteAsset } from '../model/Asset'; +import { isCompositionSprite } from '../model/composition'; +import { generatesPictures } from '../model/generated'; +import { pixelArtWornId } from '../model/pixelart'; +import type { PropertyBag } from '../model/Properties'; +import { findAsset, objectHolders, type Project } from '../model/Project'; +import { spriteFromTexture } from '../properties/aspect'; +import { type Removed, removeWhere, restoreAt } from '../util/list'; +import type { Command } from './Command'; +import { addCompositionItem } from './compositionCommands'; +import { oneStep } from './CompoundCommand'; +import { nextAssetName } from './naming'; + +/** + * What a sprite made of `asset` wears: a texture, a drawn picture (#185) or a video; a sequence of + * pixel art frames its first frame. Null for an asset that is no picture. + */ +export function wornPicture(asset: Asset): string | null { + if (asset.type === 'pixelart') return pixelArtWornId(asset); + return asset.type === 'texture' || asset.type === 'video' || generatesPictures(asset.type) + ? asset.id + : null; +} + +/** The sprite the composition already keeps for this picture: one serves every item wearing it. */ +function keptSprite(project: Project, compId: string, textureId: string): SpriteAsset | undefined { + return project.assets.find( + (a): a is SpriteAsset => isCompositionSprite(a) && a.owner === compId && a.props['texture'] === textureId, + ); +} + +/** A new sprite of the composition's own, wearing the picture at its own size. */ +function newCompositionSprite( + project: Project, + compId: string, + textureId: string, + name: string, +): SpriteAsset { + const props: PropertyBag = spriteFromTexture(project, textureId); + return { id: newId(), type: 'sprite', name, props, owner: compId }; +} + +/** + * A texture, a drawn picture or a video laid on a track: an item wearing the sprite the composition + * keeps for that picture, made first when it has none yet, in one undo step. + */ +export function addPictureItem( + project: Project, + compId: string, + textureId: string, + name: string, + item: Omit, +): { command: Command; objectId: string } { + const kept = keptSprite(project, compId, textureId); + const sprite = kept ?? newCompositionSprite(project, compId, textureId, name); + const { command, objectId } = addCompositionItem(project, compId, sprite.id, item); + return { command: kept ? command : oneStep('Add item', [new AddSpriteCommand(sprite), command]), objectId }; +} + +class AddSpriteCommand implements Command { + readonly label = 'Add sprite'; + + constructor(private readonly sprite: SpriteAsset) {} + + execute(project: Project): void { + project.assets.push(structuredClone(this.sprite)); + } + + undo(project: Project): void { + project.assets = project.assets.filter((a) => a.id !== this.sprite.id); + } +} + +interface Retarget { + holderId: string; + objectId: string; + from: string; + to: string; +} + +interface Owned { + assetId: string; + from: { owner: string; name: string }; + to: { owner: string; name: string }; +} + +export interface SpriteChange { + /** Copies for compositions using a sprite another one keeps. */ + added: Asset[]; + retargets: Retarget[]; + /** Sprites made project assets or handed to the composition now using them. */ + owners: Owned[]; + dropped: Set; +} + +/** + * Holds every composition's sprites to what uses them, in place. A sprite placed in a scene or a + * container becomes a project asset. Otherwise it stays with its composition while that uses it, else + * goes to the first composition that does; every other composition using it gets a copy of its own. + */ +export function settleCompositionSprites(project: Project): SpriteChange { + const change: SpriteChange = { added: [], retargets: [], owners: [], dropped: new Set() }; + const sprites = project.assets.filter(isCompositionSprite); + if (sprites.length === 0) return change; + const holders = objectHolders(project); + for (const sprite of sprites) { + const using = holders.filter((h) => h.objects.some((o) => o.assetId === sprite.id)); + const from = { owner: sprite.owner!, name: sprite.name }; + if (using.some((h) => h.type !== 'composition')) { + const to = { owner: '', name: nextAssetName(project, sprite.name, sprite.id) }; + setOwner(sprite, to); + change.owners.push({ assetId: sprite.id, from, to }); + continue; + } + if (using.length === 0) { + change.dropped.add(sprite.id); + continue; + } + const keeper = using.find((h) => h.id === sprite.owner) ?? using[0]!; + if (keeper.id !== sprite.owner) { + const to = { owner: keeper.id, name: sprite.name }; + setOwner(sprite, to); + change.owners.push({ assetId: sprite.id, from, to }); + } + for (const holder of using) { + if (holder === keeper) continue; + const copy: SpriteAsset = { ...structuredClone(sprite), id: newId(), owner: holder.id }; + change.added.push(copy); + for (const object of holder.objects) { + if (object.assetId !== sprite.id) continue; + change.retargets.push({ holderId: holder.id, objectId: object.id, from: sprite.id, to: copy.id }); + object.assetId = copy.id; + } + } + } + project.assets.push(...change.added); + return change; +} + +function setOwner(asset: Asset, to: { owner: string; name: string }): void { + if (to.owner) asset.owner = to.owner; + else delete asset.owner; + asset.name = to.name; +} + +function retarget(project: Project, retargets: readonly Retarget[], to: (r: Retarget) => string): void { + for (const r of retargets) { + const holder = objectHolders(project).find((h) => h.id === r.holderId); + const object = holder?.objects.find((o) => o.id === r.objectId); + if (object) object.assetId = to(r); + } +} + +/** + * Any edit, with the compositions' sprites settled after it (#419). Worked out once, so a redo brings + * back the very same copies; taken back along with the edit. + */ +export class KeepCompositionSpritesCommand implements Command { + private change: SpriteChange | null = null; + private removed: Removed[] = []; + + constructor(readonly inner: Command) {} + + get label(): string { + return this.inner.label; + } + + execute(project: Project): void { + this.inner.execute(project); + if (!this.change) this.change = settleCompositionSprites(project); + else { + for (const o of this.change.owners) setOwner(findAsset(project, o.assetId)!, o.to); + project.assets.push(...this.change.added); + retarget(project, this.change.retargets, (r) => r.to); + } + const dropped = this.change.dropped; + this.removed = removeWhere(project.assets, (a) => dropped.has(a.id)); + } + + undo(project: Project): void { + if (this.change) { + restoreAt(project.assets, this.removed); + retarget(project, this.change.retargets, (r) => r.from); + const added = new Set(this.change.added.map((a) => a.id)); + project.assets = project.assets.filter((a) => !added.has(a.id)); + for (const o of [...this.change.owners].reverse()) setOwner(findAsset(project, o.assetId)!, o.from); + } + this.inner.undo(project); + } +} diff --git a/src/core/commands/pasteCommands.ts b/src/core/commands/pasteCommands.ts index 9f32731d..11bf8cea 100644 --- a/src/core/commands/pasteCommands.ts +++ b/src/core/commands/pasteCommands.ts @@ -2,6 +2,7 @@ import { annotationList, type Graph } from '../graph/Graph'; import { isTerminalType, terminalNode } from '../graph/nested'; import { nodeDef } from '../graph/NodeRegistry'; import { newId } from '../ids'; +import type { Asset } from '../model/Asset'; import type { GraphClip, ObjectClip } from '../model/clipboard'; import { findGroup } from '../model/hierarchy'; import { isLiveInstance } from '../model/instance'; @@ -28,6 +29,7 @@ function freshIds(ids: readonly string[]): Map { export class PasteObjectsCommand implements Command { readonly label: string; private readonly ids: Map; + private restored: Asset[] = []; constructor( private readonly holderId: string, @@ -46,8 +48,10 @@ export class PasteObjectsCommand implements Command { execute(project: Project): void { const holder = requireHolder(project, this.holderId); + // A cut took the sprites a composition kept for it along (#419); who keeps them is settled after. + const restored = (this.clip.sprites ?? []).filter((s) => !findAsset(project, s.id)); for (const object of this.clip.objects) { - const asset = findAsset(project, object.assetId); + const asset = findAsset(project, object.assetId) ?? restored.find((s) => s.id === object.assetId); if (!asset) throw new CommandError('A copied object belongs to an asset that no longer exists'); const rejection = placementRejection(project, holder, asset); if (rejection) throw new CommandError(rejection); @@ -55,6 +59,8 @@ export class PasteObjectsCommand implements Command { for (const parentId of new Set(Object.values(this.parents))) if (parentId && isLiveInstance(project, holder, requireStructuralObject(project, holder, parentId))) throw new CommandError('A placed container shows its template; unpack it to put objects inside'); + this.restored = structuredClone(restored); + project.assets.push(...this.restored); const roots = new Map(this.clip.roots.map((root) => [root.id, root])); const pasted = this.clip.objects.map((object): SceneObject => { const copy = structuredClone(object); @@ -87,6 +93,8 @@ export class PasteObjectsCommand implements Command { const holder = requireHolder(project, this.holderId); const ids = new Set(this.ids.values()); holder.objects = holder.objects.filter((o) => !ids.has(o.id)); + const restored = new Set(this.restored.map((s) => s.id)); + project.assets = project.assets.filter((a) => !restored.has(a.id)); } } diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 277fb29d..96d1936f 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -14,7 +14,10 @@ export interface AssetBase { type: AssetType; name: string; props: PropertyBag; - /** The song a sound, pattern or sound FX made in it belongs to (#355); absent on a project asset. */ + /** + * The song a sound, pattern or sound FX made in it belongs to (#355), or the composition a sprite + * worn by its items does (#419); absent on a project asset. + */ owner?: string; } diff --git a/src/core/model/clipboard.ts b/src/core/model/clipboard.ts index 1dd42cea..ce18abd0 100644 --- a/src/core/model/clipboard.ts +++ b/src/core/model/clipboard.ts @@ -14,7 +14,8 @@ import { import { editorTransform, parentTransform, positionOf } from '../properties/transform'; import { findObject, subtreeIds, subtreeRoots, treeOrder } from './hierarchy'; import { isLiveInstance, isTemplateContent, resolvedHolder, splitInstanceId } from './instance'; -import type { CompositionItem } from './Asset'; +import type { CompositionItem, SpriteAsset } from './Asset'; +import { isCompositionSprite } from './composition'; import type { Mat } from './matrix'; import type { Position } from './Properties'; import type { ObjectHolder, Project } from './Project'; @@ -37,6 +38,8 @@ export interface ObjectClip { roots: ClipRoot[]; /** Copied out of a composition (#417): the items of the roots that are items, for a paste there. */ items?: CompositionItem[]; + /** The sprites of a composition's own the objects wear (#419), back for a paste after a cut. */ + sprites?: SpriteAsset[]; } export interface GraphClip { @@ -80,10 +83,13 @@ export function copyObjects( .filter((o) => members.has(o.id) && !shownByInstance(o.id)) .map((o) => ({ ...structuredClone(o), editorOffset: { x: 0, y: 0 } })); const items = holder.type === 'composition' ? holder.items.filter((i) => roots.includes(i.id)) : []; + const worn = new Set(objects.map((o) => o.assetId)); + const sprites = project.assets.filter(isCompositionSprite).filter((a) => worn.has(a.id)); return { kind: 'objects', objects, ...(items.length > 0 && { items: structuredClone(items) }), + ...(sprites.length > 0 && { sprites: structuredClone(sprites) }), roots: roots.map((id) => { const object = findObject(view, id)!; return { diff --git a/src/core/model/composition.ts b/src/core/model/composition.ts index d46c74b2..36718d09 100644 --- a/src/core/model/composition.ts +++ b/src/core/model/composition.ts @@ -16,10 +16,12 @@ import { type ReadAt, writeObjectRow } from './animationPreview'; import type { AnimationAsset, AnimationTrack, + Asset, CompositionAsset, CompositionClip, CompositionItem, CompositionTrack, + SpriteAsset, } from './Asset'; import { curveLookup } from './curve'; import { @@ -47,6 +49,11 @@ export function compositionLoops(comp: CompositionAsset): boolean { return booleanOf(comp.props[COMPOSITION_LOOP_KEY]); } +/** A sprite a composition keeps to itself (#419), worn by the items a texture or video was laid as. */ +export function isCompositionSprite(asset: Asset): asset is SpriteAsset { + return asset.type === 'sprite' && (asset.owner ?? '') !== ''; +} + export function newCompositionTrack(index: number): CompositionTrack { return { id: newId(), name: `Track ${index}` }; } diff --git a/src/core/model/compositionEdit.ts b/src/core/model/compositionEdit.ts index cb708143..430e8c2a 100644 --- a/src/core/model/compositionEdit.ts +++ b/src/core/model/compositionEdit.ts @@ -235,9 +235,13 @@ export function snappedToMedia(frame: number, media: ItemMedia | null, reach: nu ); } -/** How long an item of `asset` is when it is dropped in: as long as the video its sprite wears, else 3 s. */ +/** + * How long an item of `asset` is when it is dropped in: as long as the video it is (#419) or 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 texture = + asset.type === 'sprite' ? asset.props.texture : asset.type === 'video' ? asset.id : undefined; const video = typeof texture === 'string' ? findVideo(project, texture) : null; const fps = compositionFps(comp); const seconds = video ? videoLengthOf(video) : 0; diff --git a/src/core/music/embedded.ts b/src/core/music/embedded.ts index 57621622..c0206ddd 100644 --- a/src/core/music/embedded.ts +++ b/src/core/music/embedded.ts @@ -53,20 +53,19 @@ export function embeddedIn(project: Project, songId: string): Asset[] { return project.assets.filter((a) => a.owner === songId); } -/** The song an asset belongs to; undefined for a project asset. */ -export function ownerSong(project: Project, asset: Asset): SongAsset | undefined { - const owner = asset.owner ? findAsset(project, asset.owner) : undefined; - return owner?.type === 'song' ? owner : undefined; -} - export function isRowHiddenWhenEmbedded(asset: Asset, key: string): boolean { return isEmbedded(asset) && (HIDDEN_ROWS[asset.type]?.includes(key) ?? false); } +/** The song or composition (#419) an asset belongs to; undefined for a project asset. */ +export function ownerOf(project: Project, asset: Asset): Asset | undefined { + return isEmbedded(asset) ? findAsset(project, asset.owner!) : undefined; +} + /** "Song › Take 1" for an embedded asset, its own name for any other. */ export function assetTitle(project: Project, asset: Asset): string { - const song = ownerSong(project, asset); - return song ? `${song.name} › ${asset.name}` : asset.name; + const owner = ownerOf(project, asset); + return owner ? `${owner.name} › ${asset.name}` : asset.name; } /** The tempo and the meter of a song, as the props a pattern made in it starts with. */ diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 1cf4b891..f1678f03 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -369,11 +369,14 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { assets.push({ id, type, name, props }); } const owner = typeof a.owner === 'string' ? a.owner : ''; - if (owner && isEmbeddable(type)) assets[assets.length - 1]!.owner = owner; + if (owner && (isEmbeddable(type) || type === 'sprite')) assets[assets.length - 1]!.owner = owner; + } + // One whose song or composition (#419) is gone is kept as a project asset rather than lost (#355). + const ownerType = new Map(assets.map((a) => [a.id, a.type])); + for (const asset of assets) { + if (!asset.owner) continue; + if (ownerType.get(asset.owner) !== (asset.type === 'sprite' ? 'composition' : 'song')) delete asset.owner; } - // One whose song is gone is kept as a project asset rather than lost (#355). - const songs = new Set(assets.filter((a) => a.type === 'song').map((a) => a.id)); - for (const asset of assets) if (asset.owner && !songs.has(asset.owner)) delete asset.owner; let activeSceneId = typeof r.activeSceneId === 'string' ? r.activeSceneId : ''; if (!assets.some((a) => a.type === 'scene' && a.id === activeSceneId)) { diff --git a/src/editor/actions.ts b/src/editor/actions.ts index b99b9b4c..4f779802 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -29,7 +29,7 @@ import { isTemplateContent } from '../core/model/instance'; import type { Command } from '../core/commands/Command'; import { findAnnotation, findNode, nodeDef } from '../core/graph'; import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project'; -import { ownerSong } from '../core/music/embedded'; +import { ownerOf } from '../core/music/embedded'; import type { SceneObject } from '../core/model/SceneObject'; import { pixelArtRefIds } from '../core/model/pixelart'; import { textureRefIds } from '../core/model/texture'; @@ -255,9 +255,11 @@ export function removeSelection(state: EditorState): boolean { export function confirmDeleteAsset(state: EditorState, assetId: string): void { const asset = findAsset(state.project, assetId); if (!asset) return; - const song = ownerSong(state.project, asset); - if (song) { - state.setNotice(`"${asset.name}" lives in the song "${song.name}" and goes with what uses it there`); + const owner = ownerOf(state.project, asset); + if (owner) { + state.setNotice( + `"${asset.name}" lives in the ${owner.type} "${owner.name}" and goes with what uses it there`, + ); return; } const usages = findUsages(state.project, assetId).length; diff --git a/src/editor/composition/CompositionTimeline.ts b/src/editor/composition/CompositionTimeline.ts index 22a79c89..5dc15ad8 100644 --- a/src/editor/composition/CompositionTimeline.ts +++ b/src/editor/composition/CompositionTimeline.ts @@ -13,6 +13,7 @@ import { removeFromComposition, SetCompositionCommand, } from '../../core/commands/compositionCommands'; +import { addPictureItem, wornPicture } from '../../core/commands/compositionSprites'; import { newId } from '../../core/ids'; import { isPlaceableType } from '../../core/model/AssetTypes'; import type { CompositionAsset, CompositionClip, CompositionItem } from '../../core/model/Asset'; @@ -56,7 +57,7 @@ import { Widget, zoomFactor, } from '../../ui'; -import { fitToMedia } from '../inspector/compositionSection'; +import { fitToMedia, makeProjectAssetEntry } from '../inspector/compositionSection'; import { clampTimePan, tickToX, xToTick, zoomTime } from '../music/timeView'; import { type Box, @@ -713,15 +714,17 @@ export class CompositionTimeline extends Widget { } /** - * Follows an asset dragged in from the panel: a placeable one lands as an item under the pointer, - * as long as its media or 3 s, over whatever lies there; an animation as a clip one pass long, where - * it fits whole. + * Follows an asset dragged in from the panel: a placeable one, a picture or a video (#419) lands as + * an item under the pointer, as long as its media or 3 s, over whatever lies there; an animation as + * a clip one pass long, where it fits whole. */ private followDrop(asset: CompositionAsset, assetId: string, local: Vec2, g: Vec2): void { const dragged = findAsset(this.state.project, assetId); const row = trackAtY(this.view, asset.tracks.length, g.y); const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h; - const usable = dragged && (dragged.type === 'animation' || isPlaceableType(dragged.type)); + const picture = dragged ? wornPicture(dragged) : null; + const usable = + dragged && (dragged.type === 'animation' || isPlaceableType(dragged.type) || picture !== null); if (!inside || row === null || !dragged || !usable) { if (this.drop) { this.drop = null; @@ -761,7 +764,7 @@ export class CompositionTimeline extends Widget { track, start: at, length: droppedLength(this.state.project, asset, dragged), - rejection: placementRejection(this.state.project, asset, dragged), + rejection: picture === null ? placementRejection(this.state.project, asset, dragged) : '', }; this.redraw(); } @@ -801,13 +804,13 @@ export class CompositionTimeline extends Widget { }); return; } - const { command, objectId } = addCompositionItem(this.state.project, asset.id, drop.assetId, { - track: drop.track, - start: drop.start, - length: drop.length, - offset: 0, - fitMedia: true, - }); + const item = { track: drop.track, start: drop.start, length: drop.length, offset: 0, fitMedia: true }; + const dragged = findAsset(this.state.project, drop.assetId); + const picture = dragged ? wornPicture(dragged) : null; + const { command, objectId } = + dragged && picture !== null + ? addPictureItem(this.state.project, asset.id, picture, dragged.name, item) + : addCompositionItem(this.state.project, asset.id, drop.assetId, item); if (this.state.execute(command)) this.state.select(objectSelection(asset.id, [objectId])); } @@ -843,6 +846,7 @@ export class CompositionTimeline extends Widget { disabled: ids.length !== 1, onSelect: () => fitToMedia({ state: this.state }, asset.id, hit.id), }, + makeProjectAssetEntry({ state: this.state }, asset.id, hit.id), { separator: true }, { label: ids.length > 1 ? `Remove ${ids.length} items` : 'Remove item', @@ -1020,9 +1024,11 @@ export class CompositionTimeline extends Widget { .rect(HEAD_WIDTH, y, this.lanesWidth, h) .fill({ color: 0xffffff, alpha: row % 2 === 0 ? 0.02 : 0.04 }); if (end > zero) this.lanes.rect(zero, y, end - zero, h).fill({ color: 0xffffff, alpha: 0.02 }); + if (row === 0) this.lanes.rect(HEAD_WIDTH, y, this.lanesWidth, 1).fill({ color: c.border, alpha: 0.6 }); this.lanes.rect(HEAD_WIDTH, y + h - 1, this.lanesWidth, 1).fill({ color: c.border, alpha: 0.6 }); if (y < RULER_HEIGHT) return; if (track.id === lit) this.bg.rect(0, y, HEAD_WIDTH - 1, h).fill({ color: c.selection, alpha: 0.6 }); + if (row === 0) this.bg.rect(0, y, HEAD_WIDTH, 1).fill({ color: c.border, alpha: 0.6 }); this.bg.rect(0, y + h - 1, HEAD_WIDTH, 1).fill({ color: c.border, alpha: 0.6 }); heads.push({ text: track.name, x: 8, y: y + 4, w: HEAD_WIDTH - 16 }); }); diff --git a/src/editor/inspector/compositionSection.ts b/src/editor/inspector/compositionSection.ts index a69cf50c..6cc65199 100644 --- a/src/editor/inspector/compositionSection.ts +++ b/src/editor/inspector/compositionSection.ts @@ -1,10 +1,17 @@ +import { MakeProjectAssetCommand } from '../../core/commands/assetCommands'; import { removeFromComposition, SetCompositionCommand } from '../../core/commands/compositionCommands'; import type { CompositionAsset, CompositionItem } from '../../core/model/Asset'; -import { clipLength, compositionFps, fittedLength, itemVideo } from '../../core/model/composition'; +import { + clipLength, + compositionFps, + fittedLength, + isCompositionSprite, + itemVideo, +} from '../../core/model/composition'; import { resizedItem } from '../../core/model/compositionEdit'; -import { findAssetOf, type Project } from '../../core/model/Project'; +import { findAsset, findAssetOf, type Project } from '../../core/model/Project'; import type { PropertyDef } from '../../core/properties/PropertySchema'; -import { Button, formatNumber } from '../../ui'; +import { Button, formatNumber, type MenuEntry } from '../../ui'; import { createFieldRow } from './fieldFactory'; import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host'; import { assetLink } from './objectSection'; @@ -215,6 +222,25 @@ export function fitToMedia(host: Pick, holderId: string, host.state.execute(new SetCompositionCommand(holderId, { items }, 'Fit to media')); } +/** + * For an item wearing a sprite its composition keeps (#419): that sprite turned into a project asset, + * listed and placeable anywhere. Greyed out for any other. + */ +export function makeProjectAssetEntry( + host: Pick, + holderId: string, + objectId: string, +): MenuEntry { + const { project } = host.state; + const object = findAssetOf(project, holderId, 'composition')?.objects.find((o) => o.id === objectId); + const asset = object && findAsset(project, object.assetId); + return { + label: 'Make project asset', + disabled: !asset || !isCompositionSprite(asset), + onSelect: () => asset && host.state.execute(new MakeProjectAssetCommand(asset.id)), + }; +} + function withOffset(item: CompositionItem, offset: number): CompositionItem { const { fitMedia: _, ...rest } = item; return { ...rest, offset: Number.isFinite(offset) ? offset : 0 }; diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 84658dc5..ce110107 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -30,6 +30,7 @@ import { type ObjectChoice, objectChoices } from '../../core/model/objectRefs'; import { type PropertyValue, textOf } from '../../core/model/Properties'; import { findAsset, findObjectHolder, type Project } from '../../core/model/Project'; import { objectName, type SceneObject } from '../../core/model/SceneObject'; +import { markedName } from '../../core/music/embedded'; import { COMMON_PROPS, isCommonProp, @@ -322,7 +323,7 @@ const PART_OF_ROW = '#part-of'; 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 }); + const name = new TextField(markedName(asset), { readOnly: true }); row.add(name, 1); host.body.add(row); host.fields.set(key, { row, setValue: (v) => name.setValue(textOf(v)), setModified: () => {} }); @@ -361,7 +362,7 @@ export function refreshObjectSection(host: InspectorHost, holderId: string, obje const { object, asset } = shown; host.nameField?.setValue(object.name); // A rename of the asset changes no structure, so it arrives here rather than as a rebuild. - host.fields.get(ASSET_ROW)?.setValue(asset.name); + host.fields.get(ASSET_ROW)?.setValue(markedName(asset)); const target = targetOf(project, holderId, objectId); if (target) host.fields.get(PART_OF_ROW)?.setValue(target.asset.name); refreshTimingRows(host, holderId, objectId); diff --git a/src/editor/objectMenu.ts b/src/editor/objectMenu.ts index 16959df4..2adb5942 100644 --- a/src/editor/objectMenu.ts +++ b/src/editor/objectMenu.ts @@ -22,7 +22,7 @@ import { } from './actions'; import { hasAssetEditor } from './assetTabs'; import { canPasteObjects, copyObjectIds, pasteObjects } from './clipboard'; -import { fitToMedia } from './inspector/compositionSection'; +import { fitToMedia, makeProjectAssetEntry } from './inspector/compositionSection'; import { groupMenuItems } from './objectGroups'; import type { EditorState } from './state'; @@ -83,6 +83,7 @@ export function openObjectMenu( disabled: many || fromTemplate, onSelect: () => fitToMedia({ state }, holder.id, id), }, + makeProjectAssetEntry({ state }, holder.id, id), { label: items.length > 1 ? `Remove ${items.length} items` : 'Remove item', danger: true, diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 0c94c1e2..c55d6ce8 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -9,6 +9,7 @@ import { SetActiveSceneCommand, SetStartSceneCommand, } from '../../core/commands/assetCommands'; +import { wornPicture } from '../../core/commands/compositionSprites'; import { oneStep } from '../../core/commands/CompoundCommand'; import { nextAssetName } from '../../core/commands/naming'; import { ASSET_TYPE_LABELS, type AssetType, isPlaceableType } from '../../core/model/AssetTypes'; @@ -17,7 +18,7 @@ import { generatesPictures } from '../../core/model/generated'; import { PALETTE_KEY, pixelArtWornId } from '../../core/model/pixelart'; import type { PropertyBag } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; -import { listedAssets, ownerSong } from '../../core/music/embedded'; +import { listedAssets, ownerOf } from '../../core/music/embedded'; import { spriteFromTexture } from '../../core/properties/aspect'; import { assetDeviates } from '../../core/properties/deviation'; import { schemaForAsset } from '../../core/properties/PropertySchema'; @@ -140,12 +141,12 @@ export class AssetPanel extends Panel { /** * Unfolds and scrolls to the pinged asset and lets its row light up; the selection stays. What a - * song keeps to itself has no row, so its song lights up (#355). + * song or a composition keeps to itself has no row, so its owner lights up (#355, #419). */ private ping(): void { const asset = findAsset(this.state.project, this.state.pingedAssetId ?? ''); if (!asset) return; - const id = ownerSong(this.state.project, asset)?.id ?? asset.id; + const id = ownerOf(this.state.project, asset)?.id ?? asset.id; this.reveal(id); this.list.ping(id); } @@ -444,12 +445,13 @@ 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 composition an animation as a clip (#417). A scene has nowhere to go. + * sound (#334), a composition an animation as a clip (#417) and a picture or video as an item + * (#419). A scene has nowhere to go. */ private startPlacementDrag(assetId: string): void { const asset = findAsset(this.state.project, assetId); const laid = asset?.type === 'pattern' || asset?.type === 'sound' || asset?.type === 'animation'; - if (!asset || !(isPlaceableType(asset.type) || laid)) return; + if (!asset || !(isPlaceableType(asset.type) || laid || wornPicture(asset) !== null)) return; startDrag({ kind: 'asset', id: assetId }, asset.name); } diff --git a/src/editor/state.ts b/src/editor/state.ts index 7230a916..f93190b1 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -32,6 +32,7 @@ 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 { KeepCompositionSpritesCommand } from '../core/commands/compositionSprites'; import { settleRouting } from '../core/music/projectRouting'; export type Selection = @@ -472,7 +473,9 @@ export class EditorState { */ execute(command: Command): boolean { return this.runEdit(() => { - this.commands.execute(new KeepRoutingCommand(new FitMediaCommand(command))); + this.commands.execute( + new KeepRoutingCommand(new FitMediaCommand(new KeepCompositionSpritesCommand(command))), + ); return true; }); } diff --git a/tests/core/composition-sprites.test.ts b/tests/core/composition-sprites.test.ts new file mode 100644 index 00000000..27811b65 --- /dev/null +++ b/tests/core/composition-sprites.test.ts @@ -0,0 +1,215 @@ +/** + * What a composition keeps to itself (#419): a texture or video laid on a track wears a sprite the + * composition owns, one per picture. It goes when nothing uses it, comes back with a pasted cut, + * turns into a project asset once placed in a scene and is copied for another composition. + */ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + CreateVideoCommand, + DeleteAssetCommand, + DuplicateAssetCommand, + MakeProjectAssetCommand, +} from '../../src/core/commands/assetCommands'; +import type { Command } from '../../src/core/commands/Command'; +import { pasteCompositionItems, removeFromComposition } from '../../src/core/commands/compositionCommands'; +import { + addPictureItem, + KeepCompositionSpritesCommand, + wornPicture, +} from '../../src/core/commands/compositionSprites'; +import { PasteObjectsCommand } from '../../src/core/commands/pasteCommands'; +import { copyObjects } from '../../src/core/model/clipboard'; +import { isCompositionSprite } from '../../src/core/model/composition'; +import { droppedLength } from '../../src/core/model/compositionEdit'; +import { findAsset, findAssetOf } from '../../src/core/model/Project'; +import { assetTitle, listedAssets } from '../../src/core/music/embedded'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { makeProject } from '../helpers'; + +const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`; + +function makeFilm() { + const f = makeProject(); + const run = (command: Command) => f.stack.execute(new KeepCompositionSpritesCommand(command)); + const scene = new CreateAssetCommand('scene', 'Level'); + const film = new CreateAssetCommand('composition', 'Film'); + const video = new CreateVideoCommand('Intro', FILE, { + width: 320, + height: 180, + duration: 5, + fps: 30, + audio: false, + }); + for (const c of [scene, film, video]) run(c); + const comp = (id = film.asset.id) => findAssetOf(f.project, id, 'composition')!; + const sprites = () => f.project.assets.filter(isCompositionSprite); + /** The video laid on the first track of `compId` at `start`, run through the stack as the timeline does. */ + const drop = (start: number, compId = film.asset.id) => { + const { command, objectId } = addPictureItem(f.project, compId, video.asset.id, 'Intro', { + track: comp(compId).tracks[0]!.id, + start, + length: 150, + offset: 0, + fitMedia: true, + }); + run(command); + return objectId; + }; + const wornBy = (holder: { objects: { id: string; assetId: string }[] }, id: string) => + holder.objects.find((o) => o.id === id)!.assetId; + return { + ...f, + run, + sceneId: scene.asset.id, + filmId: film.asset.id, + videoId: video.asset.id, + comp, + sprites, + drop, + wornBy, + }; +} + +describe('a picture laid on a track', () => { + it('wears a sprite the composition keeps, at the picture size, left out of the lists', () => { + const film = makeFilm(); + const id = film.drop(0); + const [sprite] = film.sprites(); + expect(sprite).toMatchObject({ + owner: film.filmId, + name: 'Intro', + props: { texture: film.videoId, width: 320, height: 180 }, + }); + expect(film.wornBy(film.comp(), id)).toBe(sprite!.id); + expect(film.comp().items).toMatchObject([{ id, start: 0, length: 150, fitMedia: true }]); + expect(listedAssets(film.project).some((a) => a.id === sprite!.id)).toBe(false); + expect(assetTitle(film.project, sprite!)).toBe('Film › Intro'); + }); + + it('is a texture, a drawn picture or a video, and a video lasts as long as it plays', () => { + const film = makeFilm(); + const video = findAsset(film.project, film.videoId)!; + expect(wornPicture(video)).toBe(film.videoId); + expect(wornPicture(findAsset(film.project, film.sceneId)!)).toBeNull(); + expect(droppedLength(film.project, film.comp(), video)).toBe(300); + }); + + it('wears a sprite that "Make project asset" lists like any other, and it stays when the item goes', () => { + const film = makeFilm(); + const id = film.drop(0); + const sprite = film.wornBy(film.comp(), id); + film.run(new MakeProjectAssetCommand(sprite)); + expect(findAsset(film.project, sprite)).toMatchObject({ name: 'Intro 2' }); + expect(listedAssets(film.project).some((a) => a.id === sprite)).toBe(true); + film.run(removeFromComposition(film.project, film.filmId, [id], [])); + expect(findAsset(film.project, sprite)).toBeDefined(); + }); + + it('shares the sprite with every item of the same picture, and the first undo takes only the item', () => { + const film = makeFilm(); + const first = film.drop(0); + const second = film.drop(200); + expect(film.sprites()).toHaveLength(1); + expect(film.wornBy(film.comp(), second)).toBe(film.wornBy(film.comp(), first)); + film.stack.undo(); + expect(film.sprites()).toHaveLength(1); + film.stack.undo(); + expect(film.sprites()).toHaveLength(0); + expect(film.comp().objects).toHaveLength(0); + }); + + it('takes its sprite along when its last item goes, and an undo brings both back', () => { + const film = makeFilm(); + const a = film.drop(0); + const b = film.drop(200); + film.run(removeFromComposition(film.project, film.filmId, [a], [])); + expect(film.sprites()).toHaveLength(1); + film.run(removeFromComposition(film.project, film.filmId, [b], [])); + expect(film.sprites()).toHaveLength(0); + film.stack.undo(); + expect(film.sprites()).toHaveLength(1); + expect(film.wornBy(film.comp(), b)).toBe(film.sprites()[0]!.id); + }); +}); + +describe('a copied item', () => { + it('pasted after a cut wears its sprite again', () => { + const film = makeFilm(); + const id = film.drop(0); + const sprite = film.sprites()[0]!; + const clip = copyObjects(film.project, film.comp(), [id])!; + film.run(removeFromComposition(film.project, film.filmId, [id], [])); + expect(film.sprites()).toHaveLength(0); + const pasted = pasteCompositionItems(film.project, film.filmId, clip, 50)!; + film.run(pasted.command); + expect(film.sprites()).toEqual([sprite]); + expect(film.wornBy(film.comp(), pasted.itemIds[0]!)).toBe(sprite.id); + film.stack.undo(); + expect(film.sprites()).toHaveLength(0); + }); + + it('pasted into a scene makes the sprite a project asset, named apart from the video', () => { + const film = makeFilm(); + const id = film.drop(0); + const clip = copyObjects(film.project, film.comp(), [id])!; + const paste = new PasteObjectsCommand(film.sceneId, clip, {}, { x: 0, y: 0 }); + film.run(paste); + const sprite = findAsset(film.project, film.wornBy(film.comp(), id))!; + expect(sprite.owner).toBeUndefined(); + expect(sprite.name).toBe('Intro 2'); + expect(film.wornBy(findAssetOf(film.project, film.sceneId, 'scene')!, paste.rootIds[0]!)).toBe(sprite.id); + film.stack.undo(); + expect(findAsset(film.project, sprite.id)).toMatchObject({ owner: film.filmId, name: 'Intro' }); + }); + + it('pasted into another composition gets a copy that one keeps', () => { + const film = makeFilm(); + const other = new CreateAssetCommand('composition', 'Other'); + film.run(other); + const id = film.drop(0); + const clip = copyObjects(film.project, film.comp(), [id])!; + const pasted = pasteCompositionItems(film.project, other.asset.id, clip, 0)!; + film.run(pasted.command); + const mine = film.wornBy(film.comp(), id); + const theirs = film.wornBy(film.comp(other.asset.id), pasted.itemIds[0]!); + expect(theirs).not.toBe(mine); + expect(findAsset(film.project, mine)!.owner).toBe(film.filmId); + expect(findAsset(film.project, theirs)).toMatchObject({ + owner: other.asset.id, + props: { texture: film.videoId }, + }); + film.stack.undo(); + expect(film.sprites().map((s) => s.id)).toEqual([mine]); + }); +}); + +describe('the composition', () => { + it('duplicated, keeps sprites of its own', () => { + const film = makeFilm(); + film.drop(0); + const duplicate = new DuplicateAssetCommand(film.filmId); + film.run(duplicate); + const copy = film.comp(duplicate.copyId); + const worn = copy.objects[0]!.assetId; + expect(worn).not.toBe(film.comp().objects[0]!.assetId); + expect(findAsset(film.project, worn)!.owner).toBe(duplicate.copyId); + }); + + it('deleted, takes its sprites along', () => { + const film = makeFilm(); + film.drop(0); + film.run(new DeleteAssetCommand(film.filmId)); + expect(film.sprites()).toHaveLength(0); + }); + + it('saved and loaded, still keeps its sprites; one whose composition is gone is a project asset', () => { + const film = makeFilm(); + film.drop(0); + const sprite = film.sprites()[0]!; + const raw = serializeProject(film.project) as { assets: { id: string; owner?: string }[] }; + expect(findAsset(deserializeProject(structuredClone(raw)), sprite.id)!.owner).toBe(film.filmId); + raw.assets = raw.assets.filter((a) => a.id !== film.filmId); + expect(findAsset(deserializeProject(raw), sprite.id)!.owner).toBeUndefined(); + }); +}); diff --git a/tools/e2e/69-composition-tab.mjs b/tools/e2e/69-composition-tab.mjs index d189409e..e19b2ce6 100644 --- a/tools/e2e/69-composition-tab.mjs +++ b/tools/e2e/69-composition-tab.mjs @@ -5,7 +5,8 @@ * past it. 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. The video of an item about * to begin is opened ahead, so the cut to it shows its picture at once, and Play waits for a video - * whose player is still loading. A copied item is pasted under the pointer or at the playhead. + * whose player is still loading. A copied item is pasted under the pointer or at the playhead. A + * video dragged in wears a sprite the composition keeps to itself. * * Everything it adds carries a `ct69` id and goes again at the end. */ @@ -308,8 +309,47 @@ export async function compositionTab(t) { JSON.stringify({ copies, picked: pickedNow?.objectIds }), ); + // 69k. A video dragged straight onto a track (#419) is an item as long as the video, wearing a sprite + // the composition keeps and the Assets panel leaves out; a second drop shares it, and undoing both + // takes the sprite along. + const kept = (id) => + json(`(() => { + const p = __gm.state.project; + const c = p.assets.find((a) => a.id === '${CUT}'); + const object = c.objects.find((o) => o.id === ${JSON.stringify(id)}); + const sprite = object && p.assets.find((a) => a.id === object.assetId); + return { item: c.items.find((i) => i.id === ${JSON.stringify(id)}) ?? null, + sprite: sprite ? { id: sprite.id, type: sprite.type, owner: sprite.owner, texture: sprite.props.texture } : null, + owned: p.assets.filter((a) => a.owner === '${CUT}').length }; + })()`); + const videoDrop = await dragAsset(VIDEO, await json(`__gm.composition.laneAt('ct69-t2', 70)`)); + const one = await kept((await json('__gm.state.selection'))?.objectIds?.[0] ?? ''); + const row = one.sprite ? await json(`__gm.assetRow('${one.sprite.id}')`) : 'none'; + await dragAsset(VIDEO, await json(`__gm.composition.laneAt('ct69-t2', 100)`)); + const two = await kept((await json('__gm.state.selection'))?.objectIds?.[0] ?? ''); + await evaluate('__gm.state.undo()'); + await evaluate('__gm.state.undo()'); + const undone = await kept(''); + check( + 'a video dropped on a track wears a sprite the composition keeps, shared by a second drop', + videoDrop?.what === 'item' && + videoDrop.rejection === '' && + one.item?.track === 'ct69-t2' && + one.item.start === 70 && + one.item.length === 20 && + one.sprite?.type === 'sprite' && + one.sprite.owner === CUT && + one.sprite.texture === VIDEO && + row === null && + two.item?.start === 100 && + two.sprite?.id === one.sprite.id && + two.owned === 1 && + undone.owned === 0, + JSON.stringify({ videoDrop, one, row, two, undone }), + ); + await showTab('scene'); await mutate(` const p = __gm.state.project; - p.assets = p.assets.filter((a) => !a.id.startsWith('ct69-'));`); + p.assets = p.assets.filter((a) => !a.id.startsWith('ct69-') && !a.owner?.startsWith('ct69-'));`); }