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

+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)) {