Fade sounds and videos in and out on the items of a composition
A trailer needs its music and its clips to come in softly and leave
softly. Until now an item of a composition could only play at one
level, and a video item could not even be turned down on its own.
- Every item with sound gets a grip at its upper left and upper right
corner. Dragging one inwards fades the item in or out. Dragging it
back to the edge removes the fade. Lower down the same edge still
trims as before.
- The fade is drawn as its curve, with the part above it darkened.
The inspector shows Fade in and Fade out in seconds, each with the
curves sound clips already know.
- The model keeps fadeIn and fadeOut in frames plus curveIn and
curveOut on the item. Old projects load unchanged. An item shorter
than its fades plays them cut to its length but keeps the setting.
- Sound, pattern and song items ramp the gain of their own bus. The
scheduler plans the ramps on the audio clock next to the notes.
Starting in the middle of a fade picks it up from there.
- Video items set the new sprite property Video volume each frame.
It multiplies with the fade and is only shown for a driven video.
The player glides to each new level over 15 ms so it does not click
between frames.
Not covered: a container item that carries a video further down gets
no grips. Only a video on the item's own sprite counts.
Tests:
- tsc, eslint, Prettier clean.
- Unit tests for composition, music, song and sound (12 files): 269
passed, 7 of them new.
- e2e 69 on melektaus-atx: ALL PASSED, 21 checks. 69p drags a grip to
a fade in of 10 frames and hears the item bus rise from 0.127 over
0.229 and 0.339 to 1 within one second.
- Counter-checks as unit tests: without the audio ramps, and without
the level on the video sprite in the game, both go red.
- Gate on melektaus-atx: 309 files, 4796 tests passed, 8 more than
at 77ac9d1. The first run failed 14 tests in 6 files that list the
default sprite properties or the bus levels in full. They now
expect videoVolume and the new level field of busLevels.
- Not tried in a browser: video fades, pattern and song items with a
fade.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
77ac9d1aaf
commit
acd12326dc
26 files changed
+737
-53
No files matched your search
@@ -8,6 +8,7 @@ import type { AssetType } from './AssetTypes';
|
||||
import type { PropertyBag, PropertyValue, Vec2 } from './Properties';
|
||||
import type { Scope } from './Scope';
|
||||
import type { ObjectGroup, SceneObject } from './SceneObject';
|
||||
import type { FadeCurve } from './sound';
|
||||
|
||||
export interface AssetBase {
|
||||
id: string;
|
||||
@@ -275,7 +276,9 @@ export interface CompositionTrack {
|
||||
* below 0 the item begins before the video and shows its first picture until then. `fitMedia` says
|
||||
* its length still follows that video, or the default while there is none. Items of a track may
|
||||
* overlap. An item with `audio` spawns no object: it plays a sound, a pattern or a song (#418), and
|
||||
* `offset` is how far into that it begins.
|
||||
* `offset` is how far into that it begins. `fadeIn` and `fadeOut` are the frames its sound – what it
|
||||
* plays, or its video's – rises from silence at its start and falls back at its end, shaped by
|
||||
* `curveIn` and `curveOut`; absent for none.
|
||||
*/
|
||||
export interface CompositionItem {
|
||||
id: string;
|
||||
@@ -285,6 +288,10 @@ export interface CompositionItem {
|
||||
offset: number;
|
||||
fitMedia?: boolean;
|
||||
audio?: CompositionAudio;
|
||||
fadeIn?: number;
|
||||
fadeOut?: number;
|
||||
curveIn?: FadeCurve;
|
||||
curveOut?: FadeCurve;
|
||||
}
|
||||
|
||||
export type CompositionAudioKind = 'sound' | 'pattern' | 'song';
|
||||
|
||||
@@ -14,7 +14,14 @@ import {
|
||||
} from '../properties/compositions';
|
||||
import { playedSoundById } from '../properties/playedSound';
|
||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY, videoDrivenOf } from '../properties/videos';
|
||||
import { DEFAULT_FADE_CURVE } from '../properties/sounds';
|
||||
import {
|
||||
VIDEO_CLOCK_KEY,
|
||||
VIDEO_LEVEL_KEY,
|
||||
VIDEO_TIME_KEY,
|
||||
videoDrivenOf,
|
||||
videoLevelOf,
|
||||
} from '../properties/videos';
|
||||
import { animationDuration, fpsOf, frameCount, targetNamePath } from './animation';
|
||||
import { type ReadAt, writeObjectRow } from './animationPreview';
|
||||
import type {
|
||||
@@ -41,6 +48,7 @@ import {
|
||||
import { findAsset, findAssetOf, type Project } from './Project';
|
||||
import { booleanOf, type PropertyBag } from './Properties';
|
||||
import { objectName, type SceneObject } from './SceneObject';
|
||||
import { type FadeCurve, fadeGain, isFadeCurve } from './sound';
|
||||
import { findVideo, videoLengthOf, type VideoRef } from './video';
|
||||
|
||||
/** How long an item lasts while it has no video to take its length from. */
|
||||
@@ -85,6 +93,40 @@ export function setSoundMeasure(measure: (file: string) => number | null): void
|
||||
soundSeconds = measure;
|
||||
}
|
||||
|
||||
/** Whether an item has a sound its fades shape: what an audio item plays, or the video its object wears. */
|
||||
export function itemSounds(project: Project, comp: CompositionAsset, item: CompositionItem): boolean {
|
||||
return item.audio !== undefined || itemVideo(project, comp, item) !== null;
|
||||
}
|
||||
|
||||
/** The fades of an item in whole frames as they work: the fade in no longer than the item, the fade out no longer than the rest. */
|
||||
export interface ItemFades {
|
||||
fadeIn: number;
|
||||
fadeOut: number;
|
||||
curveIn: FadeCurve;
|
||||
curveOut: FadeCurve;
|
||||
}
|
||||
|
||||
export function itemFades(item: CompositionItem): ItemFades {
|
||||
const frames = (value: number | undefined, most: number) =>
|
||||
Math.max(0, Math.min(most, Math.round(Number.isFinite(value) ? value! : 0)));
|
||||
const fadeIn = frames(item.fadeIn, item.length);
|
||||
return {
|
||||
fadeIn,
|
||||
fadeOut: frames(item.fadeOut, item.length - fadeIn),
|
||||
curveIn: item.curveIn ?? DEFAULT_FADE_CURVE,
|
||||
curveOut: item.curveOut ?? DEFAULT_FADE_CURVE,
|
||||
};
|
||||
}
|
||||
|
||||
/** How loud an item's sound is `at` frames after its start, its fades considered: 0 to 1. */
|
||||
export function fadeLevel(item: CompositionItem, at: number): number {
|
||||
const { fadeIn, fadeOut, curveIn, curveOut } = itemFades(item);
|
||||
const rise = fadeIn > 0 && at < fadeIn ? fadeGain(curveIn, at / fadeIn) : 1;
|
||||
const left = item.length - at;
|
||||
const fall = fadeOut > 0 && left < fadeOut ? fadeGain(curveOut, left / fadeOut) : 1;
|
||||
return rise * fall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds the first pass of what an audio item plays lasts: its sound once loaded, its pattern, its
|
||||
* song to the end of its loop – it goes round there, what lies behind is never heard –; 0 while that
|
||||
@@ -234,6 +276,8 @@ export interface LiveItem {
|
||||
time: number;
|
||||
/** Seconds into what its videos play: `time` past the item's offset. */
|
||||
videoTime: number;
|
||||
/** How loud its videos are heard, 0 to 1: its fades at this frame. */
|
||||
level: number;
|
||||
/** In the order they started; a later one writes over an earlier one. */
|
||||
clips: ClipAt[];
|
||||
}
|
||||
@@ -266,7 +310,11 @@ export function compositionAt(project: Project, comp: CompositionAsset, frame: n
|
||||
const at = ((frame - clip.start) / fps) * fpsOf(animation!);
|
||||
playing.push({ clip, animation: animation!, frame: Math.min(at, frameCount(animation!) - 1) });
|
||||
}
|
||||
live.push({ item: { item, object, track, time, videoTime: item.offset + time, clips: playing }, order });
|
||||
const level = fadeLevel(item, frame - item.start);
|
||||
live.push({
|
||||
item: { item, object, track, time, videoTime: item.offset + time, level, clips: playing },
|
||||
order,
|
||||
});
|
||||
});
|
||||
live.sort(
|
||||
(a, b) => b.item.track - a.item.track || a.item.item.start - b.item.item.start || a.order - b.order,
|
||||
@@ -364,7 +412,7 @@ export function heldCompositionTracks(raw: unknown): CompositionTrack[] {
|
||||
/**
|
||||
* Held to what an item may be: the id of an object it spawns and no other item does – an audio item
|
||||
* an id of its own and what it plays –, a track of the composition, a start in whole frames from 0,
|
||||
* at least a frame long, a finite offset.
|
||||
* at least a frame long, a finite offset, fades in whole frames above 0 and curves there are.
|
||||
*/
|
||||
export function heldCompositionItems(
|
||||
raw: unknown,
|
||||
@@ -392,6 +440,12 @@ export function heldCompositionItems(
|
||||
};
|
||||
if (i.fitMedia === true) item.fitMedia = true;
|
||||
if (audio) item.audio = audio;
|
||||
const fadeIn = Math.round(finite(i.fadeIn));
|
||||
const fadeOut = Math.round(finite(i.fadeOut));
|
||||
if (fadeIn > 0) item.fadeIn = fadeIn;
|
||||
if (fadeOut > 0) item.fadeOut = fadeOut;
|
||||
if (isFadeCurve(i.curveIn)) item.curveIn = i.curveIn;
|
||||
if (isFadeCurve(i.curveOut)) item.curveOut = i.curveOut;
|
||||
items.push(item);
|
||||
}
|
||||
return items;
|
||||
@@ -449,6 +503,7 @@ function driveVideos(project: Project, holder: CompositionAsset, live: LiveItem)
|
||||
const ref = objectRef(project, holder, object.id);
|
||||
ref?.setOverride(VIDEO_CLOCK_KEY, 'driven');
|
||||
ref?.setOverride(VIDEO_TIME_KEY, live.videoTime);
|
||||
if (live.level < 1) ref?.setOverride(VIDEO_LEVEL_KEY, videoLevelOf(props) * live.level);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DEFAULT_ITEM_SECONDS,
|
||||
fittedLength,
|
||||
itemEnd,
|
||||
itemFades,
|
||||
itemVideo,
|
||||
} from './composition';
|
||||
import { findAsset, type Project } from './Project';
|
||||
@@ -324,6 +325,21 @@ export function trimmedItem(
|
||||
return { ...rest, start, length: item.length - by, offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* The item with the handle of its fade in or out dragged to `frame`: the fade from its edge to there,
|
||||
* none where that is at or past the edge, no further than the other fade lets it. A fade of 0 is left
|
||||
* out of the item.
|
||||
*/
|
||||
export function fadedItem(item: CompositionItem, end: 'in' | 'out', frame: number): CompositionItem {
|
||||
const fades = itemFades(item);
|
||||
const asked = end === 'in' ? frame - item.start : itemEnd(item) - frame;
|
||||
const room = item.length - (end === 'in' ? fades.fadeOut : fades.fadeIn);
|
||||
const frames = Math.max(0, Math.min(room, Math.round(asked)));
|
||||
const key = end === 'in' ? 'fadeIn' : 'fadeOut';
|
||||
const { [key]: _, ...rest } = item;
|
||||
return frames > 0 ? { ...rest, [key]: frames } : rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an item's video lies in the composition, in frames: where it begins, how long its first pass
|
||||
* is, whether it goes round, and how long every pass after the first is where that differs – a song
|
||||
|
||||
Reference in new issue
Block a user