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>
248 lines
10 KiB
TypeScript
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 };
|
|
}
|