Play sounds, patterns and songs on the tracks of a composition (#418)
feuerwerk / gate (push) Successful in 4m31s
feuerwerk / suite (push) Successful in 18m21s

A trailer needs its music and effects on the same timeline as its
pictures. Until now a composition could only place objects, so sound
had to be started by hand from a behavior and drifted against the
picture.

- Audio items: a sound, a clip of a sound, a pattern or a song dragged
  from the asset panel onto a track becomes an item as long as one
  pass of it. It may share a track with object and video items and
  overlap them.
- Editing works as for other items: move, resize, trim on the left
  (moves the offset), fit to media, delete, copy and paste, box
  selection. Sounds draw their waveform, the colour tells the kind.
- The inspector shows what it plays, start, length, offset, volume
  (-60 to +12 dB) and bus. The bus is "From asset", the master or one
  of the buses, as asked for in the issue.
- An item longer than its material plays in rounds. Sounds and
  patterns start over, a song runs on in its loop. Loop marks on the
  item follow the same rule, a song's first mark sits at its loop end.
- compositionAudio.ts turns the items into sequences, each with its
  own tempo. CompositionSound plays them, in the tab and in a game.
- In the tab the items are heard from the playhead on, straight into
  the master like every tab. A scrub is silent. Playing a song or
  pattern in its own tab stops the composition, and the other way
  round.
- In a game they are planned on the audio clock for the moment the
  composition clock reaches them, with the scheduler's LEAD made up.
  While the clock stands (waiting for a video, paused, placement off)
  they are silent and resume at the right spot.

Found along the way:

- The asset panel never let a song out of its list, so a song could
  not be dragged anywhere. Song and scene tab reject it as they reject
  a pattern.
- The asset panel scrolled to the selected asset on every rebuild of
  its list, so folding a group far below the selection jumped back up.
  Only a new selection scrolls now.
- At the end of the list, folding a group pulled every row down by the
  rows that went away. ScrollContainer has a new opt-in holdOffset,
  set only in the asset panel, that keeps the offset until the user
  scrolls up.

Limits: a time scale other than 1 or a frame longer than 0.1 s moves
the picture and not the sound. A baked song plays its notes here, not
the bake.

Tests:
- tsc, eslint, Prettier clean. Gate on melektaus-atx green,
  309 files, 4788 tests (4772 plus 16 new).
- Unit: composition-audio.test.ts 13 new, scroll-container 3 new.
  Counter-checks: without catching up on load time or without the
  silence on pause the unit tests go red.
- e2e 68h: the sound is planned 1.2 ms after the composition reaches
  0.5 s. Without the LEAD make-up it is 54 ms late and red.
- e2e 04 and 69 ALL PASSED, 86 checks. 69o-ii drags a pattern and a
  song onto a track, 20 frames each.
- e2e 04: folding Sound at the end of the list keeps its header at
  y 234. With the old panel it jumps to 984 and the check is red.
- Not run in a browser: the loop marks of a song item, copy and paste
  of audio items, the bus choice.
- README and wiki count 2072 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-13 21:28:36 +02:00
1 parent 30b618d9a9
commit 77ac9d1aaf
30 files changed
+1571 -101

No files matched your search

+1
View File
@@ -1420,6 +1420,7 @@ export class Editor implements PlayHost {
this.console.refresh();
if (this.shows('animation')) this.animationEditor.tick(step);
if (this.shows('composition')) this.compositionEditor.tick(dt);
else this.compositionEditor.hidden();
if (this.shows('tile')) this.tileEditor.tick(step);
// The preview is the editor's own, so it runs on while the game is paused.
if (this.shows('curve')) this.curveEditor.tick(dt);
+3 -3
View File
@@ -238,11 +238,11 @@ export function removeSelection(state: EditorState): boolean {
else removeTrack(state, s.assetId, s.trackId);
return true;
case 'composition':
// The clips when some are picked, else the track and what lies on it.
// The items or clips when some are picked, else the track and what lies on it.
if (
state.execute(
s.clipIds.length > 0
? removeFromComposition(state.project, s.assetId, [], s.clipIds)
s.clipIds.length > 0 || (s.itemIds ?? []).length > 0
? removeFromComposition(state.project, s.assetId, s.itemIds ?? [], s.clipIds)
: removeCompositionTrack(state.project, s.assetId, s.trackId),
)
)
+25 -6
View File
@@ -60,12 +60,31 @@ export function copyObjectIds(state: EditorState, holderId: string, ids: readonl
return put(state, clip, roots, counted(roots.length, clip.items ? 'item' : 'object'));
}
/** Copies animation clips of a composition; false when none of them is there. */
export function copyCompositionClips(state: EditorState, assetId: string, ids: readonly string[]): boolean {
/**
* Copies animation clips or items of a composition: with an item that is an object among them as
* objects, the audio items (#418) going along; false when none of them is there.
*/
export function copyCompositionClips(
state: EditorState,
assetId: string,
ids: readonly string[],
itemIds: readonly string[] = [],
): boolean {
const asset = findAssetOf(state.project, assetId, 'composition');
const clips = asset?.clips.filter((c) => ids.includes(c.id)).map((c) => ({ ...c })) ?? [];
if (clips.length === 0) return false;
return put(state, { kind: 'compositionClips', clips }, [], counted(clips.length, 'clip'));
if (!asset) return false;
const picked = asset.items.filter((i) => itemIds.includes(i.id));
const audio = picked.filter((i) => i.audio).map((i) => structuredClone(i));
const objects = picked.filter((i) => !i.audio).map((i) => i.id);
if (objects.length > 0) {
const clip = copyObjects(state.project, asset, objects);
if (!clip) return false;
const roots = clip.roots.map((r) => r.id);
return put(state, { ...clip, audio }, roots, counted(roots.length + audio.length, 'item'));
}
const clips = asset.clips.filter((c) => ids.includes(c.id)).map((c) => ({ ...c }));
if (clips.length === 0 && audio.length === 0) return false;
const noun = audio.length > 0 ? counted(audio.length, 'item') : counted(clips.length, 'clip');
return put(state, { kind: 'compositionClips', clips, items: audio }, [], noun);
}
/**
@@ -75,7 +94,7 @@ export function copyCompositionClips(state: EditorState, assetId: string, ids: r
export function copySelection(state: EditorState): boolean {
const s = state.selection;
if (s?.kind === 'object') return copyObjectIds(state, s.holderId, s.objectIds);
if (s?.kind === 'composition') return copyCompositionClips(state, s.assetId, s.clipIds);
if (s?.kind === 'composition') return copyCompositionClips(state, s.assetId, s.clipIds, s.itemIds);
if (s?.kind === 'node' || s?.kind === 'annotation') {
const graph = findGraphAsset(state.project, s.assetId)?.graph;
const nodeIds = s.kind === 'node' ? s.nodeIds : [];
+42 -2
View File
@@ -1,8 +1,11 @@
import type { Asset, CompositionAsset } from '../../core/model/Asset';
import { compositionFps, compositionLength } from '../../core/model/composition';
import { VIDEO_WAIT_MS } from '../../core/model/video';
import { CompositionSound } from '../../runtime/compositionSound';
import { Button, formatNumber, VStack, type Widget } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { AssetTabEditor } from '../AssetTabEditor';
import { claimTransport, releaseTransport } from '../music/transport';
import type { EditorState } from '../state';
import { CompositionCanvas } from './CompositionCanvas';
import { CompositionTimeline } from './CompositionTimeline';
@@ -21,6 +24,12 @@ export class CompositionEditor extends AssetTabEditor<CompositionAsset, Composit
readonly playButton: Button;
readonly preview: CompositionCanvas;
/** Its audio items (#418), heard while the playhead runs, never while it is scrubbed. Public for the check scripts. */
readonly sound = new CompositionSound(soundLibrary);
/** Set by an edit: what the composition plays may have changed under the sound. */
private edited = false;
private readonly halt = () => this.canvas.pause();
constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH });
this.addTrackButton = new Button('+ Track', { onClick: () => this.canvas.addTrack() });
@@ -31,7 +40,15 @@ export class CompositionEditor extends AssetTabEditor<CompositionAsset, Composit
this.preview.redraw();
this.followLiveItems();
};
timeline.onPlaying = () => this.syncPlayButton();
timeline.onPlaying = () => {
this.syncPlayButton();
if (timeline.isPlaying) claimTransport(this.halt);
else {
releaseTransport(this.halt);
this.sound.stop();
}
};
state.on('project', () => (this.edited = true));
this.mount(timeline, [this.addTrackButton, this.playButton]);
}
@@ -88,10 +105,33 @@ export class CompositionEditor extends AssetTabEditor<CompositionAsset, Composit
/** Only called while the tab is visible. */
tick(dt: number): void {
const drawn = this.preview.redrawCount;
if (this.canvas.isPlaying && !this.waitsForVideo()) this.canvas.tick(dt);
const runs = this.canvas.isPlaying && !this.waitsForVideo();
if (runs) this.canvas.tick(dt);
this.hear(runs);
this.preview.animate(drawn);
}
/** Called instead of `tick` while the tab is not seen: its playhead stands, and so does its sound. */
hidden(): void {
this.sound.stop();
}
/**
* The sound follows the playhead while it runs, straight into the master as every tab plays; an
* edit that changes what it plays starts it again there.
*/
private hear(runs: boolean): void {
const asset = this.canvas.isPlaying ? this.asset() : null;
if (asset && runs && this.edited && this.sound.changed(this.state.project, asset, false))
this.sound.stop();
this.edited = false;
if (!asset || !runs) {
this.sound.stop();
return;
}
this.sound.start(this.state.project, asset, this.canvas.playhead / compositionFps(asset), false);
}
/** Until when the playhead waits for a video it shows, see `waitsForVideo`. */
private videoWait: number | null = null;
+211 -26
View File
@@ -16,16 +16,24 @@ import {
import { addPictureItem, wornPicture } from '../../core/commands/compositionSprites';
import { newId } from '../../core/ids';
import { isPlaceableType } from '../../core/model/AssetTypes';
import type { CompositionAsset, CompositionClip, CompositionItem } from '../../core/model/Asset';
import type {
CompositionAsset,
CompositionAudioKind,
CompositionClip,
CompositionItem,
} from '../../core/model/Asset';
import {
clipLength,
compositionFps,
compositionLength,
compositionLoops,
fittedLength,
itemObject,
itemStands,
itemVideo,
} from '../../core/model/composition';
import {
audioKindOf,
clampedMove,
clipSpans,
droppedLength,
@@ -38,6 +46,7 @@ import {
movedItems,
movedWithClips,
pastedClips,
pastedItemsWithClips,
resizedItem,
snappedToMedia,
trimmedItem,
@@ -46,6 +55,9 @@ import { placementRejection } from '../../core/model/placement';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset, findAssetOf } from '../../core/model/Project';
import { objectName } from '../../core/model/SceneObject';
import { findSound } from '../../core/model/sound';
import { assetRefLabel } from '../../core/properties/assetRef';
import { playedSoundById } from '../../core/properties/playedSound';
import {
activeDrag,
crispText,
@@ -62,6 +74,7 @@ import {
zoomFactor,
} from '../../ui';
import { fitToMedia, makeProjectAssetEntry } from '../inspector/compositionSection';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { clampTimePan, tickToX, xToTick, zoomTime } from '../music/timeView';
import {
type Box,
@@ -69,6 +82,7 @@ import {
EDGE_REACH,
fitSong,
nearestTrack,
peakOver,
trackAtY,
trackTop,
zoomTracks,
@@ -100,6 +114,12 @@ const ITEM_COLOR = 0x35598f;
const VIDEO_COLOR = 0x2f6f52;
const CLIP_COLOR = 0x8a5a2b;
const REJECTED_COLOR = 0x6b3a3a;
/** Audio items (#418), by what they play. */
const AUDIO_COLORS: Record<CompositionAudioKind, number> = {
sound: 0x2f6a78,
pattern: 0x5b4a8f,
song: 0x7a3f6a,
};
const DEFAULT_VIEW: CompositionView = { panX: 0, pxPerTick: 2, panY: 0, trackHeight: TRACK_HEIGHT };
/** Room at the top of an item for its name; marks of its video keep below it. */
const LABEL_STRIP = 15;
@@ -182,6 +202,8 @@ export class CompositionTimeline extends Widget {
private hover: { id: string; part: Part } | null = null;
/** Where the pointer was last, in local coordinates: a paste lands there while it is over the lanes. */
private pointer: Vec2 | null = null;
/** Sound files whose waveform is on its way. */
private readonly loading = new Set<string>();
private needsFit = true;
/** Where the playhead stands, in frames – fractional while playing. */
playhead = 0;
@@ -338,10 +360,38 @@ export class CompositionTimeline extends Widget {
const picked = this.pickedTrack(asset);
const row = spot?.row ?? (picked >= 0 ? picked : undefined);
const at = spot?.frame ?? Math.floor(this.playhead + 1e-6);
if (clip.kind === 'compositionClips' && (clip.items ?? []).length > 0)
return this.pasteAudio(asset, clip.items!, clip.clips, at, row);
if (clip.kind === 'compositionClips') return this.pasteClips(asset, clip.clips, at, row);
const pasted = pasteCompositionItems(this.state.project, asset.id, clip, at, row);
if (!pasted || !this.state.execute(pasted.command)) return false;
this.state.select(objectSelection(asset.id, pasted.itemIds));
this.pickItems(pasted.itemIds);
return true;
}
/** Copied audio items (#418), and clips copied with them, put in as items are. */
private pasteAudio(
asset: CompositionAsset,
copied: readonly CompositionItem[],
copiedClips: readonly CompositionClip[],
at: number,
row: number | undefined,
): boolean {
const ids = new Map([...copied, ...copiedClips].map((c) => [c.id, newId()]));
const { items, clips } = pastedItemsWithClips(
copied,
copiedClips,
asset.clips,
ids,
at,
asset.tracks.map((t) => t.id),
this.lengthOf(asset),
row,
);
const label = items.length === 1 ? 'Paste item' : 'Paste items';
const parts = { items: [...asset.items, ...items], clips: [...asset.clips, ...clips] };
if (!this.state.execute(new SetCompositionCommand(asset.id, parts, label))) return false;
this.pickItems(items.map((i) => i.id));
return true;
}
@@ -463,18 +513,39 @@ export class CompositionTimeline extends Widget {
return (clip) => Math.max(1, clipLength(this.state.project, asset, clip));
}
/** The items of the composition there is an object for: one whose object went is not on the tracks. */
/** The items of the composition on its tracks: one whose object went is not. */
private liveItems(asset: CompositionAsset): CompositionItem[] {
return asset.items.filter((i) => itemObject(asset, i));
return asset.items.filter((i) => itemStands(asset, i));
}
private pickedItems(asset: CompositionAsset): string[] {
const s = this.state.selection;
if (s?.kind === 'composition' && s.assetId === asset.id) return s.itemIds ?? [];
return s?.kind === 'object' && s.holderId === asset.id
? s.objectIds.filter((id) => asset.items.some((i) => i.id === id))
: [];
}
/**
* Picks items: as objects while they all are – the hierarchy and the inspector show them –, on the
* composition once an audio item is among them (#418); the composition itself for none.
*/
private pickItems(ids: readonly string[]): void {
const asset = this.asset();
if (!asset) return;
const items = ids.flatMap((id) => asset.items.find((i) => i.id === id) ?? []);
if (items.length === 0) this.state.select({ kind: 'asset', assetId: asset.id });
else if (!items.some((i) => i.audio)) this.state.select(objectSelection(asset.id, [...ids]));
else
this.state.select({
kind: 'composition',
assetId: asset.id,
trackId: items[0]!.track,
clipIds: [],
itemIds: items.map((i) => i.id),
});
}
private pickedClips(asset: CompositionAsset): string[] {
const s = this.state.selection;
return s?.kind === 'composition' && s.assetId === asset.id ? s.clipIds : [];
@@ -551,11 +622,10 @@ export class CompositionTimeline extends Widget {
const item = asset.items.find((i) => i.id === hit.id)!;
const picked = this.pickedItems(asset);
if (toggle) {
this.state.toggleObject(asset.id, item.id);
this.pickItems(picked.includes(item.id) ? picked.filter((id) => id !== item.id) : [...picked, item.id]);
return;
}
if (!picked.includes(item.id) || hit.part !== 'body')
this.state.select(objectSelection(asset.id, [item.id]));
if (!picked.includes(item.id) || hit.part !== 'body') this.pickItems([item.id]);
const media = itemMedia(this.state.project, asset, item);
if (hit.part === 'end') this.gesture = { kind: 'resize', anchor: item, item, media };
else if (hit.part === 'start') this.gesture = { kind: 'trim', anchor: item, item, media };
@@ -653,7 +723,9 @@ export class CompositionTimeline extends Widget {
gesture.anchor,
this.snapped(frame, gesture.media),
compositionFps(asset),
gesture.media !== null || itemVideo(this.state.project, asset, gesture.anchor) !== null,
gesture.media !== null ||
gesture.anchor.audio !== undefined ||
itemVideo(this.state.project, asset, gesture.anchor) !== null,
);
break;
}
@@ -743,7 +815,7 @@ export class CompositionTimeline extends Widget {
break;
case 'move': {
if (!gesture.moved) {
if (gesture.what === 'item') this.state.select(objectSelection(asset.id, [gesture.anchor.id]));
if (gesture.what === 'item') this.pickItems([gesture.anchor.id]);
break;
}
const parts = this.movedParts(asset, gesture, gesture.frames, gesture.tracks);
@@ -788,9 +860,7 @@ export class CompositionTimeline extends Widget {
const caught = band.active
? itemsInBand(this.view, asset.tracks, this.liveItems(asset), band.start, band.current)
: [];
const ids = [...new Set([...(band.add ? this.pickedItems(asset) : []), ...caught])];
if (ids.length > 0) this.state.select(objectSelection(asset.id, ids));
else this.state.select({ kind: 'asset', assetId: asset.id });
this.pickItems([...new Set([...(band.add ? this.pickedItems(asset) : []), ...caught])]);
}
private commit(
@@ -804,17 +874,21 @@ export class CompositionTimeline extends Widget {
/**
* Follows an asset dragged in from the panel: a placeable one, a picture or a video (#419) lands as
* an item under the pointer, as long as its media or 3 s, over whatever lies there; an animation as
* a clip one pass long, where it fits whole.
* an item under the pointer, as long as its media or 3 s, over whatever lies there; a sound, a clip
* of one, a pattern or a song as an audio item as long as one pass of it (#418); an animation as a
* clip one pass long, where it fits whole.
*/
private followDrop(asset: CompositionAsset, assetId: string, local: Vec2, g: Vec2): void {
const dragged = findAsset(this.state.project, assetId);
const project = this.state.project;
const dragged = findAsset(project, assetId);
const audio = audioKindOf(project, assetId);
const row = trackAtY(this.view, asset.tracks.length, g.y);
const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h;
const picture = dragged ? wornPicture(dragged) : null;
const usable =
dragged && (dragged.type === 'animation' || isPlaceableType(dragged.type) || picture !== null);
if (!inside || row === null || !dragged || !usable) {
audio !== null ||
(dragged && (dragged.type === 'animation' || isPlaceableType(dragged.type) || picture !== null));
if (!inside || row === null || !usable) {
if (this.drop) {
this.drop = null;
this.redraw();
@@ -823,7 +897,19 @@ export class CompositionTimeline extends Widget {
}
const track = asset.tracks[row]!.id;
const at = Math.max(0, Math.round(xToTick(this.view, g.x)));
if (dragged.type === 'animation') {
if (audio) {
const file = audio === 'sound' ? playedSoundById(project, assetId) : '';
if (file) this.ensureLoaded(file);
const item = { id: '', track, start: at, length: 1, offset: 0, audio: audioOf(audio, assetId) };
this.drop = {
what: 'item',
assetId,
track,
start: at,
length: fittedLength(project, asset, item),
rejection: '',
};
} else if (dragged!.type === 'animation') {
const length = clipLength(this.state.project, asset, {
id: '',
track,
@@ -852,8 +938,8 @@ export class CompositionTimeline extends Widget {
assetId,
track,
start: at,
length: droppedLength(this.state.project, asset, dragged),
rejection: picture === null ? placementRejection(this.state.project, asset, dragged) : '',
length: droppedLength(this.state.project, asset, dragged!),
rejection: picture === null ? placementRejection(this.state.project, asset, dragged!) : '',
};
this.redraw();
}
@@ -894,6 +980,13 @@ export class CompositionTimeline extends Widget {
return;
}
const item = { track: drop.track, start: drop.start, length: drop.length, offset: 0, fitMedia: true };
const audio = audioKindOf(this.state.project, drop.assetId);
if (audio) {
const added = { ...item, id: newId(), audio: audioOf(audio, drop.assetId) };
const command = new SetCompositionCommand(asset.id, { items: [...asset.items, added] }, 'Add item');
if (this.state.execute(command)) this.pickItems([added.id]);
return;
}
const dragged = findAsset(this.state.project, drop.assetId);
const picture = dragged ? wornPicture(dragged) : null;
const { command, objectId } =
@@ -927,15 +1020,30 @@ export class CompositionTimeline extends Widget {
const row = local.y >= RULER_HEIGHT ? trackAtY(this.view, asset.tracks.length, g.y) : null;
const hit = local.x >= HEAD_WIDTH && local.y >= RULER_HEIGHT ? this.hitAt(asset, g) : null;
if (hit?.kind === 'item') {
if (!this.pickedItems(asset).includes(hit.id)) this.state.select(objectSelection(asset.id, [hit.id]));
if (!this.pickedItems(asset).includes(hit.id)) this.pickItems([hit.id]);
const ids = this.pickedItems(asset);
const audio = asset.items.find((i) => i.id === hit.id)?.audio;
const played =
audio &&
(audio.kind === 'sound'
? findSound(this.state.project, audio.asset)?.asset
: findAsset(this.state.project, audio.asset));
new Menu([
...(audio
? [
{
label: `Open ${audio.kind}`,
disabled: !played,
onSelect: () => played && this.state.openAsset(played.id),
},
]
: []),
{
label: 'Fit to media',
disabled: ids.length !== 1,
onSelect: () => fitToMedia({ state: this.state }, asset.id, hit.id),
},
makeProjectAssetEntry({ state: this.state }, asset.id, hit.id),
...(audio ? [] : [makeProjectAssetEntry({ state: this.state }, asset.id, hit.id)]),
{ separator: true },
{
label: ids.length > 1 ? `Remove ${ids.length} items` : 'Remove item',
@@ -1087,7 +1195,7 @@ export class CompositionTimeline extends Widget {
const hint = !asset
? ''
: asset.items.length === 0 && asset.clips.length === 0
? 'Drag a sprite, text, camera or container onto a track, an animation to play on it.'
? 'Drag a sprite, text, camera or container onto a track, an animation to play on it, a sound, pattern or song to hear.'
: '';
this.hint.text = hint;
this.hint.visible = hint !== '';
@@ -1098,7 +1206,12 @@ export class CompositionTimeline extends Widget {
const c = Theme.colors;
const s = this.state.selection;
const lit =
s?.kind === 'composition' && s.assetId === asset.id && s.clipIds.length === 0 ? s.trackId : '';
s?.kind === 'composition' &&
s.assetId === asset.id &&
s.clipIds.length === 0 &&
(s.itemIds ?? []).length === 0
? s.trackId
: '';
const end = HEAD_WIDTH + tickToX(this.view, compositionLength(this.state.project, asset));
const zero = HEAD_WIDTH + tickToX(this.view, 0);
asset.tracks.forEach((track, row) => {
@@ -1137,7 +1250,13 @@ export class CompositionTimeline extends Widget {
if (box.x > this.w || box.x + box.w < HEAD_WIDTH) continue;
const lit = picked.has(item.id);
const media = itemMedia(this.state.project, asset, item);
const color = itemVideo(this.state.project, asset, item) ? VIDEO_COLOR : ITEM_COLOR;
const color = item.audio
? this.audioName(item) === null
? REJECTED_COLOR
: AUDIO_COLORS[item.audio.kind]
: itemVideo(this.state.project, asset, item)
? VIDEO_COLOR
: ITEM_COLOR;
this.lanes
.roundRect(box.x, box.y, box.w, box.h, 3)
.fill({ color, alpha: lit ? 1 : 0.85 })
@@ -1147,10 +1266,15 @@ export class CompositionTimeline extends Widget {
alpha: lit ? 1 : 0.45,
alignment: 1,
});
if (item.audio?.kind === 'sound') this.drawWave(item, box);
if (media) this.drawMediaMarks(item, media, box);
const object = itemObject(asset, item);
const placed = object && findAsset(this.state.project, object.assetId);
const name = object ? objectName(object, placed) : '';
const name = item.audio
? (this.audioName(item) ?? '(missing)')
: object
? objectName(object, placed)
: '';
const seen = HEAD_WIDTH + tickToX(this.view, uncoveredEnd(shown, item)) - box.x;
if (seen > 18)
labels.push({ text: name, x: Math.max(box.x, HEAD_WIDTH) + 4, y: box.y + 2, w: seen - 8 });
@@ -1213,6 +1337,62 @@ export class CompositionTimeline extends Widget {
}
}
/** What an audio item plays, by name – "Sound / Clip" for a clip –; null once it is gone. */
private audioName(item: CompositionItem): string | null {
const audio = item.audio!;
const project = this.state.project;
const there =
audio.kind === 'sound'
? findSound(project, audio.asset) !== null
: findAssetOf(project, audio.asset, audio.kind) !== undefined;
return there ? assetRefLabel(project, audio.asset) : null;
}
/**
* The waveform of a sound item, round and round as it plays, from where it starts in it, below its
* name – the Song tab's.
*/
private drawWave(item: CompositionItem, box: Box): void {
const asset = this.asset();
const played = asset && playedSoundById(this.state.project, item.audio!.asset);
if (!asset || !played) return;
const peaks = soundLibrary.peaks(played);
const seconds = soundLibrary.duration(played);
if (!peaks || !seconds) {
this.ensureLoaded(played);
return;
}
const fps = compositionFps(asset);
const width = tickToX(this.view, seconds * fps) - tickToX(this.view, 0);
if (!(width > 0)) return;
const mid = box.y + LABEL_STRIP + (box.h - LABEL_STRIP) / 2;
const half = (box.h - LABEL_STRIP - 4) / 2;
const from = Math.max(box.x, HEAD_WIDTH);
const to = Math.min(box.x + box.w, this.w);
const begins = HEAD_WIDTH + tickToX(this.view, item.start - item.offset * fps);
for (let round = Math.floor((from - begins) / width); ; round++) {
const start = Math.round(begins + round * width);
if (start >= to) break;
for (let dx = Math.max(0, Math.ceil((from - start) / 2) * 2); dx < width; dx += 2) {
const x = start + dx;
if (x >= to) break;
// Before a sound that begins inside the item, it is silent.
if (x < begins) continue;
const reach = Math.max(0.5, peakOver(peaks, dx / width, (dx + 2) / width) * half);
this.lanes.rect(x, mid - reach, 1.5, reach * 2).fill({ color: 0xffffff, alpha: 0.6 });
}
}
}
private ensureLoaded(file: string): void {
if (this.loading.has(file) || soundLibrary.peaks(file) !== null) return;
this.loading.add(file);
void soundLibrary.loadPicture(file).then(() => {
this.loading.delete(file);
this.redraw();
});
}
/** The end of an item the pointer would drag, lit – over an item that covers it, too. */
private drawGrip(box: Box, part: 'start' | 'end'): void {
const reach = Math.min(EDGE_REACH, box.w / 3);
@@ -1307,6 +1487,11 @@ export class CompositionTimeline extends Widget {
}
}
/** A new audio item's, as its asset says: unity, on the bus its routing sends it to. */
function audioOf(kind: CompositionAudioKind, asset: string): NonNullable<CompositionItem['audio']> {
return { kind, asset, volume: 0, bus: '' };
}
/** Puts the texts where they are wanted, reusing the ones there are and hiding the rest. */
function syncTexts(pool: Text[], layer: Container, wanted: readonly Placed[], style: TextStyle): void {
wanted.forEach((place, i) => {
+5
View File
@@ -96,6 +96,11 @@ export function documentHooks({ state, editor }: HookContext) {
objectScreen: (objectId: string) => editor.compositionEditor.preview.objectScreen(objectId),
/** Where the picture draws a sprite right now; null while it is left out, a video without its picture. */
drawn: (objectId: string) => editor.compositionEditor.preview.drawnRect(objectId),
/** Whether its audio items are heard (#418), and the numbers of their playings. */
sound: () => ({
playing: editor.compositionEditor.sound.playing,
playings: [...editor.compositionEditor.sound.playings],
}),
},
tile: {
current: () => editor.tileEditor.currentAssetId,
+4 -3
View File
@@ -146,7 +146,8 @@ export class Inspector extends Panel {
else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys);
else if (s.kind === 'edge') buildEdgeSection(this.host, s.assetId, s.edgeId, s.edgeIds);
else if (s.kind === 'song') buildSongSection(this.host, s.assetId, s.trackId, s.itemIds);
else if (s.kind === 'composition') buildCompositionSection(this.host, s.assetId, s.trackId, s.clipIds);
else if (s.kind === 'composition')
buildCompositionSection(this.host, s.assetId, s.trackId, s.clipIds, s.itemIds);
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
else buildObjectSection(this.host, s.holderId, s.objectId);
this.scroll.setContent(this.host.body);
@@ -167,7 +168,7 @@ export class Inspector extends Panel {
if (s.kind === 'edge') return edgeSignature(this.state.project, s.assetId, s.edgeId, s.edgeIds);
if (s.kind === 'song') return songSignature(this.state.project, s.assetId, s.trackId, s.itemIds);
if (s.kind === 'composition')
return compositionSignature(this.state.project, s.assetId, s.trackId, s.clipIds);
return compositionSignature(this.state.project, s.assetId, s.trackId, s.clipIds, s.itemIds);
return objectSignature(this.state, s.holderId, s.objectIds, s.objectId);
}
@@ -208,7 +209,7 @@ export class Inspector extends Panel {
: s.kind === 'song'
? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds)
: s.kind === 'composition'
? refreshCompositionSection(this.host, s.assetId, s.trackId, s.clipIds)
? refreshCompositionSection(this.host, s.assetId, s.trackId, s.clipIds, s.itemIds)
: s.objectIds.length > 1 ||
refreshObjectSection(this.host, s.holderId, s.objectId);
if (!ok) this.rebuild();
+116 -7
View File
@@ -1,6 +1,12 @@
import { MakeProjectAssetCommand } from '../../core/commands/assetCommands';
import { SetCompositionCommand } from '../../core/commands/compositionCommands';
import type { CompositionAsset, CompositionItem } from '../../core/model/Asset';
import type {
Asset,
CompositionAsset,
CompositionAudio,
CompositionAudioKind,
CompositionItem,
} from '../../core/model/Asset';
import {
clipLength,
compositionFps,
@@ -10,6 +16,8 @@ import {
} from '../../core/model/composition';
import { heldMove, movedWithClips, resizedItem } from '../../core/model/compositionEdit';
import { findAsset, findAssetOf, type Project } from '../../core/model/Project';
import { findSound } from '../../core/model/sound';
import { ITEM_MIN_DB, SOUND_ITEM_MAX_DB } from '../../core/music/song';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, formatNumber, type MenuEntry } from '../../ui';
import { createFieldRow } from './fieldFactory';
@@ -21,6 +29,36 @@ const LENGTH_ROW = 'composition:length';
const OFFSET_ROW = 'composition:offset';
const CLIP_START_ROW = 'composition:clipStart';
const ANIMATION_ROW = 'composition:animation';
const PLAYS_ROW = 'composition:plays';
const VOLUME_ROW = 'composition:volume';
const BUS_ROW = 'composition:bus';
const AUDIO_TITLES: Record<CompositionAudioKind, string> = {
sound: 'SOUND ITEM',
pattern: 'PATTERN ITEM',
song: 'SONG ITEM',
};
const VOLUME_DEF: PropertyDef = {
key: VOLUME_ROW,
label: 'Volume',
kind: 'number',
default: 0,
min: ITEM_MIN_DB,
max: SOUND_ITEM_MAX_DB,
step: 0.5,
decimals: 1,
};
const BUS_DEF: PropertyDef = {
key: BUS_ROW,
label: 'Bus',
kind: 'asset',
assetTypes: ['bus'],
none: 'From asset',
master: true,
default: '',
};
const seconds = (key: string, label: string, negative = false): PropertyDef => ({
key,
@@ -32,15 +70,21 @@ const seconds = (key: string, label: string, negative = false): PropertyDef => (
decimals: 3,
});
/** Structure-defining facts of a track or clip selection: which it is, and whether it is still there. */
/** Structure-defining facts of a track, clip or item selection: which it is, and whether it is still there. */
export function compositionSignature(
project: Project,
assetId: string,
trackId: string,
clipIds: string[],
itemIds: string[] = [],
): string {
const comp = findAssetOf(project, assetId, 'composition');
if (!comp?.tracks.some((t) => t.id === trackId)) return 'missing';
if (itemIds.length === 1) {
const audio = comp.items.find((i) => i.id === itemIds[0])?.audio;
return `item:${itemIds[0]}:${audio ? playedAsset(project, audio)?.id : ''}${timingSignature(project, assetId, itemIds[0]!)}`;
}
if (itemIds.length > 0) return `items:${itemIds.join(',')}`;
return clipIds.length > 0 ? `clips:${clipIds.join(',')}` : 'track';
}
@@ -50,10 +94,17 @@ export function buildCompositionSection(
assetId: string,
trackId: string,
clipIds: string[],
itemIds: string[] = [],
): void {
const comp = findAssetOf(host.state.project, assetId, 'composition');
const track = comp?.tracks.find((t) => t.id === trackId);
if (!comp || !track) return;
if (itemIds.length > 0) {
const item = itemIds.length === 1 ? comp.items.find((i) => i.id === itemIds[0]) : undefined;
if (item?.audio) buildAudioItem(host, assetId, item, item.audio);
else addTitle(host, `${itemIds.length} ITEMS`);
return;
}
if (clipIds.length === 0) {
addTitle(host, 'TRACK');
addNameField(host, track.name, {
@@ -99,10 +150,20 @@ export function refreshCompositionSection(
assetId: string,
trackId: string,
clipIds: string[],
itemIds: 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 (itemIds.length > 0) {
const audio = itemIds.length === 1 ? comp.items.find((i) => i.id === itemIds[0])?.audio : undefined;
if (audio) {
refreshTimingRows(host, assetId, itemIds[0]!);
host.fields.get(VOLUME_ROW)?.setValue(audio.volume);
host.fields.get(BUS_ROW)?.setValue(audio.bus);
}
return true;
}
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));
@@ -129,7 +190,7 @@ export function addTimingRows(host: InspectorHost, holderId: string, objectId: s
if (!found) return;
const { comp, item } = found;
const fps = compositionFps(comp);
const video = itemVideo(host.state.project, comp, item) !== null;
const video = item.audio !== undefined || 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);
@@ -190,9 +251,11 @@ export function addTimingRows(host: InspectorHost, holderId: string, objectId: s
addNote(
host,
item.fitMedia
? video
? 'Its length follows its video.'
: 'Its length follows a video it is given, 3 s while it has none.'
? item.audio
? 'Its length follows what it plays.'
: 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',
);
@@ -214,7 +277,7 @@ export function refreshTimingRows(host: InspectorHost, holderId: string, objectI
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 video = found.item.audio !== undefined || 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}`;
}
@@ -248,6 +311,52 @@ export function makeProjectAssetEntry(
};
}
/**
* An audio item (#418): what it plays, when, how loud and on which bus. It plays round and round for
* as long as it lasts; its offset is how far into what it plays it begins.
*/
function buildAudioItem(
host: InspectorHost,
assetId: string,
item: CompositionItem,
audio: CompositionAudio,
): void {
addTitle(host, AUDIO_TITLES[audio.kind]);
const played = playedAsset(host.state.project, audio);
if (played) assetLink(host, PLAYS_ROW, 'Plays', played);
else addNote(host, `Its ${audio.kind} is gone.`, 'warning');
addTimingRows(host, assetId, item.id);
const edit = (label: string, change: Partial<CompositionAudio>) => {
const comp = findAssetOf(host.state.project, assetId, 'composition');
if (!comp) return;
const items = comp.items.map((i) =>
i.id === item.id && i.audio ? { ...i, audio: { ...i.audio, ...change } } : i,
);
if (SetCompositionCommand.changes(host.state.project, assetId, { items }))
host.state.execute(new SetCompositionCommand(assetId, { items }, label));
};
const volume = createFieldRow(host, VOLUME_DEF, audio.volume, {
onInput: () => undefined,
onChange: (value) => edit('Set item volume', { volume: Number(value) || 0 }),
});
host.fields.set(VOLUME_ROW, volume);
host.body.add(volume.row);
const bus = createFieldRow(host, BUS_DEF, audio.bus, {
onInput: () => undefined,
onChange: (value) => edit('Set item bus', { bus: typeof value === 'string' ? value : '' }),
});
host.fields.set(BUS_ROW, bus);
host.body.add(bus.row);
addNote(host, 'Volume in dB. It plays round and round for as long as it lasts.', 'small');
}
/** The asset an audio item plays, for a clip of a sound the sound; undefined once it is gone. */
function playedAsset(project: Project, audio: CompositionAudio): Asset | undefined {
return audio.kind === 'sound'
? findSound(project, audio.asset)?.asset
: findAssetOf(project, audio.asset, audio.kind);
}
function withOffset(item: CompositionItem, offset: number): CompositionItem {
const { fitMedia: _, ...rest } = item;
return { ...rest, offset: Number.isFinite(offset) ? offset : 0 };
+17 -4
View File
@@ -78,6 +78,11 @@ export class AssetPanel extends Panel {
private readonly foldedCategories = new Set<AssetCategory>();
/** Ids of the rows as shown, headings included. */
private rows: string[] = [];
/**
* The selected asset the list last scrolled to. Only a new selection scrolls: a rebuild – folding
* a group, any edit – leaves the list where the user put it.
*/
private scrolledTo: string | null = null;
private readonly stopListening: () => void;
constructor(
@@ -112,6 +117,8 @@ export class AssetPanel extends Panel {
};
// Room to scroll the last group up off the panel's bottom edge, a few rows' worth.
this.scroll.tailSpace = 6 * this.list.rowHeight;
// Folding a group leaves the rows above it, and the heading under the pointer, where they are.
this.scroll.holdOffset = true;
scrollTween(this.scroll);
this.scroll.setContent(this.list);
this.content.add(this.scroll, 1);
@@ -121,6 +128,7 @@ export class AssetPanel extends Panel {
state.on('load', () => {
this.unfolded.clear();
this.foldedCategories.clear();
this.scrolledTo = null;
this.refresh();
});
state.on('selection', () => this.syncSelection());
@@ -274,7 +282,8 @@ export class AssetPanel extends Panel {
return;
}
this.list.select(id, true);
if (id) this.scrollTo(id);
if (id && id !== this.scrolledTo) this.scrollTo(id);
this.scrolledTo = id;
}
private scrollTo(id: string): void {
@@ -445,12 +454,16 @@ export class AssetPanel extends Panel {
/**
* Hands the asset to the view under the pointer: a scene places it, a song lays out a pattern or a
* sound (#334), a composition an animation as a clip (#417) and a picture or video as an item
* (#419). A scene has nowhere to go.
* sound (#334), a composition an animation as a clip (#417), a picture or video as an item (#419)
* and a sound, pattern or song as an audio item (#418). A scene has nowhere to go.
*/
private startPlacementDrag(assetId: string): void {
const asset = findAsset(this.state.project, assetId);
const laid = asset?.type === 'pattern' || asset?.type === 'sound' || asset?.type === 'animation';
const laid =
asset?.type === 'pattern' ||
asset?.type === 'song' ||
asset?.type === 'sound' ||
asset?.type === 'animation';
if (!asset || !(isPlaceableType(asset.type) || laid || wornPicture(asset) !== null)) return;
startDrag({ kind: 'asset', id: assetId }, asset.name);
}
+14 -5
View File
@@ -83,9 +83,10 @@ export type Selection =
| { kind: 'song'; assetId: string; trackId: string; itemIds: string[] }
/**
* A track of a composition (#417) and the animation clips picked on it or on others: `clipIds` is
* empty when the track itself was clicked. Its items are objects, picked as an object selection.
* empty when the track itself was clicked. Items that are objects only are picked as an object
* selection; `itemIds` holds picked items once an audio item is among them (#418).
*/
| { kind: 'composition'; assetId: string; trackId: string; clipIds: string[] }
| { kind: 'composition'; assetId: string; trackId: string; clipIds: string[]; itemIds?: string[] }
| null;
/**
@@ -857,11 +858,14 @@ export class EditorState {
const asset = findAssetOf(this.project, s.assetId, 'composition');
if (!asset) this.select(null);
else {
// The same as a song's: a clip that died leaves, a track that died hands over to the composition.
// The same as a song's: a clip or item that died leaves, a track that died hands over to the composition.
const clips = s.clipIds.filter((id) => asset.clips.some((clip) => clip.id === id));
const picked = s.itemIds ?? [];
const items = picked.filter((id) => asset.items.some((item) => item.id === id));
const trackAlive = asset.tracks.some((track) => track.id === s.trackId);
if (!trackAlive) this.select({ kind: 'asset', assetId: s.assetId });
else if (clips.length !== s.clipIds.length) this.select({ ...s, clipIds: clips });
else if (clips.length !== s.clipIds.length || items.length !== picked.length)
this.select({ ...s, clipIds: clips, itemIds: items });
}
}
}
@@ -1089,7 +1093,12 @@ export function sameSelection(a: Selection, b: Selection): boolean {
if (a.kind === 'song' && b.kind === 'song')
return a.assetId === b.assetId && a.trackId === b.trackId && sameIds(a.itemIds, b.itemIds);
if (a.kind === 'composition' && b.kind === 'composition')
return a.assetId === b.assetId && a.trackId === b.trackId && sameIds(a.clipIds, b.clipIds);
return (
a.assetId === b.assetId &&
a.trackId === b.trackId &&
sameIds(a.clipIds, b.clipIds) &&
sameIds(a.itemIds ?? [], b.itemIds ?? [])
);
return false;
}