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

+29 -3
View File
@@ -1,10 +1,17 @@
import { MakeProjectAssetCommand } from '../../core/commands/assetCommands';
import { removeFromComposition, SetCompositionCommand } from '../../core/commands/compositionCommands';
import type { CompositionAsset, CompositionItem } from '../../core/model/Asset';
import { clipLength, compositionFps, fittedLength, itemVideo } from '../../core/model/composition';
import {
clipLength,
compositionFps,
fittedLength,
isCompositionSprite,
itemVideo,
} from '../../core/model/composition';
import { resizedItem } from '../../core/model/compositionEdit';
import { findAssetOf, type Project } from '../../core/model/Project';
import { findAsset, findAssetOf, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, formatNumber } from '../../ui';
import { Button, formatNumber, type MenuEntry } from '../../ui';
import { createFieldRow } from './fieldFactory';
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
import { assetLink } from './objectSection';
@@ -215,6 +222,25 @@ export function fitToMedia(host: Pick<InspectorHost, 'state'>, holderId: string,
host.state.execute(new SetCompositionCommand(holderId, { items }, 'Fit to media'));
}
/**
* For an item wearing a sprite its composition keeps (#419): that sprite turned into a project asset,
* listed and placeable anywhere. Greyed out for any other.
*/
export function makeProjectAssetEntry(
host: Pick<InspectorHost, 'state'>,
holderId: string,
objectId: string,
): MenuEntry {
const { project } = host.state;
const object = findAssetOf(project, holderId, 'composition')?.objects.find((o) => o.id === objectId);
const asset = object && findAsset(project, object.assetId);
return {
label: 'Make project asset',
disabled: !asset || !isCompositionSprite(asset),
onSelect: () => asset && host.state.execute(new MakeProjectAssetCommand(asset.id)),
};
}
function withOffset(item: CompositionItem, offset: number): CompositionItem {
const { fitMedia: _, ...rest } = item;
return { ...rest, offset: Number.isFinite(offset) ? offset : 0 };
+3 -2
View File
@@ -30,6 +30,7 @@ import { type ObjectChoice, objectChoices } from '../../core/model/objectRefs';
import { type PropertyValue, textOf } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
import { objectName, type SceneObject } from '../../core/model/SceneObject';
import { markedName } from '../../core/music/embedded';
import {
COMMON_PROPS,
isCommonProp,
@@ -322,7 +323,7 @@ const PART_OF_ROW = '#part-of';
export function assetLink(host: InspectorHost, key: string, text: string, asset: Asset): void {
const { row, label } = labelledRow(text);
linkLabel(host, label)(asset.id);
const name = new TextField(asset.name, { readOnly: true });
const name = new TextField(markedName(asset), { readOnly: true });
row.add(name, 1);
host.body.add(row);
host.fields.set(key, { row, setValue: (v) => name.setValue(textOf(v)), setModified: () => {} });
@@ -361,7 +362,7 @@ export function refreshObjectSection(host: InspectorHost, holderId: string, obje
const { object, asset } = shown;
host.nameField?.setValue(object.name);
// A rename of the asset changes no structure, so it arrives here rather than as a rebuild.
host.fields.get(ASSET_ROW)?.setValue(asset.name);
host.fields.get(ASSET_ROW)?.setValue(markedName(asset));
const target = targetOf(project, holderId, objectId);
if (target) host.fields.get(PART_OF_ROW)?.setValue(target.asset.name);
refreshTimingRows(host, holderId, objectId);