A double click on a clip row did not open its tab. The first click of the double click had already selected the clip for the inspector, so the second click selected the same thing again and the editor saw no change. Frames of textures and pixel art had no rows at all. - A double click now asks for the tab with openSelection, even when the selection is already right. - Textures with frames and pixel art with more than one frame get a chevron and one row per frame, like sounds and videos. Which rows an asset has is decided in one place, src/core/model/assetParts.ts, used by the panel and the inspector link. - A texture frame shows in the inspector on click and opens the texture with the frame selected on double click. - A pixel art frame selects the pixel art on click, so an open tab paints on that frame. A double click opens the tab on the frame. - A frame can be dragged onto a composition track like a video clip. The item carries the sprite of the whole image, the frame becomes the texture of its object, and it is 3 s long. The agent can do the same with add_composition_item. - A texture label that names a frame now points at the frame row. Found on the way: the e2e check from #425 only double clicked the texture label, where the first click is a ping, so the bug never showed there. Tests: - Unit: asset-parts (new, 6), composition-sprites and references extended, related tests 303/303, agent tests 21/21 - 5 mutations, all red in the matching test - e2e 69 (atx): ALL PASSED, 26 checks, 69t new - Gate (atx): CHECK EXIT 0, 325 files, 5017 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
9.3 KiB
TypeScript
241 lines
9.3 KiB
TypeScript
/**
|
|
* 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<CompositionItem, 'id'>,
|
|
): { 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<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.
|
|
*/
|
|
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);
|
|
}
|
|
}
|