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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-12 18:48:20 +02:00
1 parent f171c77f91
commit b16fe8c718
18 files changed
+573 -49

No files matched your search

+198
View File
@@ -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<CompositionItem, 'id'>,
): { 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<string>;
}
/**
* 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<Asset>[] = [];
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);
}
}
+9 -1
View File
@@ -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<string, string> {
export class PasteObjectsCommand implements Command {
readonly label: string;
private readonly ids: Map<string, string>;
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));
}
}
+4 -1
View File
@@ -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;
}
+7 -1
View File
@@ -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 {
+7
View File
@@ -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}` };
}
+6 -2
View File
@@ -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;
+7 -8
View File
@@ -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. */
+7 -4
View File
@@ -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)) {