Files
feuerwerk/src/editor/inspector/compositionSection.ts
T
adminandClaude Opus 5.5 b16fe8c718 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>
2026-10-12 18:48:20 +02:00

248 lines
10 KiB
TypeScript

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,
isCompositionSprite,
itemVideo,
} from '../../core/model/composition';
import { resizedItem } from '../../core/model/compositionEdit';
import { findAsset, findAssetOf, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, formatNumber, type MenuEntry } from '../../ui';
import { createFieldRow } from './fieldFactory';
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
import { assetLink } from './objectSection';
const START_ROW = 'composition:start';
const LENGTH_ROW = 'composition:length';
const OFFSET_ROW = 'composition:offset';
const CLIP_START_ROW = 'composition:clipStart';
const ANIMATION_ROW = 'composition:animation';
const seconds = (key: string, label: string, negative = false): PropertyDef => ({
key,
label,
kind: 'number',
default: 0,
...(negative ? {} : { min: 0 }),
step: 0.1,
decimals: 3,
});
/** Structure-defining facts of a track or clip selection: which it is, and whether it is still there. */
export function compositionSignature(
project: Project,
assetId: string,
trackId: string,
clipIds: string[],
): string {
const comp = findAssetOf(project, assetId, 'composition');
if (!comp?.tracks.some((t) => t.id === trackId)) return 'missing';
return clipIds.length > 0 ? `clips:${clipIds.join(',')}` : 'track';
}
/** A track: its name; the timeline's menu adds, moves and deletes tracks. Clips: what they play, from when, and the way to remove them. */
export function buildCompositionSection(
host: InspectorHost,
assetId: string,
trackId: string,
clipIds: string[],
): void {
const comp = findAssetOf(host.state.project, assetId, 'composition');
const track = comp?.tracks.find((t) => t.id === trackId);
if (!comp || !track) return;
if (clipIds.length === 0) {
addTitle(host, 'TRACK');
addNameField(host, track.name, {
onChange: (name) => {
const tracks = comp.tracks.map((t) => (t.id === trackId ? { ...t, name: name.trim() } : t));
if (name.trim()) host.state.execute(new SetCompositionCommand(assetId, { tracks }, 'Rename track'));
else host.nameField?.setValue(track.name);
},
});
addNote(host, 'An upper track lies in front of the ones below it.');
return;
}
const clip = clipIds.length === 1 ? comp.clips.find((c) => c.id === clipIds[0]) : undefined;
if (clip) {
addTitle(host, 'ANIMATION CLIP');
const animation = findAssetOf(host.state.project, clip.animation, 'animation');
if (animation) assetLink(host, ANIMATION_ROW, 'Animation', animation);
else addNote(host, 'Its animation is gone.', 'warning');
const fps = compositionFps(comp);
const row = createFieldRow(host, seconds(CLIP_START_ROW, 'Start'), clip.start / fps, {
onInput: () => undefined,
onChange: (value) => {
const start = Math.max(0, Math.round(Number(value) * fps));
const current = findAssetOf(host.state.project, assetId, 'composition');
if (!current) return;
const clips = current.clips.map((c) => (c.id === clip.id ? { ...c, start } : c));
if (SetCompositionCommand.changes(host.state.project, assetId, { clips }))
host.state.execute(new SetCompositionCommand(assetId, { clips }, 'Move clip'));
},
});
host.fields.set(CLIP_START_ROW, row);
host.body.add(row.row);
const length = clipLength(host.state.project, comp, clip);
addNote(
host,
`One pass: ${formatNumber(length / fps, 2)} s. It plays on every item of its track it overlaps.`,
);
} else addTitle(host, `${clipIds.length} ANIMATION CLIPS`);
addGap(host);
host.body.add(
new Button(clipIds.length === 1 ? 'Remove clip' : 'Remove clips', {
variant: 'danger',
onClick: () => {
if (host.state.execute(removeFromComposition(host.state.project, assetId, [], clipIds)))
host.state.select({ kind: 'asset', assetId });
},
}),
);
}
export function refreshCompositionSection(
host: InspectorHost,
assetId: string,
trackId: string,
clipIds: string[],
): boolean {
const comp = findAssetOf(host.state.project, assetId, 'composition');
const track = comp?.tracks.find((t) => t.id === trackId);
if (!comp || !track) return false;
if (clipIds.length === 0) host.nameField?.setValue(track.name);
const clip = clipIds.length === 1 ? comp.clips.find((c) => c.id === clipIds[0]) : undefined;
if (clip) host.fields.get(CLIP_START_ROW)?.setValue(clip.start / compositionFps(comp));
return true;
}
/** The item an object of a composition stands for; undefined for any other object. */
export function compositionItemOf(
project: Project,
holderId: string,
objectId: string,
): { comp: CompositionAsset; item: CompositionItem } | undefined {
const comp = findAssetOf(project, holderId, 'composition');
const item = comp?.items.find((i) => i.id === objectId);
return comp && item ? { comp, item } : undefined;
}
/**
* When the object's item lives in the composition, and how far into its video it begins (#417). A
* length typed in or an edge moved is the item's own from then on; "Fit to media" hands it back.
*/
export function addTimingRows(host: InspectorHost, holderId: string, objectId: string): void {
const found = compositionItemOf(host.state.project, holderId, objectId);
if (!found) return;
const { comp, item } = found;
const fps = compositionFps(comp);
const video = itemVideo(host.state.project, comp, item) !== null;
addTitle(host, 'TIMING');
const write = (label: string, edit: (current: CompositionItem) => CompositionItem) => {
const current = compositionItemOf(host.state.project, holderId, objectId);
if (!current) return;
const items = current.comp.items.map((i) => (i.id === objectId ? edit(i) : i));
if (SetCompositionCommand.changes(host.state.project, holderId, { items }))
host.state.execute(new SetCompositionCommand(holderId, { items }, label));
};
const frames = (value: unknown) => Math.round(Number(value) * fps);
const rows: [string, string, number, (value: unknown) => void, boolean?][] = [
[
START_ROW,
'Start',
item.start / fps,
(value) => write('Move item', (i) => ({ ...i, start: Math.max(0, frames(value)) })),
],
[
LENGTH_ROW,
'Length',
item.length / fps,
(value) => write('Resize item', (i) => resizedItem(i, frames(value))),
],
];
if (video)
rows.push([
OFFSET_ROW,
'Offset',
item.offset,
(value) => write('Set item offset', (i) => withOffset(i, Number(value))),
true,
]);
// An offset below 0 starts the item before its video.
for (const [key, label, value, onChange, negative] of rows) {
const row = createFieldRow(host, seconds(key, label, negative), value, {
onInput: () => undefined,
onChange,
});
host.fields.set(key, row);
host.body.add(row.row);
}
addNote(
host,
item.fitMedia
? video
? 'Its length follows its video.'
: 'Its length follows a video it is given, 3 s while it has none.'
: 'Its length is its own.',
'small',
);
const fit = new Button('Fit to media', { onClick: () => fitToMedia(host, holderId, objectId) });
fit.setEnabled(!(item.fitMedia === true && item.length === fittedLength(host.state.project, comp, item)));
host.body.add(fit);
}
export function refreshTimingRows(host: InspectorHost, holderId: string, objectId: string): void {
const found = compositionItemOf(host.state.project, holderId, objectId);
if (!found) return;
const fps = compositionFps(found.comp);
host.fields.get(START_ROW)?.setValue(found.item.start / fps);
host.fields.get(LENGTH_ROW)?.setValue(found.item.length / fps);
host.fields.get(OFFSET_ROW)?.setValue(found.item.offset);
}
/** What shapes the timing rows: whether there is a video, and whether the length still follows it. */
export function timingSignature(project: Project, holderId: string, objectId: string): string {
const found = compositionItemOf(project, holderId, objectId);
if (!found) return '';
const video = itemVideo(project, found.comp, found.item) !== null;
const fitted = found.item.length === fittedLength(project, found.comp, found.item);
return `|timing:${video}:${found.item.fitMedia === true}:${fitted}`;
}
/** The item's length taken from its video again, and following it from then on (#417). */
export function fitToMedia(host: Pick<InspectorHost, 'state'>, holderId: string, objectId: string): void {
const found = compositionItemOf(host.state.project, holderId, objectId);
if (!found) return;
const length = fittedLength(host.state.project, found.comp, found.item);
const items = found.comp.items.map((i) => (i.id === objectId ? { ...i, length, fitMedia: true } : i));
if (SetCompositionCommand.changes(host.state.project, holderId, { items }))
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 };
}