Skip to content

Commit 0039a74

Browse files
authored
fix: hold the first keyframe value and ease gaps like the render (#253)
1 parent 4afc59a commit 0039a74

9 files changed

Lines changed: 139 additions & 226 deletions

File tree

‎src/components/canvas/players/audio-player.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export class AudioPlayer extends Player {
186186
private rebuildVolumeKeyframeBuilder(): void {
187187
const audioAsset = this.clipConfiguration.asset as AudioAsset;
188188
const baseVolume = this.getBaseVolume();
189-
this.volumeKeyframeBuilder = new KeyframeBuilder(this.createVolumeKeyframes(audioAsset, baseVolume), this.getLength(), baseVolume);
189+
this.volumeKeyframeBuilder = new KeyframeBuilder(this.createVolumeKeyframes(audioAsset, baseVolume));
190190
}
191191

192192
private createVolumeKeyframes(asset: AudioAsset, baseVolume: number): Keyframe[] | number {

‎src/components/canvas/players/player.ts‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,13 @@ export abstract class Player extends Entity {
157157
const baseSkewY = typeof config.transform?.skew?.y === "number" ? config.transform.skew.y : 0;
158158

159159
// Create composed builders with base values
160-
this.offsetXKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetX, length, "additive");
161-
this.offsetYKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetY, length, "additive");
162-
this.scaleKeyframeBuilder = new ComposedKeyframeBuilder(baseScale, length, "multiplicative");
163-
this.opacityKeyframeBuilder = new ComposedKeyframeBuilder(baseOpacity, length, "multiplicative", { min: 0, max: 1 });
164-
this.rotationKeyframeBuilder = new ComposedKeyframeBuilder(baseRotation, length, "additive");
165-
this.skewXKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewX, length, "additive");
166-
this.skewYKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewY, length, "additive");
160+
this.offsetXKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetX, "additive");
161+
this.offsetYKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetY, "additive");
162+
this.scaleKeyframeBuilder = new ComposedKeyframeBuilder(baseScale, "multiplicative");
163+
this.opacityKeyframeBuilder = new ComposedKeyframeBuilder(baseOpacity, "multiplicative", { min: 0, max: 1 });
164+
this.rotationKeyframeBuilder = new ComposedKeyframeBuilder(baseRotation, "additive");
165+
this.skewXKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewX, "additive");
166+
this.skewYKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewY, "additive");
167167

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

234234
// Wipe brightness sweep (wipeLeft/wipeRight) — driven through WipeFilter, not a mask.
235235
const brightnessKeyframes = [...transitionSet.in.brightnessKeyframes, ...transitionSet.out.brightnessKeyframes];
236236
if (brightnessKeyframes.length) {
237-
this.wipeBrightnessBuilder = new KeyframeBuilder(brightnessKeyframes, length);
237+
this.wipeBrightnessBuilder = new KeyframeBuilder(brightnessKeyframes);
238238
this.wipeInFromRight = transitionSet.in.wipeFromRight;
239239
this.wipeOutFromRight = transitionSet.out.wipeFromRight;
240240
this.wipeOutStart = transitionSet.out.brightnessKeyframes[0]?.start ?? Number.POSITIVE_INFINITY;
241241
}
242242

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

246246
// 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.
247247
const { origin: inOrigin } = transitionSet.in;
248248
const { origin: outOrigin } = transitionSet.out;
249249
const outStart = transitionSet.out.rotationKeyframes[0]?.start ?? length;
250250
const originCurve = (from = 0.5, to?: number) =>
251-
new KeyframeBuilder(to === undefined ? from : [{ from, to, start: 0, length: outStart, interpolation: "bezier", easing: "smooth" }], length);
251+
new KeyframeBuilder(to === undefined ? from : [{ from, to, start: 0, length: outStart, interpolation: "bezier", easing: "smooth" }]);
252252
this.rotationOrigin =
253253
inOrigin || outOrigin ? { x: originCurve(inOrigin?.x, outOrigin?.x), y: originCurve(inOrigin?.y, outOrigin?.y) } : undefined;
254254
}

‎src/components/canvas/players/video-player.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,17 @@ function seekVideo(video: HTMLVideoElement, time: number): Promise<string | unde
3333

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

7078
const videoAsset = this.clipConfiguration.asset as VideoAsset;
7179

72-
this.volumeKeyframeBuilder = new KeyframeBuilder(videoAsset.volume ?? 1, this.getLength());
80+
this.volumeKeyframeBuilder = new KeyframeBuilder(videoAsset.volume ?? 1);
7381
this.animatedSpeed = createAnimatedSpeed(videoAsset.speed, this.getLength());
7482
this.syncTimer = 0;
7583
this.activeSyncTimer = 0;
@@ -223,7 +231,7 @@ export class VideoPlayer extends Player {
223231
super.reconfigureAfterRestore();
224232

225233
const videoAsset = this.clipConfiguration.asset as VideoAsset;
226-
this.volumeKeyframeBuilder = new KeyframeBuilder(videoAsset.volume ?? 1, this.getLength());
234+
this.volumeKeyframeBuilder = new KeyframeBuilder(videoAsset.volume ?? 1);
227235
this.animatedSpeed = createAnimatedSpeed(videoAsset.speed, this.getLength());
228236
}
229237

‎src/core/animations/composed-keyframe-builder.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,10 @@ export class ComposedKeyframeBuilder {
1616
private readonly baseValue: number;
1717
private readonly mode: CompositionMode;
1818
private readonly layers: KeyframeBuilder[] = [];
19-
private readonly length: number;
2019
private readonly clampRange?: { min: number; max: number };
2120

22-
constructor(baseValue: number, length: number, mode: CompositionMode, clampRange?: { min: number; max: number }) {
21+
constructor(baseValue: number, mode: CompositionMode, clampRange?: { min: number; max: number }) {
2322
this.baseValue = baseValue;
24-
this.length = length;
2523
this.mode = mode;
2624
this.clampRange = clampRange;
2725
}
@@ -34,8 +32,7 @@ export class ComposedKeyframeBuilder {
3432
addLayer(keyframes: AnimationKeyframe[]): void {
3533
if (keyframes.length === 0) return;
3634

37-
const neutralValue = this.mode === "additive" ? 0 : 1;
38-
this.layers.push(new KeyframeBuilder(keyframes, this.length, neutralValue));
35+
this.layers.push(new KeyframeBuilder(keyframes));
3936
}
4037

4138
/**

‎src/core/animations/curve-interpolator.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,15 @@ export class CurveInterpolator {
132132
};
133133
}
134134

135-
public getValue(from: number, to: number, progress: number, easing?: string): number {
135+
/** An easing's handles, leaving the start then entering the end; the render reads an unknown name as `ease`. */
136+
public getHandles(easing?: string): number[][] {
137+
return this.curves[easing ?? ""] ?? this.curves["ease"];
138+
}
139+
140+
public getValue(from: number, to: number, progress: number, handles: number[][]): number {
136141
if (progress <= 0) return from;
137142
if (progress >= 1) return to;
138143

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

142146
// Standard cubic-bezier timing function: solve the X-axis bezier for t at this progress,

‎src/core/animations/keyframe-builder.ts‎

Lines changed: 31 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -10,109 +10,42 @@ import { CurveInterpolator } from "./curve-interpolator";
1010
*/
1111
export const TIME_EPSILON = 1e-6;
1212

13-
export class KeyframeBuilder {
14-
private readonly property: NumericKeyframe[];
15-
private readonly length: number;
16-
17-
private readonly cubicBuilder: CurveInterpolator;
13+
/** A curve point as the render keeps them: a segment takes its right point's interpolation and the handles facing into it. */
14+
type Point = { time: number; value: number; interpolation: NumericKeyframe["interpolation"]; handleLeft: number[]; handleRight: number[] };
1815

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

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

25-
this.cubicBuilder = new CurveInterpolator();
23+
constructor(value: AnimationKeyframe[] | number) {
24+
this.points = this.createPoints(value);
2625
}
2726

2827
public getValue(time: number): number {
29-
const keyframe = this.findKeyframe(time);
30-
if (!keyframe) {
31-
if (this.property.length > 0) {
32-
if (time >= this.length) return this.property[this.property.length - 1].to;
33-
if (time < 0) return this.property[0].from;
34-
}
35-
return 1;
36-
}
37-
38-
const progress = (time - keyframe.start) / keyframe.length;
39-
switch (keyframe.interpolation) {
28+
const index = this.points.findLastIndex(point => point.time <= time);
29+
const left = this.points[index];
30+
const right = this.points[index + 1];
31+
// Before the first point and after the last the curve holds that point's value; with no usable tween it reads 1.
32+
if (!left || !right) return (left ?? right)?.value ?? 1;
33+
34+
const progress = (time - left.time) / (right.time - left.time);
35+
switch (right.interpolation) {
4036
case "bezier":
41-
return this.cubicBuilder.getValue(keyframe.from, keyframe.to, progress, keyframe.easing);
37+
return curves.getValue(left.value, right.value, progress, [left.handleRight, right.handleLeft]);
4238
case "constant":
43-
return keyframe.from;
39+
return left.value;
4440
case "linear":
4541
default:
46-
return keyframe.from + (keyframe.to - keyframe.from) * progress;
47-
}
48-
}
49-
50-
private findKeyframe(time: number): NumericKeyframe | undefined {
51-
const props = this.property;
52-
if (props.length === 0) return undefined;
53-
54-
const cached = props[this.cachedIndex];
55-
if (cached) {
56-
const cachedEnd = cached.start + cached.length;
57-
if (Number.isFinite(cachedEnd) && time >= cached.start && time < cachedEnd) {
58-
return cached;
59-
}
60-
}
61-
62-
const nextIdx = this.cachedIndex + 1;
63-
if (nextIdx < props.length) {
64-
const next = props[nextIdx];
65-
const nextEnd = next.start + next.length;
66-
if (Number.isFinite(nextEnd) && time >= next.start && time < nextEnd) {
67-
this.cachedIndex = nextIdx;
68-
return next;
69-
}
70-
}
71-
72-
const prevIdx = this.cachedIndex - 1;
73-
if (prevIdx >= 0) {
74-
const prev = props[prevIdx];
75-
const prevEnd = prev.start + prev.length;
76-
if (Number.isFinite(prevEnd) && time >= prev.start && time < prevEnd) {
77-
this.cachedIndex = prevIdx;
78-
return prev;
79-
}
80-
}
81-
82-
const idx = this.binarySearchKeyframe(time);
83-
if (idx !== -1) {
84-
this.cachedIndex = idx;
85-
return props[idx];
86-
}
87-
88-
return undefined;
89-
}
90-
91-
private binarySearchKeyframe(time: number): number {
92-
const props = this.property;
93-
let low = 0;
94-
let high = props.length - 1;
95-
96-
while (low <= high) {
97-
const mid = Math.floor((low + high) / 2);
98-
const kf = props[mid];
99-
const end = kf.start + kf.length;
100-
101-
if (!Number.isFinite(end) || time >= end) {
102-
low = mid + 1;
103-
} else if (time < kf.start) {
104-
high = mid - 1;
105-
} else {
106-
return mid;
107-
}
42+
return left.value + (right.value - left.value) * progress;
10843
}
109-
110-
return -1;
11144
}
11245

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

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

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

13070
private createNormalizedKeyframes(keyframes: AnimationKeyframe[]): NumericKeyframe[] {
13171
return keyframes
132-
.filter((kf): kf is AnimationKeyframe & { start: number; length: number } => typeof kf.start === "number" && typeof kf.length === "number")
72+
.filter((kf): kf is AnimationKeyframe & { start: number; length: number } => Number.isFinite(kf.start) && Number.isFinite(kf.length))
13373
.sort((a, b) => a.start - b.start)
13474
.map(keyframe => ({
13575
start: keyframe.start,
@@ -166,43 +106,4 @@ export class KeyframeBuilder {
166106
}
167107
}
168108
}
169-
170-
private insertFillerKeyframes(keyframes: NumericKeyframe[], length: number, initialValue = 0): NumericKeyframe[] {
171-
const updatedKeyframes: NumericKeyframe[] = [];
172-
173-
for (let i = 0; i < keyframes.length; i += 1) {
174-
const current = keyframes[i];
175-
const next = keyframes[i + 1];
176-
177-
const shouldFillStart = i === 0 && current.start > 0;
178-
if (shouldFillStart) {
179-
const fillerKeyframe: NumericKeyframe = { start: 0, length: current.start, from: initialValue, to: current.from };
180-
updatedKeyframes.push(fillerKeyframe);
181-
}
182-
183-
updatedKeyframes.push(current);
184-
185-
if (!next) {
186-
const shouldFillEnd = length - (current.start + current.length) > 0;
187-
if (shouldFillEnd) {
188-
const currentStart = current.start + current.length;
189-
const fillerKeyframe: NumericKeyframe = { start: currentStart, length: length - currentStart, from: current.to, to: current.to };
190-
191-
updatedKeyframes.push(fillerKeyframe);
192-
}
193-
194-
break;
195-
}
196-
197-
const shouldFillMiddle = next.start - (current.start + current.length) > 0;
198-
if (shouldFillMiddle) {
199-
const fillerStart = current.start + current.length;
200-
const fillerLength = next.start - fillerStart;
201-
const fillerKeyframe: NumericKeyframe = { start: fillerStart, length: fillerLength, from: current.to, to: next.from };
202-
updatedKeyframes.push(fillerKeyframe);
203-
}
204-
}
205-
206-
return updatedKeyframes;
207-
}
208109
}

‎src/core/animations/opacity-keyframes.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export function encodeOpacityPoints(points: readonly OpacityPoint[], clipLength:
9898

9999
export function evaluateOpacity(value: Clip["opacity"], localTime: number, clipLength: number): number | null {
100100
try {
101-
const evaluated = new KeyframeBuilder(value ?? 1, clipLength, 1).getValue(Math.max(0, Math.min(localTime, clipLength)));
101+
const evaluated = new KeyframeBuilder(value ?? 1).getValue(Math.max(0, Math.min(localTime, clipLength)));
102102
return Number.isFinite(evaluated) ? Math.max(0, Math.min(1, evaluated)) : null;
103103
} catch {
104104
return null;

0 commit comments

Comments
 (0)