From 8987f09f1dcca79a1392d5953c055dda20ae0697 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 26 Oct 2026 17:38:02 +1100 Subject: [PATCH] fix: hold the first keyframe value and ease gaps like the render --- src/components/canvas/players/audio-player.ts | 2 +- src/components/canvas/players/player.ts | 22 +-- src/components/canvas/players/video-player.ts | 18 +- .../animations/composed-keyframe-builder.ts | 7 +- src/core/animations/curve-interpolator.ts | 8 +- src/core/animations/keyframe-builder.ts | 161 ++++-------------- src/core/animations/opacity-keyframes.ts | 2 +- tests/keyframe-builder.test.ts | 136 +++++++-------- tests/player-sync.test.ts | 9 + 9 files changed, 139 insertions(+), 226 deletions(-) diff --git a/src/components/canvas/players/audio-player.ts b/src/components/canvas/players/audio-player.ts index 58db2b52..a01df824 100644 --- a/src/components/canvas/players/audio-player.ts +++ b/src/components/canvas/players/audio-player.ts @@ -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 { diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index ac014ee9..5e0bedc7 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -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)) { @@ -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; } diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index d8f63e86..a530a38d 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -33,9 +33,17 @@ function seekVideo(video: HTMLVideoElement, time: number): Promise (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) { @@ -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; @@ -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()); } diff --git a/src/core/animations/composed-keyframe-builder.ts b/src/core/animations/composed-keyframe-builder.ts index 61413f39..842a4337 100644 --- a/src/core/animations/composed-keyframe-builder.ts +++ b/src/core/animations/composed-keyframe-builder.ts @@ -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; } @@ -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)); } /** diff --git a/src/core/animations/curve-interpolator.ts b/src/core/animations/curve-interpolator.ts index 383e630b..da1f71cf 100644 --- a/src/core/animations/curve-interpolator.ts +++ b/src/core/animations/curve-interpolator.ts @@ -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, diff --git a/src/core/animations/keyframe-builder.ts b/src/core/animations/keyframe-builder.ts index bcd6cef6..c177a92a 100644 --- a/src/core/animations/keyframe-builder.ts +++ b/src/core/animations/keyframe-builder.ts @@ -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) { @@ -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, @@ -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; - } } diff --git a/src/core/animations/opacity-keyframes.ts b/src/core/animations/opacity-keyframes.ts index 33aa7f12..22148662 100644 --- a/src/core/animations/opacity-keyframes.ts +++ b/src/core/animations/opacity-keyframes.ts @@ -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; diff --git a/tests/keyframe-builder.test.ts b/tests/keyframe-builder.test.ts index e92a4eb0..95141092 100644 --- a/tests/keyframe-builder.test.ts +++ b/tests/keyframe-builder.test.ts @@ -3,7 +3,7 @@ import { KeyframeBuilder } from "../src/core/animations/keyframe-builder"; describe("KeyframeBuilder", () => { describe("getValue", () => { it("returns constant value for numeric input", () => { - const builder = new KeyframeBuilder(0.5, 10); + const builder = new KeyframeBuilder(0.5); expect(builder.getValue(0)).toBe(0.5); expect(builder.getValue(5)).toBe(0.5); @@ -12,7 +12,7 @@ describe("KeyframeBuilder", () => { it("interpolates between keyframe values", () => { const keyframes = [{ start: 0, length: 10, from: 0, to: 1 }]; - const builder = new KeyframeBuilder(keyframes, 10); + const builder = new KeyframeBuilder(keyframes); expect(builder.getValue(0)).toBe(0); expect(builder.getValue(5)).toBe(0.5); @@ -20,52 +20,62 @@ describe("KeyframeBuilder", () => { }); it("returns default when time is before first keyframe", () => { - const builder = new KeyframeBuilder(1, 10); + const builder = new KeyframeBuilder(1); expect(builder.getValue(-1)).toBe(1); }); it("returns last keyframe value when time exceeds length", () => { - const builder = new KeyframeBuilder(0.8, 10); + const builder = new KeyframeBuilder(0.8); expect(builder.getValue(15)).toBe(0.8); }); }); - describe("NaN length handling (regression test)", () => { - /** - * Regression test for volume NaN bug. - * - * When clip length is "end" and not properly resolved, getLength() returns NaN. - * The KeyframeBuilder must handle this gracefully by returning a valid default (1) - * rather than returning NaN which causes HTMLMediaElement errors: - * "Failed to set the 'volume' property: The provided double value is non-finite." - * - * The original .find() handled this because `time < NaN` returns false. - * The optimized binary search must match this behavior. - */ - it("returns valid default when length is NaN", () => { - const builder = new KeyframeBuilder(1, NaN); - - // Should return 1 (the default), not NaN - const value = builder.getValue(0); + describe("the render's tween semantics", () => { + it("holds the first tween's from value before it starts", () => { + const builder = new KeyframeBuilder([{ start: 2, length: 1, from: 0.5, to: 1 }]); - expect(Number.isFinite(value)).toBe(true); - expect(value).toBe(1); + expect(builder.getValue(0)).toBe(0.5); + expect(builder.getValue(1.5)).toBe(0.5); }); - it("returns valid default when length is Infinity", () => { - const builder = new KeyframeBuilder(1, Infinity); + it("bridges a gap with the next tween's interpolation", () => { + const builder = new KeyframeBuilder([ + { start: 0, length: 1, from: 0, to: 0.2, interpolation: "linear" }, + { start: 2, length: 1, from: 0.6, to: 1, interpolation: "linear" } + ]); - const value = builder.getValue(0); + expect(builder.getValue(1.5)).toBeCloseTo(0.4); + }); - expect(Number.isFinite(value)).toBe(true); + it("eases a gap into a Bezier tween along the default curve, whatever the tween's easing", () => { + const builder = new KeyframeBuilder([ + { start: 0, length: 1, from: 0, to: 0, interpolation: "linear" }, + { start: 2, length: 1, from: 1, to: 1, interpolation: "bezier", easing: "easeIn" } + ]); + + // cubic-bezier(0.5, 0, 0.5, 1) a quarter of the way through + expect(builder.getValue(1.25)).toBeCloseTo(0.1059, 3); }); + it("holds the previous value through a gap before a constant tween", () => { + const builder = new KeyframeBuilder([ + { start: 0, length: 1, from: 0, to: 0.2, interpolation: "linear" }, + { start: 2, length: 1, from: 0.8, to: 0.8, interpolation: "constant" } + ]); + + expect(builder.getValue(1.9)).toBe(0.2); + expect(builder.getValue(2)).toBe(0.8); + }); + }); + + describe("non-finite input (regression test)", () => { + // A NaN value reaching HTMLMediaElement.volume throws, so a broken tween must still give a finite value. it("handles NaN length with keyframe array input", () => { // Simulates a keyframe with invalid length that slipped through validation const keyframes = [{ start: 0, length: NaN, from: 0.5, to: 0.5 }]; - const builder = new KeyframeBuilder(keyframes, NaN); + const builder = new KeyframeBuilder(keyframes); const value = builder.getValue(0); @@ -74,7 +84,7 @@ describe("KeyframeBuilder", () => { }); it("never returns NaN for any reasonable time input", () => { - const builder = new KeyframeBuilder(1, 10); + const builder = new KeyframeBuilder(1); // Test various time values including edge cases const times = [-1, 0, 0.001, 5, 9.999, 10, 11, 100]; @@ -92,7 +102,7 @@ describe("KeyframeBuilder", () => { { start: 0, length: 5, from: 0, to: 0.5 }, { start: 5, length: 5, from: 0.5, to: 1 } ]; - const builder = new KeyframeBuilder(keyframes, 10); + const builder = new KeyframeBuilder(keyframes); // Simulate sequential playback - cache should provide O(1) lookups expect(builder.getValue(0)).toBe(0); @@ -108,7 +118,7 @@ describe("KeyframeBuilder", () => { { start: 0, length: 5, from: 0, to: 0.5 }, { start: 5, length: 5, from: 0.5, to: 1 } ]; - const builder = new KeyframeBuilder(keyframes, 10); + const builder = new KeyframeBuilder(keyframes); // Forward then backward expect(builder.getValue(8)).toBeCloseTo(0.8); @@ -125,7 +135,7 @@ describe("KeyframeBuilder", () => { { start: 6, length: 2, from: 0.6, to: 0.8 }, { start: 8, length: 2, from: 0.8, to: 1 } ]; - const builder = new KeyframeBuilder(keyframes, 10); + const builder = new KeyframeBuilder(keyframes); // Random access pattern expect(builder.getValue(9)).toBeCloseTo(0.9); @@ -138,14 +148,11 @@ describe("KeyframeBuilder", () => { describe("timeline boundary safety", () => { it("plays adjacent decimal segments whose normalised end still rounds past the next start", () => { - const builder = new KeyframeBuilder( - [ - { start: 0, length: 0.7, from: 0, to: 1 }, - { start: 0.7, length: 3.2, from: 1, to: 1 }, - { start: 3.9, length: 0.35, from: 1, to: 0 } - ], - 4.25 - ); + const builder = new KeyframeBuilder([ + { start: 0, length: 0.7, from: 0, to: 1 }, + { start: 0.7, length: 3.2, from: 1, to: 1 }, + { start: 3.9, length: 0.35, from: 1, to: 0 } + ]); expect(builder.getValue(0.35)).toBeCloseTo(0.5); expect(builder.getValue(2)).toBe(1); @@ -154,20 +161,17 @@ describe("KeyframeBuilder", () => { }); it("preserves and evaluates a keyframe that extends beyond a shortened clip", () => { - const builder = new KeyframeBuilder([{ start: 0, length: 10, from: 0, to: 1, interpolation: "linear" }], 5); + const builder = new KeyframeBuilder([{ start: 0, length: 10, from: 0, to: 1, interpolation: "linear" }]); expect(builder.getValue(5)).toBe(0.5); }); it("tolerates floating-point noise between adjacent frame-derived segments", () => { const boundary = 2 / 29.97; - const builder = new KeyframeBuilder( - [ - { start: 0, length: boundary + 1e-10, from: 0, to: 0.5, interpolation: "linear" }, - { start: boundary, length: 1, from: 0.5, to: 1, interpolation: "linear" } - ], - 2 - ); + const builder = new KeyframeBuilder([ + { start: 0, length: boundary + 1e-10, from: 0, to: 0.5, interpolation: "linear" }, + { start: boundary, length: 1, from: 0.5, to: 1, interpolation: "linear" } + ]); expect(builder.getValue(boundary)).toBeCloseTo(0.5); }); @@ -175,13 +179,10 @@ describe("KeyframeBuilder", () => { it("normalises tolerated overlaps so seek order cannot change the value", () => { const overlap = 5e-7; const overlapTime = 1 + overlap / 2; - const builder = new KeyframeBuilder( - [ - { start: 0, length: 1 + overlap, from: 0, to: 0, interpolation: "linear" }, - { start: 1, length: 1, from: 1, to: 1, interpolation: "linear" } - ], - 2 - ); + const builder = new KeyframeBuilder([ + { start: 0, length: 1 + overlap, from: 0, to: 0, interpolation: "linear" }, + { start: 1, length: 1, from: 1, to: 1, interpolation: "linear" } + ]); builder.getValue(0.5); const valueAfterEarlierSeek = builder.getValue(overlapTime); @@ -192,15 +193,11 @@ describe("KeyframeBuilder", () => { expect(valueAfterLaterSeek).toBe(valueAfterEarlierSeek); }); - it("fills a tiny positive gap instead of falling back to the initial value", () => { - const builder = new KeyframeBuilder( - [ - { start: 0, length: 1, from: 0, to: 0.2, interpolation: "linear" }, - { start: 1 + 1e-10, length: 1, from: 0.4, to: 0.6, interpolation: "linear" } - ], - 3, - 1 - ); + it("fills a tiny positive gap from the tweens either side", () => { + const builder = new KeyframeBuilder([ + { start: 0, length: 1, from: 0, to: 0.2, interpolation: "linear" }, + { start: 1 + 1e-10, length: 1, from: 0.4, to: 0.6, interpolation: "linear" } + ]); expect(builder.getValue(1 + 5e-11)).toBeLessThan(0.5); }); @@ -208,13 +205,10 @@ describe("KeyframeBuilder", () => { it("still rejects material overlaps", () => { expect( () => - new KeyframeBuilder( - [ - { start: 0, length: 1.01, from: 0, to: 0.5, interpolation: "linear" }, - { start: 1, length: 1, from: 0.5, to: 1, interpolation: "linear" } - ], - 2 - ) + new KeyframeBuilder([ + { start: 0, length: 1.01, from: 0, to: 0.5, interpolation: "linear" }, + { start: 1, length: 1, from: 0.5, to: 1, interpolation: "linear" } + ]) ).toThrow("Overlapping keyframes detected."); }); }); diff --git a/tests/player-sync.test.ts b/tests/player-sync.test.ts index bb5a1b65..e859b945 100644 --- a/tests/player-sync.test.ts +++ b/tests/player-sync.test.ts @@ -440,6 +440,15 @@ describe("animated speed", () => { expect(player.getSourceTime()).toBeCloseTo(1, 3); }); + it("bridges a gap between speed tweens linearly, whatever the next tween's interpolation", () => { + const speed: Tween[] = [ + { from: 1, to: 1, start: 0, length: 0.5 }, + { from: 3, to: 3, start: 1, length: 1, interpolation: "constant" } + ]; + // 0.5 s at 1× then 0.5 s ramping 1× → 3× + expect(new VideoPlayer(createMockEdit(1), createAnimatedSpeedClipConfig(speed)).getSourceTime()).toBeCloseTo(1.5, 2); + }); + it("freezes at trim when the speed is 0", () => { const player = new VideoPlayer(createMockEdit(1), createAnimatedSpeedClipConfig([{ from: 0, to: 0, start: 0, length: 2 }], 4)); expect(player.getSourceTime()).toBeCloseTo(4, 5);