Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/canvas/players/audio-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export class AudioPlayer extends Player {
private rebuildVolumeKeyframeBuilder(): void {
const audioAsset = this.clipConfiguration.asset as AudioAsset;
const baseVolume = this.getBaseVolume();
this.volumeKeyframeBuilder = new KeyframeBuilder(this.createVolumeKeyframes(audioAsset, baseVolume), this.getLength(), baseVolume);
this.volumeKeyframeBuilder = new KeyframeBuilder(this.createVolumeKeyframes(audioAsset, baseVolume));
}

private createVolumeKeyframes(asset: AudioAsset, baseVolume: number): Keyframe[] | number {
Expand Down
22 changes: 11 additions & 11 deletions src/components/canvas/players/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,13 @@ export abstract class Player extends Entity {
const baseSkewY = typeof config.transform?.skew?.y === "number" ? config.transform.skew.y : 0;

// Create composed builders with base values
this.offsetXKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetX, length, "additive");
this.offsetYKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetY, length, "additive");
this.scaleKeyframeBuilder = new ComposedKeyframeBuilder(baseScale, length, "multiplicative");
this.opacityKeyframeBuilder = new ComposedKeyframeBuilder(baseOpacity, length, "multiplicative", { min: 0, max: 1 });
this.rotationKeyframeBuilder = new ComposedKeyframeBuilder(baseRotation, length, "additive");
this.skewXKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewX, length, "additive");
this.skewYKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewY, length, "additive");
this.offsetXKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetX, "additive");
this.offsetYKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetY, "additive");
this.scaleKeyframeBuilder = new ComposedKeyframeBuilder(baseScale, "multiplicative");
this.opacityKeyframeBuilder = new ComposedKeyframeBuilder(baseOpacity, "multiplicative", { min: 0, max: 1 });
this.rotationKeyframeBuilder = new ComposedKeyframeBuilder(baseRotation, "additive");
this.skewXKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewX, "additive");
this.skewYKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewY, "additive");

// If user has custom keyframes, add them and skip effect/transition layers
if (hasKeyframedVisualProperty(this.clipConfiguration)) {
Expand Down Expand Up @@ -228,27 +228,27 @@ export abstract class Player extends Entity {
// Mask keyframes (reveal effect)
const maskXKeyframes = [...transitionSet.in.maskXKeyframes, ...transitionSet.out.maskXKeyframes];
if (maskXKeyframes.length) {
this.maskXKeyframeBuilder = new KeyframeBuilder(maskXKeyframes, length);
this.maskXKeyframeBuilder = new KeyframeBuilder(maskXKeyframes);
}

// Wipe brightness sweep (wipeLeft/wipeRight) — driven through WipeFilter, not a mask.
const brightnessKeyframes = [...transitionSet.in.brightnessKeyframes, ...transitionSet.out.brightnessKeyframes];
if (brightnessKeyframes.length) {
this.wipeBrightnessBuilder = new KeyframeBuilder(brightnessKeyframes, length);
this.wipeBrightnessBuilder = new KeyframeBuilder(brightnessKeyframes);
this.wipeInFromRight = transitionSet.in.wipeFromRight;
this.wipeOutFromRight = transitionSet.out.wipeFromRight;
this.wipeOutStart = transitionSet.out.brightnessKeyframes[0]?.start ?? Number.POSITIVE_INFINITY;
}

const blurKeyframes = [...transitionSet.in.blurKeyframes, ...transitionSet.out.blurKeyframes];
this.zoomBlurBuilder = blurKeyframes.length ? new KeyframeBuilder(blurKeyframes, length) : undefined;
this.zoomBlurBuilder = blurKeyframes.length ? new KeyframeBuilder(blurKeyframes) : undefined;

// The render keeps one origin curve: the in shuffle's origin (or the centre) eases to the out shuffle's by the time it starts.
const { origin: inOrigin } = transitionSet.in;
const { origin: outOrigin } = transitionSet.out;
const outStart = transitionSet.out.rotationKeyframes[0]?.start ?? length;
const originCurve = (from = 0.5, to?: number) =>
new KeyframeBuilder(to === undefined ? from : [{ from, to, start: 0, length: outStart, interpolation: "bezier", easing: "smooth" }], length);
new KeyframeBuilder(to === undefined ? from : [{ from, to, start: 0, length: outStart, interpolation: "bezier", easing: "smooth" }]);
this.rotationOrigin =
inOrigin || outOrigin ? { x: originCurve(inOrigin?.x, outOrigin?.x), y: originCurve(inOrigin?.y, outOrigin?.y) } : undefined;
}
Expand Down
18 changes: 13 additions & 5 deletions src/components/canvas/players/video-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,17 @@ function seekVideo(video: HTMLVideoElement, time: number): Promise<string | unde

export function createAnimatedSpeed(tweens: VideoAsset["speed"], length: number): AnimatedSpeed | null {
if (!Array.isArray(tweens) || tweens.length === 0) return null;
const [first] = [...tweens].sort((a, b) => (a.start ?? 0) - (b.start ?? 0));
// The render holds the first tween's speed before it starts; KeyframeBuilder would otherwise ramp from 0.
const curve = new KeyframeBuilder(tweens, length, typeof first.from === "number" ? first.from : 1);
const sorted = [...tweens].sort((a, b) => (a.start ?? 0) - (b.start ?? 0));
// The render bridges gaps between speed tweens linearly, whatever the next tween's interpolation.
const bridged = sorted.flatMap((tween, index) => {
const previous = sorted[index - 1];
const gapStart = (previous?.start ?? 0) + (previous?.length ?? 0);
const gap = (tween.start ?? 0) - gapStart;
return previous && gap > 0
? [{ from: previous.to, to: tween.from, start: gapStart, length: gap, interpolation: "linear" as const }, tween]
: [tween];
});
const curve = new KeyframeBuilder(bridged);
// Trapezoid rule, matching the render, so linear ramps land exactly.
const sourceOffsets = new Float64Array(Math.ceil(length / SPEED_STEP_SECONDS) + 1);
for (let i = 1; i < sourceOffsets.length; i += 1) {
Expand Down Expand Up @@ -69,7 +77,7 @@ export class VideoPlayer extends Player {

const videoAsset = this.clipConfiguration.asset as VideoAsset;

this.volumeKeyframeBuilder = new KeyframeBuilder(videoAsset.volume ?? 1, this.getLength());
this.volumeKeyframeBuilder = new KeyframeBuilder(videoAsset.volume ?? 1);
this.animatedSpeed = createAnimatedSpeed(videoAsset.speed, this.getLength());
this.syncTimer = 0;
this.activeSyncTimer = 0;
Expand Down Expand Up @@ -223,7 +231,7 @@ export class VideoPlayer extends Player {
super.reconfigureAfterRestore();

const videoAsset = this.clipConfiguration.asset as VideoAsset;
this.volumeKeyframeBuilder = new KeyframeBuilder(videoAsset.volume ?? 1, this.getLength());
this.volumeKeyframeBuilder = new KeyframeBuilder(videoAsset.volume ?? 1);
this.animatedSpeed = createAnimatedSpeed(videoAsset.speed, this.getLength());
}

Expand Down
7 changes: 2 additions & 5 deletions src/core/animations/composed-keyframe-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,10 @@ export class ComposedKeyframeBuilder {
private readonly baseValue: number;
private readonly mode: CompositionMode;
private readonly layers: KeyframeBuilder[] = [];
private readonly length: number;
private readonly clampRange?: { min: number; max: number };

constructor(baseValue: number, length: number, mode: CompositionMode, clampRange?: { min: number; max: number }) {
constructor(baseValue: number, mode: CompositionMode, clampRange?: { min: number; max: number }) {
this.baseValue = baseValue;
this.length = length;
this.mode = mode;
this.clampRange = clampRange;
}
Expand All @@ -34,8 +32,7 @@ export class ComposedKeyframeBuilder {
addLayer(keyframes: AnimationKeyframe[]): void {
if (keyframes.length === 0) return;

const neutralValue = this.mode === "additive" ? 0 : 1;
this.layers.push(new KeyframeBuilder(keyframes, this.length, neutralValue));
this.layers.push(new KeyframeBuilder(keyframes));
}

/**
Expand Down
8 changes: 6 additions & 2 deletions src/core/animations/curve-interpolator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,15 @@ export class CurveInterpolator {
};
}

public getValue(from: number, to: number, progress: number, easing?: string): number {
/** An easing's handles, leaving the start then entering the end; the render reads an unknown name as `ease`. */
public getHandles(easing?: string): number[][] {
return this.curves[easing ?? ""] ?? this.curves["ease"];
}

public getValue(from: number, to: number, progress: number, handles: number[][]): number {
if (progress <= 0) return from;
if (progress >= 1) return to;

const handles = this.curves[easing ?? ""] ?? this.curves["ease"];
const [[controlPoint1X, controlPoint1Y], [controlPoint2X, controlPoint2Y]] = handles;

// Standard cubic-bezier timing function: solve the X-axis bezier for t at this progress,
Expand Down
161 changes: 31 additions & 130 deletions src/core/animations/keyframe-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,109 +10,42 @@ import { CurveInterpolator } from "./curve-interpolator";
*/
export const TIME_EPSILON = 1e-6;

export class KeyframeBuilder {
private readonly property: NumericKeyframe[];
private readonly length: number;

private readonly cubicBuilder: CurveInterpolator;
/** A curve point as the render keeps them: a segment takes its right point's interpolation and the handles facing into it. */
type Point = { time: number; value: number; interpolation: NumericKeyframe["interpolation"]; handleLeft: number[]; handleRight: number[] };

private cachedIndex = 0;
const curves = new CurveInterpolator();
// The render gives every point its default curve's handles unless a tween sets them.
const [DEFAULT_RIGHT, DEFAULT_LEFT] = curves.getHandles("smooth");

constructor(value: AnimationKeyframe[] | number, length: number, initialValue = 0) {
this.property = this.createKeyframes(value, length, initialValue);
this.length = length;
export class KeyframeBuilder {
private readonly points: Point[];

this.cubicBuilder = new CurveInterpolator();
constructor(value: AnimationKeyframe[] | number) {
this.points = this.createPoints(value);
}

public getValue(time: number): number {
const keyframe = this.findKeyframe(time);
if (!keyframe) {
if (this.property.length > 0) {
if (time >= this.length) return this.property[this.property.length - 1].to;
if (time < 0) return this.property[0].from;
}
return 1;
}

const progress = (time - keyframe.start) / keyframe.length;
switch (keyframe.interpolation) {
const index = this.points.findLastIndex(point => point.time <= time);
const left = this.points[index];
const right = this.points[index + 1];
// Before the first point and after the last the curve holds that point's value; with no usable tween it reads 1.
if (!left || !right) return (left ?? right)?.value ?? 1;

const progress = (time - left.time) / (right.time - left.time);
switch (right.interpolation) {
case "bezier":
return this.cubicBuilder.getValue(keyframe.from, keyframe.to, progress, keyframe.easing);
return curves.getValue(left.value, right.value, progress, [left.handleRight, right.handleLeft]);
case "constant":
return keyframe.from;
return left.value;
case "linear":
default:
return keyframe.from + (keyframe.to - keyframe.from) * progress;
}
}

private findKeyframe(time: number): NumericKeyframe | undefined {
const props = this.property;
if (props.length === 0) return undefined;

const cached = props[this.cachedIndex];
if (cached) {
const cachedEnd = cached.start + cached.length;
if (Number.isFinite(cachedEnd) && time >= cached.start && time < cachedEnd) {
return cached;
}
}

const nextIdx = this.cachedIndex + 1;
if (nextIdx < props.length) {
const next = props[nextIdx];
const nextEnd = next.start + next.length;
if (Number.isFinite(nextEnd) && time >= next.start && time < nextEnd) {
this.cachedIndex = nextIdx;
return next;
}
}

const prevIdx = this.cachedIndex - 1;
if (prevIdx >= 0) {
const prev = props[prevIdx];
const prevEnd = prev.start + prev.length;
if (Number.isFinite(prevEnd) && time >= prev.start && time < prevEnd) {
this.cachedIndex = prevIdx;
return prev;
}
}

const idx = this.binarySearchKeyframe(time);
if (idx !== -1) {
this.cachedIndex = idx;
return props[idx];
}

return undefined;
}

private binarySearchKeyframe(time: number): number {
const props = this.property;
let low = 0;
let high = props.length - 1;

while (low <= high) {
const mid = Math.floor((low + high) / 2);
const kf = props[mid];
const end = kf.start + kf.length;

if (!Number.isFinite(end) || time >= end) {
low = mid + 1;
} else if (time < kf.start) {
high = mid - 1;
} else {
return mid;
}
return left.value + (right.value - left.value) * progress;
}

return -1;
}

private createKeyframes(value: AnimationKeyframe[] | number, length: number, initialValue = 0): NumericKeyframe[] {
private createPoints(value: AnimationKeyframe[] | number): Point[] {
if (typeof value === "number") {
return [{ start: 0, length, from: value, to: value }];
return [{ time: 0, value, interpolation: "constant", handleLeft: DEFAULT_LEFT, handleRight: DEFAULT_RIGHT }];
}

if (!value.length) {
Expand All @@ -124,12 +57,19 @@ export class KeyframeBuilder {
this.normaliseAdjacentBoundaries(normalizedKeyframes);
this.validateKeyframes(normalizedKeyframes);

return this.insertFillerKeyframes(normalizedKeyframes, length, initialValue);
// Adjacent tweens each keep a point at their shared time, so each keeps its own curve.
return normalizedKeyframes.flatMap(({ start, length, from, to, interpolation, easing }) => {
const [handleRight, handleLeft] = interpolation === "bezier" ? curves.getHandles(easing) : [DEFAULT_RIGHT, DEFAULT_LEFT];
return [
{ time: start, value: from, interpolation, handleLeft: DEFAULT_LEFT, handleRight },
{ time: start + length, value: to, interpolation, handleLeft, handleRight: DEFAULT_RIGHT }
];
});
}

private createNormalizedKeyframes(keyframes: AnimationKeyframe[]): NumericKeyframe[] {
return keyframes
.filter((kf): kf is AnimationKeyframe & { start: number; length: number } => typeof kf.start === "number" && typeof kf.length === "number")
.filter((kf): kf is AnimationKeyframe & { start: number; length: number } => Number.isFinite(kf.start) && Number.isFinite(kf.length))
.sort((a, b) => a.start - b.start)
.map(keyframe => ({
start: keyframe.start,
Expand Down Expand Up @@ -166,43 +106,4 @@ export class KeyframeBuilder {
}
}
}

private insertFillerKeyframes(keyframes: NumericKeyframe[], length: number, initialValue = 0): NumericKeyframe[] {
const updatedKeyframes: NumericKeyframe[] = [];

for (let i = 0; i < keyframes.length; i += 1) {
const current = keyframes[i];
const next = keyframes[i + 1];

const shouldFillStart = i === 0 && current.start > 0;
if (shouldFillStart) {
const fillerKeyframe: NumericKeyframe = { start: 0, length: current.start, from: initialValue, to: current.from };
updatedKeyframes.push(fillerKeyframe);
}

updatedKeyframes.push(current);

if (!next) {
const shouldFillEnd = length - (current.start + current.length) > 0;
if (shouldFillEnd) {
const currentStart = current.start + current.length;
const fillerKeyframe: NumericKeyframe = { start: currentStart, length: length - currentStart, from: current.to, to: current.to };

updatedKeyframes.push(fillerKeyframe);
}

break;
}

const shouldFillMiddle = next.start - (current.start + current.length) > 0;
if (shouldFillMiddle) {
const fillerStart = current.start + current.length;
const fillerLength = next.start - fillerStart;
const fillerKeyframe: NumericKeyframe = { start: fillerStart, length: fillerLength, from: current.to, to: next.from };
updatedKeyframes.push(fillerKeyframe);
}
}

return updatedKeyframes;
}
}
2 changes: 1 addition & 1 deletion src/core/animations/opacity-keyframes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function encodeOpacityPoints(points: readonly OpacityPoint[], clipLength:

export function evaluateOpacity(value: Clip["opacity"], localTime: number, clipLength: number): number | null {
try {
const evaluated = new KeyframeBuilder(value ?? 1, clipLength, 1).getValue(Math.max(0, Math.min(localTime, clipLength)));
const evaluated = new KeyframeBuilder(value ?? 1).getValue(Math.max(0, Math.min(localTime, clipLength)));
return Number.isFinite(evaluated) ? Math.max(0, Math.min(1, evaluated)) : null;
} catch {
return null;
Expand Down
Loading
Loading