/** * 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 { findPixelFrame, pixelArtWornId } from '../model/pixelart'; import type { PropertyBag } from '../model/Properties'; import { findAsset, objectHolders, type Project } from '../model/Project'; import { findFrame } from '../model/texture'; import { findVideo } from '../model/video'; 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'; import { SetOverrideCommand } from './sceneObjectCommands'; /** * 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; } /** What an item dropped on a track wears: the picture its sprite is made of, and a part (#425, #430). */ export interface DroppedPicture { picture: string; /** * The clip of a video the item plays or the frame it shows, as an override on its object; null for * what its sprite wears. */ part: string | null; /** The name a new sprite for the picture gets. */ name: string; } /** * What an asset, a clip of a video (#425) or a frame of a picture (#430) dragged onto a track wears. A * part shares the sprite of its asset, so one composition keeps one sprite per video or picture. Null * for anything that is no picture. */ export function droppedPicture(project: Project, id: string): DroppedPicture | null { const asset = findAsset(project, id); if (asset) { const picture = wornPicture(asset); return picture === null ? null : { picture, part: null, name: asset.name }; } const video = findVideo(project, id); if (video?.clip) return { picture: video.asset.id, part: video.clip.id, name: video.asset.name }; const frame = findFrame(project, id); if (frame) return { picture: frame.asset.id, part: id, name: frame.asset.name }; const pixel = findPixelFrame(project, id); if (!pixel) return null; // Its first frame is what the sprite of a sequence wears already. const picture = pixelArtWornId(pixel.asset); return { picture, part: id === picture ? null : id, name: pixel.asset.name }; } /** 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, a video or a part of one 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. A part is * the texture of the item's object. */ export function addPictureItem( project: Project, compId: string, dropped: DroppedPicture, item: Omit, ): { command: Command; objectId: string } { const kept = keptSprite(project, compId, dropped.picture); const sprite = kept ?? newCompositionSprite(project, compId, dropped.picture, dropped.name); const { command, objectId } = addCompositionItem(project, compId, sprite.id, item); const steps = [ ...(kept ? [] : [new AddSpriteCommand(sprite)]), command, ...(dropped.part ? [new SetOverrideCommand(compId, objectId, 'texture', dropped.part)] : []), ]; return { command: steps.length === 1 ? command : oneStep('Add item', steps), 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. */ 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); } }