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:
1 parent
f171c77f91
commit
b16fe8c718
18 files changed
+573
-49
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user