From c24aa8d2daa695090eaf690b8524d931cd864432 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 26 Oct 2026 21:36:54 +1100 Subject: [PATCH 1/7] refactor: give the preset builders their own keyframe type --- src/components/canvas/players/player.ts | 6 +++--- src/core/animations/composed-keyframe-builder.ts | 4 ++-- src/core/animations/keyframe-builder.ts | 10 +++++----- src/core/animations/transition-preset-builder.ts | 16 ++++++++-------- src/core/schemas/index.ts | 5 ++++- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 1624730c..40e00037 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -19,7 +19,7 @@ import { import { Pointer } from "@inputs/pointer"; import { type Size, type Vector } from "@layouts/geometry"; import { relativeToAbsolute, absoluteToRelative } from "@layouts/position-builder"; -import { type Clip, type ResolvedClip, type Keyframe } from "@schemas"; +import { type Clip, type ResolvedClip } from "@schemas"; import * as pixi from "pixi.js"; import { Entity } from "../../../core/shared/entity"; @@ -222,13 +222,13 @@ export abstract class Player extends Entity { this.rotationKeyframeBuilder.addLayer(transitionSet.out.rotationKeyframes); // Mask keyframes (reveal effect) - const maskXKeyframes: Keyframe[] = [...transitionSet.in.maskXKeyframes, ...transitionSet.out.maskXKeyframes]; + const maskXKeyframes = [...transitionSet.in.maskXKeyframes, ...transitionSet.out.maskXKeyframes]; if (maskXKeyframes.length) { this.maskXKeyframeBuilder = new KeyframeBuilder(maskXKeyframes, length); } // Wipe brightness sweep (wipeLeft/wipeRight) — driven through WipeFilter, not a mask. - const brightnessKeyframes: Keyframe[] = [...transitionSet.in.brightnessKeyframes, ...transitionSet.out.brightnessKeyframes]; + const brightnessKeyframes = [...transitionSet.in.brightnessKeyframes, ...transitionSet.out.brightnessKeyframes]; if (brightnessKeyframes.length) { this.wipeBrightnessBuilder = new KeyframeBuilder(brightnessKeyframes, length); this.wipeInFromRight = transitionSet.in.wipeFromRight; diff --git a/src/core/animations/composed-keyframe-builder.ts b/src/core/animations/composed-keyframe-builder.ts index 0fd009c0..61413f39 100644 --- a/src/core/animations/composed-keyframe-builder.ts +++ b/src/core/animations/composed-keyframe-builder.ts @@ -1,4 +1,4 @@ -import { type Keyframe } from "@schemas"; +import { type AnimationKeyframe } from "@schemas"; import { KeyframeBuilder } from "./keyframe-builder"; @@ -31,7 +31,7 @@ export class ComposedKeyframeBuilder { * For additive mode, keyframes should represent deltas (e.g., 0 → 0.1 means "move by 0.1") * For multiplicative mode, keyframes should represent factors (e.g., 1 → 1.3 means "scale by 1.3x") */ - addLayer(keyframes: Keyframe[]): void { + addLayer(keyframes: AnimationKeyframe[]): void { if (keyframes.length === 0) return; const neutralValue = this.mode === "additive" ? 0 : 1; diff --git a/src/core/animations/keyframe-builder.ts b/src/core/animations/keyframe-builder.ts index 21c1b32d..bcd6cef6 100644 --- a/src/core/animations/keyframe-builder.ts +++ b/src/core/animations/keyframe-builder.ts @@ -1,4 +1,4 @@ -import { type Keyframe, type NumericKeyframe } from "@schemas"; +import { type AnimationKeyframe, type NumericKeyframe } from "@schemas"; import { CurveInterpolator } from "./curve-interpolator"; @@ -18,7 +18,7 @@ export class KeyframeBuilder { private cachedIndex = 0; - constructor(value: Keyframe[] | number, length: number, initialValue = 0) { + constructor(value: AnimationKeyframe[] | number, length: number, initialValue = 0) { this.property = this.createKeyframes(value, length, initialValue); this.length = length; @@ -110,7 +110,7 @@ export class KeyframeBuilder { return -1; } - private createKeyframes(value: Keyframe[] | number, length: number, initialValue = 0): NumericKeyframe[] { + private createKeyframes(value: AnimationKeyframe[] | number, length: number, initialValue = 0): NumericKeyframe[] { if (typeof value === "number") { return [{ start: 0, length, from: value, to: value }]; } @@ -127,9 +127,9 @@ export class KeyframeBuilder { return this.insertFillerKeyframes(normalizedKeyframes, length, initialValue); } - private createNormalizedKeyframes(keyframes: Keyframe[]): NumericKeyframe[] { + private createNormalizedKeyframes(keyframes: AnimationKeyframe[]): NumericKeyframe[] { return keyframes - .filter((kf): kf is Keyframe & { start: number; length: number } => typeof kf.start === "number" && typeof kf.length === "number") + .filter((kf): kf is AnimationKeyframe & { start: number; length: number } => typeof kf.start === "number" && typeof kf.length === "number") .sort((a, b) => a.start - b.start) .map(keyframe => ({ start: keyframe.start, diff --git a/src/core/animations/transition-preset-builder.ts b/src/core/animations/transition-preset-builder.ts index 944bb726..2dcbec68 100644 --- a/src/core/animations/transition-preset-builder.ts +++ b/src/core/animations/transition-preset-builder.ts @@ -1,14 +1,14 @@ -import { type ResolvedClip, type Keyframe } from "@schemas"; +import { type ResolvedClip, type AnimationKeyframe } from "@schemas"; export type TransitionKeyframeSet = { - offsetXKeyframes: Keyframe[]; - offsetYKeyframes: Keyframe[]; - opacityKeyframes: Keyframe[]; - scaleKeyframes: Keyframe[]; - rotationKeyframes: Keyframe[]; - maskXKeyframes: Keyframe[]; + offsetXKeyframes: AnimationKeyframe[]; + offsetYKeyframes: AnimationKeyframe[]; + opacityKeyframes: AnimationKeyframe[]; + scaleKeyframes: AnimationKeyframe[]; + rotationKeyframes: AnimationKeyframe[]; + maskXKeyframes: AnimationKeyframe[]; /** Wipe brightness sweep (+1 hidden → −1 revealed), applied through WipeFilter. */ - brightnessKeyframes: Keyframe[]; + brightnessKeyframes: AnimationKeyframe[]; /** Wipe reveal direction for this phase: true = from the right edge inward, false = from the left. */ wipeFromRight: boolean; }; diff --git a/src/core/schemas/index.ts b/src/core/schemas/index.ts index ac339e8d..4d14c6aa 100644 --- a/src/core/schemas/index.ts +++ b/src/core/schemas/index.ts @@ -122,13 +122,16 @@ export type ExtendedCaptionAsset = CaptionAsset & { // ─── Internal Animation Types ─────────────────────────────────────────────── // Keyframes with all required fields and numeric values for animation interpolation +/** A tween as the animation code reads it. */ +export type AnimationKeyframe = Tween; + export interface NumericKeyframe { start: number; length: number; from: number; to: number; interpolation?: Tween["interpolation"]; - easing?: Tween["easing"]; + easing?: AnimationKeyframe["easing"]; } // ─── Zod Schemas (for validation) ────────────────────────────────────────── From 94d7938bababb6f0452ab7a27f8a3bfc2b26d004 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 26 Oct 2026 17:29:08 +1100 Subject: [PATCH 2/7] fix: preview transitions with the render's durations, curves and directions --- src/components/canvas/players/player.ts | 30 +++++++ .../animations/transition-preset-builder.ts | 89 ++++++++----------- src/core/schemas/index.ts | 4 +- tests/transition-preset-builder.test.ts | 87 ++++++++++++++++++ 4 files changed, 156 insertions(+), 54 deletions(-) create mode 100644 tests/transition-preset-builder.test.ts diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 40e00037..fe8a072a 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -100,6 +100,8 @@ export abstract class Player extends Entity { private wipeOutFromRight: boolean = false; private wipeOutStart: number = Number.POSITIVE_INFINITY; private wipeFilter: WipeFilter | null = null; + private zoomBlurBuilder?: KeyframeBuilder; + private zoomBlurFilter: pixi.BlurFilter | null = null; private wipeMask: pixi.Graphics | null; protected lumaWrapper: pixi.Container; @@ -235,6 +237,9 @@ export abstract class Player extends Entity { 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; } protected recordLoadError(error: unknown): void { @@ -298,6 +303,7 @@ export abstract class Player extends Entity { // Update wipe/reveal mask animation this.updateWipeMask(); this.updateWipeFilter(); + this.updateZoomBlur(); if (this.shouldDiscardFrame()) { this.contentContainer.alpha = 0; @@ -351,11 +357,35 @@ export abstract class Player extends Entity { this.wipeFilter = null; } + private updateZoomBlur(): void { + if (!this.zoomBlurBuilder) { + if (!this.zoomBlurFilter) return; + this.getContainer().filters = []; + this.zoomBlurFilter.destroy(); + this.zoomBlurFilter = null; + return; + } + if (!this.zoomBlurFilter) { + this.zoomBlurFilter = new pixi.BlurFilter(); + this.getContainer().filters = [this.zoomBlurFilter]; + } + // The render blurs by a whole-pixel radius before applying the clip's scale, so on screen the blur grows with scale + // and the canvas zoom. pixi's Gaussian approximates the render's three box-blur passes. + const time = this.getPlaybackTime(); + const radius = Math.trunc(this.zoomBlurBuilder.getValue(time)); + const canvas = this.getContainer().parent?.worldTransform; + const canvasZoom = canvas ? Math.hypot(canvas.a, canvas.b) : 1; + this.zoomBlurFilter.enabled = radius > 0; + this.zoomBlurFilter.strength = radius * (this.scaleKeyframeBuilder?.getValue(time) ?? 1) * canvasZoom; + } + public override dispose(): void { this.wipeMask?.destroy(); this.wipeMask = null; this.wipeFilter?.destroy(); this.wipeFilter = null; + this.zoomBlurFilter?.destroy(); + this.zoomBlurFilter = null; this.contentContainer?.destroy(); this.lumaWrapper?.destroy(); diff --git a/src/core/animations/transition-preset-builder.ts b/src/core/animations/transition-preset-builder.ts index 2dcbec68..3161618b 100644 --- a/src/core/animations/transition-preset-builder.ts +++ b/src/core/animations/transition-preset-builder.ts @@ -9,6 +9,8 @@ export type TransitionKeyframeSet = { maskXKeyframes: AnimationKeyframe[]; /** Wipe brightness sweep (+1 hidden → −1 revealed), applied through WipeFilter. */ brightnessKeyframes: AnimationKeyframe[]; + /** Zoom blur radius, in pixels of the clip before its scale applies. */ + blurKeyframes: AnimationKeyframe[]; /** Wipe reveal direction for this phase: true = from the right edge inward, false = from the left. */ wipeFromRight: boolean; }; @@ -20,6 +22,11 @@ export type RelativeTransitionKeyframeSet = { type ParsedTransition = { name: string; speed: string | undefined }; +// The render's presets use its default Bezier handles, cubic-bezier(0.5, 0, 0.5, 1). +const SMOOTH = { interpolation: "bezier", easing: "smooth" } as const; +const LINEAR = { interpolation: "linear" } as const; +type Curve = typeof SMOOTH | typeof LINEAR; + export class TransitionPresetBuilder { private readonly clipConfiguration: ResolvedClip; private readonly inPreset: ParsedTransition; @@ -33,7 +40,7 @@ export class TransitionPresetBuilder { } private parseTransition(value: string | undefined): ParsedTransition { - const [name, speed] = (value ?? "").split(/(Slow|Fast|VeryFast)/); + const [name, speed] = (value ?? "").split(/(Slow|Fast)/); return { name, speed }; } @@ -53,6 +60,7 @@ export class TransitionPresetBuilder { rotationKeyframes: [], maskXKeyframes: [], brightnessKeyframes: [], + blurKeyframes: [], wipeFromRight: false }; } @@ -68,75 +76,54 @@ export class TransitionPresetBuilder { const transitionName = this.getPresetName(direction); const isIn = direction === "in"; + // The render eases between a point at the clip's edge and one where the transition meets the clip, and each + // segment takes its later point's curve: an in transition follows the inner point, an out transition the edge. + const ramp = (edge: number, rest: number, edgeCurve: Curve, innerCurve: Curve): AnimationKeyframe => + isIn ? { from: edge, to: rest, start, length, ...innerCurve } : { from: rest, to: edge, start, length, ...edgeCurve }; + const fade = ramp(0, 1, SMOOTH, SMOOTH); + switch (transitionName) { case "fade": { - const [from, to] = isIn ? [0, 1] : [1, 0]; - keyframes.opacityKeyframes.push({ from, to, start, length, interpolation: "bezier", easing: "easeInOut" }); + keyframes.opacityKeyframes.push(fade); break; } case "zoom": { - const [scaleFrom, scaleTo] = isIn ? [10, 1] : [1, 10]; - const [opacityFrom, opacityTo] = isIn ? [0, 1] : [1, 0]; - const easing = isIn ? "easeIn" : "easeOut"; - if (isIn) { - keyframes.scaleKeyframes.push({ from: scaleFrom, to: scaleTo, start, length, interpolation: "linear" }); - } else { - keyframes.scaleKeyframes.push({ from: scaleFrom, to: scaleTo, start, length, interpolation: "bezier", easing }); - } - keyframes.opacityKeyframes.push({ from: opacityFrom, to: opacityTo, start, length, interpolation: "bezier", easing }); + keyframes.scaleKeyframes.push(ramp(10, 1, SMOOTH, LINEAR)); + keyframes.opacityKeyframes.push(fade); + keyframes.blurKeyframes.push(ramp(5, 0, SMOOTH, SMOOTH)); break; } case "slideLeft": case "slideRight": case "slideUp": - case "slideDown": { - const isHorizontal = transitionName === "slideLeft" || transitionName === "slideRight"; - const isNegative = transitionName === "slideLeft" || transitionName === "slideUp"; - const offset = 0.025; - - const [offsetFrom, offsetTo] = isIn ? [isNegative ? offset : -offset, 0] : [0, isNegative ? -offset : offset]; - const interpolation = isIn ? "linear" : "bezier"; - const [opacityFrom, opacityTo] = isIn ? [0, 1] : [1, 0]; - - const targetKeyframes = isHorizontal ? keyframes.offsetXKeyframes : keyframes.offsetYKeyframes; - targetKeyframes.push( - isIn - ? { from: offsetFrom, to: offsetTo, start, length, interpolation } - : { from: offsetFrom, to: offsetTo, start, length, interpolation, easing: "ease" } - ); - keyframes.opacityKeyframes.push({ from: opacityFrom, to: opacityTo, start, length, interpolation: "bezier", easing: "ease" }); - break; - } + case "slideDown": case "carouselLeft": case "carouselRight": case "carouselUp": case "carouselDown": { - const isHorizontal = transitionName === "carouselLeft" || transitionName === "carouselRight"; - const isNegative = transitionName === "carouselLeft" || transitionName === "carouselUp"; - - // Carousel uses different offsets for in vs out, and different interpolation - const baseOffset = (isHorizontal && 1) || (isIn ? 1.05 : 1.1); - const [offsetFrom, offsetTo] = isIn ? [isNegative ? baseOffset : -baseOffset, 0] : [0, isNegative ? -baseOffset : baseOffset]; + const isSlide = transitionName.startsWith("slide"); + const isHorizontal = transitionName.endsWith("Left") || transitionName.endsWith("Right"); + // Fractions of the frame, matching the render; vertical carousels travel further to clear the edge. + let distance = isSlide ? 0.025 : 1; + if (!isSlide && !isHorizontal) distance = isIn ? 1.05 : 1.1; + // Left and down enter from the positive side of their axis (offset y points up) and leave by the other. + const entersFromPositive = transitionName.endsWith("Left") || transitionName.endsWith("Down"); + const edge = (isIn === entersFromPositive ? 1 : -1) * distance; const targetKeyframes = isHorizontal ? keyframes.offsetXKeyframes : keyframes.offsetYKeyframes; - targetKeyframes.push( - isIn - ? { from: offsetFrom, to: offsetTo, start, length, interpolation: "linear" } - : { from: offsetFrom, to: offsetTo, start, length, interpolation: "bezier", easing: "ease" } - ); + targetKeyframes.push(ramp(edge, 0, SMOOTH, LINEAR)); + if (isSlide) keyframes.opacityKeyframes.push(fade); break; } case "reveal": { - const [from, to] = isIn ? [0, 1] : [1, 0]; - keyframes.maskXKeyframes.push({ from, to, start, length, interpolation: "bezier", easing: "ease" }); + keyframes.maskXKeyframes.push(ramp(0, 1, SMOOTH, SMOOTH)); break; } case "wipeLeft": case "wipeRight": { // Brightness sweeps +1 (hidden) → −1 (revealed) in, and back out, applied via WipeFilter. // wipeLeft reveals from the right edge inward; wipeRight from the left. - const [from, to] = isIn ? [1, -1] : [-1, 1]; - keyframes.brightnessKeyframes.push({ from, to, start, length, interpolation: "bezier", easing: "easeInOut" }); + keyframes.brightnessKeyframes.push(ramp(1, -1, SMOOTH, SMOOTH)); keyframes.wipeFromRight = transitionName === "wipeLeft" ? isIn : !isIn; break; } @@ -153,20 +140,18 @@ export class TransitionPresetBuilder { private getPresetLength(direction: "in" | "out"): number { const { name, speed } = direction === "in" ? this.inPreset : this.outPreset; - const isCarousel = name.startsWith("carousel"); - const isSlide = name.startsWith("slide"); if (name === "zoom") return 0.4; + // Matches the render: slides and carousels take half as long as other transitions at every speed. + const base = name.startsWith("slide") || name.startsWith("carousel") ? 0.5 : 1; switch (speed) { case "Slow": - return 2; + return base * 2; case "Fast": - return isCarousel || isSlide ? 0.25 : 0.5; - case "VeryFast": - return 0.25; + return base / 2; default: - return isCarousel || isSlide ? 0.5 : 1; + return base; } } } diff --git a/src/core/schemas/index.ts b/src/core/schemas/index.ts index 4d14c6aa..5b582eb6 100644 --- a/src/core/schemas/index.ts +++ b/src/core/schemas/index.ts @@ -122,8 +122,8 @@ export type ExtendedCaptionAsset = CaptionAsset & { // ─── Internal Animation Types ─────────────────────────────────────────────── // Keyframes with all required fields and numeric values for animation interpolation -/** A tween as the animation code reads it. */ -export type AnimationKeyframe = Tween; +/** A tween as the animation code reads it. `smooth` is the render's default curve, which its presets use. */ +export type AnimationKeyframe = Omit & { easing?: Tween["easing"] | "smooth" }; export interface NumericKeyframe { start: number; diff --git a/tests/transition-preset-builder.test.ts b/tests/transition-preset-builder.test.ts new file mode 100644 index 00000000..70a26a19 --- /dev/null +++ b/tests/transition-preset-builder.test.ts @@ -0,0 +1,87 @@ +import { TransitionPresetBuilder } from "@animations/transition-preset-builder"; +import { sec } from "@core/timing/types"; +import type { ResolvedClip } from "@schemas"; + +const build = (transition: ResolvedClip["transition"]) => + new TransitionPresetBuilder({ + id: "clip-1", + asset: { type: "image", src: "https://example.com/a.png" }, + start: sec(0), + length: sec(5), + transition + }).buildRelative(); + +const SMOOTH = { interpolation: "bezier", easing: "smooth" }; + +describe("TransitionPresetBuilder", () => { + it.each([ + ["slideLeftSlow", 1], + ["slideLeft", 0.5], + ["slideLeftFast", 0.25], + ["carouselUpSlow", 1], + ["carouselUpFast", 0.25], + ["fadeSlow", 2], + ["fadeFast", 0.5], + ["zoom", 0.4] + ])("runs %s for the render's %s seconds", (name, length) => { + const { in: phase } = build({ in: name as never }); + const [keyframe] = [...phase.offsetXKeyframes, ...phase.offsetYKeyframes, ...phase.opacityKeyframes]; + + expect(keyframe.length).toBe(length); + }); + + it("eases fades with the render's default curve", () => { + const { in: fadeIn, out: fadeOut } = build({ in: "fade", out: "fade" }); + + expect(fadeIn.opacityKeyframes).toEqual([{ from: 0, to: 1, start: 0, length: 1, ...SMOOTH }]); + expect(fadeOut.opacityKeyframes).toEqual([{ from: 1, to: 0, start: 4, length: 1, ...SMOOTH }]); + }); + + it("moves slides linearly on the way in and along the default curve on the way out", () => { + const { in: slideIn, out: slideOut } = build({ in: "slideLeft", out: "slideLeft" }); + + expect(slideIn.offsetXKeyframes).toEqual([{ from: 0.025, to: 0, start: 0, length: 0.5, interpolation: "linear" }]); + expect(slideOut.offsetXKeyframes).toEqual([{ from: 0, to: -0.025, start: 4.5, length: 0.5, ...SMOOTH }]); + expect(slideIn.opacityKeyframes).toEqual([{ from: 0, to: 1, start: 0, length: 0.5, ...SMOOTH }]); + }); + + it.each([ + ["slideUp", -0.025, 0.025], + ["slideDown", 0.025, -0.025], + ["carouselUp", -1.05, 1.1], + ["carouselDown", 1.05, -1.1] + ])("moves %s the way its name says (offset y points up)", (name, inFrom, outTo) => { + const { in: phaseIn, out: phaseOut } = build({ in: name as never, out: name as never }); + + expect(phaseIn.offsetYKeyframes[0]).toMatchObject({ from: inFrom, to: 0 }); + expect(phaseOut.offsetYKeyframes[0]).toMatchObject({ from: 0, to: outTo }); + }); + + it("eases carousels out along the default curve", () => { + expect(build({ out: "carouselLeft" }).out.offsetXKeyframes).toEqual([{ from: 0, to: -1, start: 4.5, length: 0.5, ...SMOOTH }]); + }); + + it("zooms in linearly, zooms out along the default curve and fades both ways on the default curve", () => { + const { in: zoomIn, out: zoomOut } = build({ in: "zoom", out: "zoom" }); + + expect(zoomIn.scaleKeyframes).toEqual([{ from: 10, to: 1, start: 0, length: 0.4, interpolation: "linear" }]); + expect(zoomOut.scaleKeyframes).toEqual([{ from: 1, to: 10, start: 4.6, length: 0.4, ...SMOOTH }]); + expect(zoomIn.opacityKeyframes[0]).toMatchObject(SMOOTH); + expect(zoomOut.opacityKeyframes[0]).toMatchObject(SMOOTH); + }); + + it("blurs zooms by 5 pixels at the clip's edge", () => { + const { in: zoomIn, out: zoomOut } = build({ in: "zoom", out: "zoom" }); + + expect(zoomIn.blurKeyframes).toEqual([{ from: 5, to: 0, start: 0, length: 0.4, ...SMOOTH }]); + expect(zoomOut.blurKeyframes).toEqual([{ from: 0, to: 5, start: 4.6, length: 0.4, ...SMOOTH }]); + }); + + it("eases reveals and wipes along the default curve", () => { + const { in: reveal } = build({ in: "reveal" }); + const { in: wipe } = build({ in: "wipeLeft" }); + + expect(reveal.maskXKeyframes[0]).toMatchObject(SMOOTH); + expect(wipe.brightnessKeyframes[0]).toMatchObject(SMOOTH); + }); +}); From 686584b2d7d90c993ec9f1267f490652a1cce116 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 26 Oct 2026 17:32:43 +1100 Subject: [PATCH 3/7] fix: preview shuffle transitions --- src/components/canvas/players/player.ts | 26 ++++++++++++--- .../animations/transition-preset-builder.ts | 24 +++++++++++++- src/core/layouts/position-builder.ts | 13 ++++++++ tests/export-preflight.test.ts | 8 +++-- tests/position-builder.test.ts | 18 +++++++++- tests/transition-preset-builder.test.ts | 33 +++++++++++++++++-- 6 files changed, 111 insertions(+), 11 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index fe8a072a..46542019 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -18,7 +18,7 @@ import { } from "@core/timing/types"; import { Pointer } from "@inputs/pointer"; import { type Size, type Vector } from "@layouts/geometry"; -import { relativeToAbsolute, absoluteToRelative } from "@layouts/position-builder"; +import { relativeToAbsolute, absoluteToRelative, rotationOriginOffset } from "@layouts/position-builder"; import { type Clip, type ResolvedClip } from "@schemas"; import * as pixi from "pixi.js"; @@ -102,6 +102,7 @@ export abstract class Player extends Entity { private wipeFilter: WipeFilter | null = null; private zoomBlurBuilder?: KeyframeBuilder; private zoomBlurFilter: pixi.BlurFilter | null = null; + private rotationOrigin?: { x: KeyframeBuilder; y: KeyframeBuilder }; private wipeMask: pixi.Graphics | null; protected lumaWrapper: pixi.Container; @@ -240,6 +241,23 @@ export abstract class Player extends Entity { const blurKeyframes = [...transitionSet.in.blurKeyframes, ...transitionSet.out.blurKeyframes]; this.zoomBlurBuilder = blurKeyframes.length ? new KeyframeBuilder(blurKeyframes, length) : 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); + this.rotationOrigin = + inOrigin || outOrigin ? { x: originCurve(inOrigin?.x, outOrigin?.x), y: originCurve(inOrigin?.y, outOrigin?.y) } : undefined; + } + + private getRotationOriginOffset(angle: number, scale: Vector): Vector { + if (!this.rotationOrigin) return { x: 0, y: 0 }; + const time = this.getPlaybackTime(); + const size = this.getSize(); + const origin = { x: this.rotationOrigin.x.getValue(time), y: this.rotationOrigin.y.getValue(time) }; + return rotationOriginOffset({ width: size.width * scale.x, height: size.height * scale.y }, origin, angle); } protected recordLoadError(error: unknown): void { @@ -283,12 +301,12 @@ export abstract class Player extends Entity { const pivot = this.getPivot(); const position = this.getPosition(); const scaleVector = this.getContainerScale(); + const angle = this.getRotation(); + const turn = this.getRotationOriginOffset(angle, scaleVector); this.getContainer().scale.set(scaleVector.x, scaleVector.y); this.getContainer().pivot.set(pivot.x, pivot.y); - this.getContainer().position.set(position.x + pivot.x, position.y + pivot.y); - - const angle = this.getRotation(); + this.getContainer().position.set(position.x + pivot.x + turn.x, position.y + pivot.y + turn.y); this.contentContainer.alpha = this.getOpacity(); this.getContainer().angle = angle; diff --git a/src/core/animations/transition-preset-builder.ts b/src/core/animations/transition-preset-builder.ts index 3161618b..35532856 100644 --- a/src/core/animations/transition-preset-builder.ts +++ b/src/core/animations/transition-preset-builder.ts @@ -1,3 +1,4 @@ +import { type Vector } from "@layouts/geometry"; import { type ResolvedClip, type AnimationKeyframe } from "@schemas"; export type TransitionKeyframeSet = { @@ -13,6 +14,8 @@ export type TransitionKeyframeSet = { blurKeyframes: AnimationKeyframe[]; /** Wipe reveal direction for this phase: true = from the right edge inward, false = from the left. */ wipeFromRight: boolean; + /** The point the clip rotates about, as a fraction of its size from the top left; the centre when unset. */ + origin?: Vector; }; export type RelativeTransitionKeyframeSet = { @@ -27,6 +30,18 @@ const SMOOTH = { interpolation: "bezier", easing: "smooth" } as const; const LINEAR = { interpolation: "linear" } as const; type Curve = typeof SMOOTH | typeof LINEAR; +// The render's shuffles swing the clip about a point well outside it: [degrees, origin x, origin y]. +const SHUFFLES: Record = { + shuffleBottomLeft: [17, -1.6, 0.5], + shuffleBottomRight: [-17, 2.6, 0.5], + shuffleTopLeft: [-17, -1.6, 0.5], + shuffleTopRight: [17, 2.6, 0.5], + shuffleLeftBottom: [-25, 0.5, 3.6], + shuffleLeftTop: [25, 0.5, -2.6], + shuffleRightBottom: [25, 0.5, 3.6], + shuffleRightTop: [-25, 0.5, -2.6] +}; + export class TransitionPresetBuilder { private readonly clipConfiguration: ResolvedClip; private readonly inPreset: ParsedTransition; @@ -127,8 +142,15 @@ export class TransitionPresetBuilder { keyframes.wipeFromRight = transitionName === "wipeLeft" ? isIn : !isIn; break; } - default: + default: { + const shuffle = SHUFFLES[transitionName]; + if (!shuffle) break; + const [angle, x, y] = shuffle; + keyframes.rotationKeyframes.push(ramp(angle, 0, SMOOTH, SMOOTH)); + keyframes.scaleKeyframes.push(ramp(1.5, 1, SMOOTH, SMOOTH)); + keyframes.origin = { x, y }; break; + } } return keyframes; diff --git a/src/core/layouts/position-builder.ts b/src/core/layouts/position-builder.ts index f35b80c2..f5eb99b4 100644 --- a/src/core/layouts/position-builder.ts +++ b/src/core/layouts/position-builder.ts @@ -103,3 +103,16 @@ export function absoluteToRelative(containerSize: Size, entitySize: Size, anchor }; } } + +/** + * How far a clip turned `angle` degrees about its centre sits from where turning about `origin` would put it. + * `origin` is a fraction of `size` from the top left, as the render's rotation origin is. + */ +export function rotationOriginOffset(size: Size, origin: Vector, angle: number): Vector { + const x = (origin.x - 0.5) * size.width; + const y = (origin.y - 0.5) * size.height; + const radians = (angle * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + return { x: x - (x * cos - y * sin), y: y - (x * sin + y * cos) }; +} diff --git a/tests/export-preflight.test.ts b/tests/export-preflight.test.ts index 8f21ba35..65af6c4c 100644 --- a/tests/export-preflight.test.ts +++ b/tests/export-preflight.test.ts @@ -108,9 +108,11 @@ describe("findEditIssues", () => { }); it("reports a transition the presets don't animate as transition", () => { - expect(codes([clip({ asset: { type: "image", src: "https://example.com/a.png" }, transition: { in: "shuffleTopRight", out: "fade" } })])).toEqual( - ["transition"] - ); + // Every schema transition has a preset today; an unknown name stands in for one the schema adds later. + const name = "spin" as NonNullable["in"]; + expect(codes([clip({ asset: { type: "image", src: "https://example.com/a.png" }, transition: { in: name, out: "fade" } })])).toEqual([ + "transition" + ]); }); it("reports a font that is neither built in nor in the edit's fonts as font", () => { diff --git a/tests/position-builder.test.ts b/tests/position-builder.test.ts index 1e557671..f44e5ef6 100644 --- a/tests/position-builder.test.ts +++ b/tests/position-builder.test.ts @@ -1,4 +1,4 @@ -import { relativeToAbsolute, absoluteToRelative } from "@core/layouts/position-builder"; +import { relativeToAbsolute, absoluteToRelative, rotationOriginOffset } from "@core/layouts/position-builder"; describe("PositionBuilder", () => { describe("relativeToAbsolute", () => { @@ -75,4 +75,20 @@ describe("PositionBuilder", () => { expect(horizontal.y).not.toBe(vertical.y); }); }); + + describe("rotationOriginOffset", () => { + it("leaves a turn about the centre where it is", () => { + const offset = rotationOriginOffset({ width: 100, height: 50 }, { x: 0.5, y: 0.5 }, 30); + + expect(offset.x).toBeCloseTo(0); + expect(offset.y).toBeCloseTo(0); + }); + + it("moves the centre the way a clockwise turn about the left edge would", () => { + const offset = rotationOriginOffset({ width: 100, height: 50 }, { x: 0, y: 0.5 }, 90); + + expect(offset.x).toBeCloseTo(-50); + expect(offset.y).toBeCloseTo(50); + }); + }); }); diff --git a/tests/transition-preset-builder.test.ts b/tests/transition-preset-builder.test.ts index 70a26a19..1fb354bb 100644 --- a/tests/transition-preset-builder.test.ts +++ b/tests/transition-preset-builder.test.ts @@ -22,14 +22,43 @@ describe("TransitionPresetBuilder", () => { ["carouselUpFast", 0.25], ["fadeSlow", 2], ["fadeFast", 0.5], - ["zoom", 0.4] + ["zoom", 0.4], + ["shuffleTopRight", 1], + ["shuffleTopRightSlow", 2], + ["shuffleTopRightFast", 0.5] ])("runs %s for the render's %s seconds", (name, length) => { const { in: phase } = build({ in: name as never }); - const [keyframe] = [...phase.offsetXKeyframes, ...phase.offsetYKeyframes, ...phase.opacityKeyframes]; + const [keyframe] = [...phase.offsetXKeyframes, ...phase.offsetYKeyframes, ...phase.opacityKeyframes, ...phase.rotationKeyframes]; expect(keyframe.length).toBe(length); }); + it.each([ + ["shuffleBottomLeft", 17, { x: -1.6, y: 0.5 }], + ["shuffleBottomRight", -17, { x: 2.6, y: 0.5 }], + ["shuffleTopLeft", -17, { x: -1.6, y: 0.5 }], + ["shuffleTopRight", 17, { x: 2.6, y: 0.5 }], + ["shuffleLeftBottom", -25, { x: 0.5, y: 3.6 }], + ["shuffleLeftTop", 25, { x: 0.5, y: -2.6 }], + ["shuffleRightBottom", 25, { x: 0.5, y: 3.6 }], + ["shuffleRightTop", -25, { x: 0.5, y: -2.6 }] + ])("swings %s by %s degrees about the render's origin", (name, angle, origin) => { + const { in: phaseIn, out: phaseOut } = build({ in: name as never, out: name as never }); + + expect(phaseIn.rotationKeyframes).toEqual([{ from: angle, to: 0, start: 0, length: 1, ...SMOOTH }]); + expect(phaseOut.rotationKeyframes).toEqual([{ from: 0, to: angle, start: 4, length: 1, ...SMOOTH }]); + expect(phaseIn.origin).toEqual(origin); + expect(phaseOut.origin).toEqual(origin); + }); + + it("grows shuffles by half at the clip's edge without fading them", () => { + const { in: phaseIn, out: phaseOut } = build({ in: "shuffleLeftTop", out: "shuffleLeftTop" }); + + expect(phaseIn.scaleKeyframes).toEqual([{ from: 1.5, to: 1, start: 0, length: 1, ...SMOOTH }]); + expect(phaseOut.scaleKeyframes).toEqual([{ from: 1, to: 1.5, start: 4, length: 1, ...SMOOTH }]); + expect([...phaseIn.opacityKeyframes, ...phaseOut.opacityKeyframes]).toEqual([]); + }); + it("eases fades with the render's default curve", () => { const { in: fadeIn, out: fadeOut } = build({ in: "fade", out: "fade" }); From 92a6b2b79c765aef173d22a5dca54012f01be196 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 26 Oct 2026 17:33:44 +1100 Subject: [PATCH 4/7] fix: preview slide effects with the render's distances --- src/core/animations/effect-preset-builder.ts | 38 +++++----------- tests/effect-preset-builder.test.ts | 48 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 27 deletions(-) create mode 100644 tests/effect-preset-builder.test.ts diff --git a/src/core/animations/effect-preset-builder.ts b/src/core/animations/effect-preset-builder.ts index cf45ebb7..ff367d85 100644 --- a/src/core/animations/effect-preset-builder.ts +++ b/src/core/animations/effect-preset-builder.ts @@ -59,17 +59,19 @@ export class EffectPresetBuilder { case "slideUp": case "slideDown": { const isHorizontal = effectName === "slideLeft" || effectName === "slideRight"; - const startsPositive = effectName === "slideLeft" || effectName === "slideUp"; + // Left and down start on the positive side of their axis (offset y points up). + const startsPositive = effectName === "slideLeft" || effectName === "slideDown"; - const fittedSize = this.getFittedSize(editSize, clipSize); + // The render checks for overflow with the media fitted to the frame's other side, whatever the clip's fit. const editDimension = isHorizontal ? editSize.width : editSize.height; - const fittedDimension = isHorizontal ? fittedSize.width : fittedSize.height; + const fittedDimension = isHorizontal + ? (clipSize.width / clipSize.height) * editSize.height + : (clipSize.height / clipSize.width) * editSize.width; let targetOffset = this.getSlideStart(); - const minScale = editDimension + editDimension * targetOffset * 2; + const scaleFactor = 1 + targetOffset * 2; - if (fittedDimension < minScale) { - const scaleFactor = minScale / fittedDimension; + if (fittedDimension < editDimension * scaleFactor) { scaleKeyframes.push({ from: scaleFactor, to: scaleFactor, start, length, interpolation: "linear" }); } else { targetOffset = (fittedDimension - editDimension) / 2 / editDimension; @@ -111,36 +113,18 @@ export class EffectPresetBuilder { private getSlideStart(): number { const { name, speed } = this.effectPreset; + // Fractions of the frame, matching the render. if (name.startsWith("slide")) { switch (speed) { case "Slow": return 0.03; case "Fast": - return 0.2; - default: return 0.12; + default: + return 0.08; } } return 0; } - - private getFittedSize(editSize: Size, clipSize: Size): Size { - const fit = this.clipConfiguration.fit ?? "crop"; - - switch (fit) { - case "cover": - case "crop": { - const scale = Math.max(editSize.width / clipSize.width, editSize.height / clipSize.height); - return { width: clipSize.width * scale, height: clipSize.height * scale }; - } - case "contain": { - const scale = Math.min(editSize.width / clipSize.width, editSize.height / clipSize.height); - return { width: clipSize.width * scale, height: clipSize.height * scale }; - } - case "none": - default: - return clipSize; - } - } } diff --git a/tests/effect-preset-builder.test.ts b/tests/effect-preset-builder.test.ts new file mode 100644 index 00000000..75f1b100 --- /dev/null +++ b/tests/effect-preset-builder.test.ts @@ -0,0 +1,48 @@ +import { EffectPresetBuilder } from "@animations/effect-preset-builder"; +import { sec } from "@core/timing/types"; +import type { ResolvedClip } from "@schemas"; + +const EDIT = { width: 1920, height: 1080 }; + +const build = (effect: string, clipSize = EDIT, fit: ResolvedClip["fit"] = "crop") => + new EffectPresetBuilder({ + id: "clip-1", + asset: { type: "image", src: "https://example.com/a.png" }, + start: sec(0), + length: sec(5), + fit, + effect: effect as ResolvedClip["effect"] + }).buildRelative(EDIT, clipSize); + +describe("EffectPresetBuilder", () => { + it.each([ + ["slideLeft", 0.08], + ["slideLeftSlow", 0.03], + ["slideLeftFast", 0.12] + ])("pans %s by the render's %s of the frame, enlarged to cover it", (effect, distance) => { + const { offsetXKeyframes, scaleKeyframes } = build(effect); + + expect(offsetXKeyframes).toEqual([{ from: distance, to: -distance, start: 0, length: 5 }]); + expect(scaleKeyframes[0]).toMatchObject({ from: 1 + 2 * distance, to: 1 + 2 * distance }); + }); + + it.each([ + ["slideUp", -0.08, 0.08], + ["slideDown", 0.08, -0.08] + ])("pans %s the way its name says (offset y points up)", (effect, from, to) => { + expect(build(effect).offsetYKeyframes).toEqual([{ from, to, start: 0, length: 5 }]); + }); + + it("enlarges narrow media by the same amount whatever its fit", () => { + const { scaleKeyframes } = build("slideLeft", { width: 1080, height: 1920 }, "contain"); + + expect(scaleKeyframes[0]).toMatchObject({ from: 1.16, to: 1.16 }); + }); + + it.each(["crop", "contain"] as const)("pans the overflow of media fitted to the frame's height (%s)", fit => { + const { offsetXKeyframes, scaleKeyframes } = build("slideRight", { width: 4000, height: 1000 }, fit); + + expect(offsetXKeyframes).toEqual([{ from: -0.625, to: 0.625, start: 0, length: 5 }]); + expect(scaleKeyframes).toEqual([]); + }); +}); From 8829667f8b5254bd6232cfa862cdccee94f12989 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 26 Oct 2026 17:38:02 +1100 Subject: [PATCH 5/7] 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 46542019..e5dc1a8d 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -156,13 +156,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)) { @@ -227,27 +227,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); From dda06468a40eed948b08ca84463e98a155a709b1 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 26 Oct 2026 17:45:46 +1100 Subject: [PATCH 6/7] fix: apply effect and transition presets over keyframes in preview --- src/components/canvas/players/player.ts | 149 ++++++------------ .../animations/composed-keyframe-builder.ts | 64 -------- src/core/animations/effect-preset-builder.ts | 46 +++--- src/core/animations/keyframe-builder.ts | 24 ++- .../animations/transition-preset-builder.ts | 75 +++++---- src/core/schemas/index.ts | 5 +- src/core/shared/clip-utils.ts | 5 +- tests/effect-preset-builder.test.ts | 28 +++- tests/player-presets.test.ts | 87 ++++++++++ tests/transition-preset-builder.test.ts | 79 +++++----- 10 files changed, 286 insertions(+), 276 deletions(-) delete mode 100644 src/core/animations/composed-keyframe-builder.ts create mode 100644 tests/player-presets.test.ts diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index e5dc1a8d..3a231592 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -1,12 +1,10 @@ -import { ComposedKeyframeBuilder } from "@animations/composed-keyframe-builder"; import { EffectPresetBuilder } from "@animations/effect-preset-builder"; -import { KeyframeBuilder } from "@animations/keyframe-builder"; +import { KeyframeBuilder, type PresetPoint } from "@animations/keyframe-builder"; import { TransitionPresetBuilder } from "@animations/transition-preset-builder"; import { WipeFilter } from "@animations/wipe-filter"; import { type Edit } from "@core/edit-session"; import { InternalEvent } from "@core/events/edit-events"; import { calculateContainerScale, calculateFitScale, calculateSpriteTransform, type FitMode } from "@core/layout/fit-system"; -import { hasKeyframedVisualProperty } from "@core/shared/clip-utils"; import { type AliasReference, type ResolvedTiming, @@ -19,7 +17,7 @@ import { import { Pointer } from "@inputs/pointer"; import { type Size, type Vector } from "@layouts/geometry"; import { relativeToAbsolute, absoluteToRelative, rotationOriginOffset } from "@layouts/position-builder"; -import { type Clip, type ResolvedClip } from "@schemas"; +import { type Clip, type Keyframe, type ResolvedClip } from "@schemas"; import * as pixi from "pixi.js"; import { Entity } from "../../../core/shared/entity"; @@ -87,13 +85,13 @@ export abstract class Player extends Entity { private resolvedTiming: ResolvedTiming; - private offsetXKeyframeBuilder?: ComposedKeyframeBuilder; - private offsetYKeyframeBuilder?: ComposedKeyframeBuilder; - private scaleKeyframeBuilder?: ComposedKeyframeBuilder; - private opacityKeyframeBuilder?: ComposedKeyframeBuilder; - private rotationKeyframeBuilder?: ComposedKeyframeBuilder; - private skewXKeyframeBuilder?: ComposedKeyframeBuilder; - private skewYKeyframeBuilder?: ComposedKeyframeBuilder; + private offsetXKeyframeBuilder?: KeyframeBuilder; + private offsetYKeyframeBuilder?: KeyframeBuilder; + private scaleKeyframeBuilder?: KeyframeBuilder; + private opacityKeyframeBuilder?: KeyframeBuilder; + private rotationKeyframeBuilder?: KeyframeBuilder; + private skewXKeyframeBuilder?: KeyframeBuilder; + private skewYKeyframeBuilder?: KeyframeBuilder; private maskXKeyframeBuilder?: KeyframeBuilder; private wipeBrightnessBuilder?: KeyframeBuilder; private wipeInFromRight: boolean = false; @@ -145,50 +143,17 @@ export abstract class Player extends Entity { protected configureKeyframes() { const length = this.getLength(); const config = this.clipConfiguration; - - // Extract base values from clip configuration - const baseOffsetX = typeof config.offset?.x === "number" ? config.offset.x : 0; - const baseOffsetY = typeof config.offset?.y === "number" ? config.offset.y : 0; - const baseScale = typeof config.scale === "number" ? config.scale : 1; - const baseOpacity = typeof config.opacity === "number" ? config.opacity : 1; - const baseRotation = typeof config.transform?.rotate?.angle === "number" ? config.transform.rotate.angle : 0; - const baseSkewX = typeof config.transform?.skew?.x === "number" ? config.transform.skew.x : 0; - const baseSkewY = typeof config.transform?.skew?.y === "number" ? config.transform.skew.y : 0; - - // Create composed builders with base values - 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)) { - if (Array.isArray(config.scale)) { - this.scaleKeyframeBuilder.addLayer(config.scale); - } - if (Array.isArray(config.opacity)) { - this.opacityKeyframeBuilder.addLayer(config.opacity); - } - if (Array.isArray(config.offset?.x)) { - this.offsetXKeyframeBuilder.addLayer(config.offset.x); - } - if (Array.isArray(config.offset?.y)) { - this.offsetYKeyframeBuilder.addLayer(config.offset.y); - } - if (Array.isArray(config.transform?.rotate?.angle)) { - this.rotationKeyframeBuilder.addLayer(config.transform.rotate.angle); - } - if (Array.isArray(config.transform?.skew?.x)) { - this.skewXKeyframeBuilder.addLayer(config.transform.skew.x); - } - if (Array.isArray(config.transform?.skew?.y)) { - this.skewYKeyframeBuilder.addLayer(config.transform.skew.y); - } - return; - } + // A property that holds neither a number nor tweens, such as an unresolved merge field, plays its default. + const curve = (value: Keyframe[] | number | undefined, fallback: number) => + new KeyframeBuilder(typeof value === "number" || (Array.isArray(value) && value.length > 0) ? value : fallback); + + this.offsetXKeyframeBuilder = curve(config.offset?.x, 0); + this.offsetYKeyframeBuilder = curve(config.offset?.y, 0); + this.scaleKeyframeBuilder = curve(config.scale, 1); + this.opacityKeyframeBuilder = curve(config.opacity, 1); + this.rotationKeyframeBuilder = curve(config.transform?.rotate?.angle, 0); + this.skewXKeyframeBuilder = curve(config.transform?.skew?.x, 0); + this.skewYKeyframeBuilder = curve(config.transform?.skew?.y, 0); // Build resolved clip config for preset builders const resolvedClipConfig: ResolvedClip = { @@ -197,59 +162,40 @@ export abstract class Player extends Entity { length }; - // Build relative effect keyframes (factors/deltas) const effectSet = new EffectPresetBuilder(resolvedClipConfig).buildRelative(this.edit.size, this.getSize()); - - // Build relative transition keyframes (separate in/out sets) const transitionSet = new TransitionPresetBuilder(resolvedClipConfig).buildRelative(); - // Add effect layer - this.offsetXKeyframeBuilder.addLayer(effectSet.offsetXKeyframes); - this.offsetYKeyframeBuilder.addLayer(effectSet.offsetYKeyframes); - this.scaleKeyframeBuilder.addLayer(effectSet.scaleKeyframes); - this.opacityKeyframeBuilder.addLayer(effectSet.opacityKeyframes); - this.rotationKeyframeBuilder.addLayer(effectSet.rotationKeyframes); - - // Add transition-in layer - this.offsetXKeyframeBuilder.addLayer(transitionSet.in.offsetXKeyframes); - this.offsetYKeyframeBuilder.addLayer(transitionSet.in.offsetYKeyframes); - this.scaleKeyframeBuilder.addLayer(transitionSet.in.scaleKeyframes); - this.opacityKeyframeBuilder.addLayer(transitionSet.in.opacityKeyframes); - this.rotationKeyframeBuilder.addLayer(transitionSet.in.rotationKeyframes); - - // Add transition-out layer - this.offsetXKeyframeBuilder.addLayer(transitionSet.out.offsetXKeyframes); - this.offsetYKeyframeBuilder.addLayer(transitionSet.out.offsetYKeyframes); - this.scaleKeyframeBuilder.addLayer(transitionSet.out.scaleKeyframes); - this.opacityKeyframeBuilder.addLayer(transitionSet.out.opacityKeyframes); - this.rotationKeyframeBuilder.addLayer(transitionSet.out.rotationKeyframes); - - // Mask keyframes (reveal effect) - const maskXKeyframes = [...transitionSet.in.maskXKeyframes, ...transitionSet.out.maskXKeyframes]; - if (maskXKeyframes.length) { - this.maskXKeyframeBuilder = new KeyframeBuilder(maskXKeyframes); + // The render applies the effect, then the in transition, then the out, each over the curves left by those before. + for (const preset of [effectSet, transitionSet.in, transitionSet.out]) { + this.offsetXKeyframeBuilder.addPresetPoints(preset.offsetXKeyframes); + this.offsetYKeyframeBuilder.addPresetPoints(preset.offsetYKeyframes); + this.scaleKeyframeBuilder.addPresetPoints(preset.scaleKeyframes); + this.opacityKeyframeBuilder.addPresetPoints(preset.opacityKeyframes); + this.rotationKeyframeBuilder.addPresetPoints(preset.rotationKeyframes); } + // Curves only transitions drive, resting at a value that leaves the clip untouched. + const transitionCurve = (rest: number, ...phases: PresetPoint[][]) => { + if (!phases.some(points => points.length)) return undefined; + const builder = new KeyframeBuilder(rest); + phases.forEach(points => builder.addPresetPoints(points)); + return builder; + }; + const { in: transitionIn, out: transitionOut } = transitionSet; + + this.maskXKeyframeBuilder = transitionCurve(1, transitionIn.maskXKeyframes, transitionOut.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); - this.wipeInFromRight = transitionSet.in.wipeFromRight; - this.wipeOutFromRight = transitionSet.out.wipeFromRight; - this.wipeOutStart = transitionSet.out.brightnessKeyframes[0]?.start ?? Number.POSITIVE_INFINITY; - } + this.wipeBrightnessBuilder = transitionCurve(-1, transitionIn.brightnessKeyframes, transitionOut.brightnessKeyframes); + this.wipeInFromRight = transitionIn.wipeFromRight; + this.wipeOutFromRight = transitionOut.wipeFromRight; + this.wipeOutStart = transitionOut.brightnessKeyframes[0]?.time ?? Number.POSITIVE_INFINITY; - const blurKeyframes = [...transitionSet.in.blurKeyframes, ...transitionSet.out.blurKeyframes]; - this.zoomBlurBuilder = blurKeyframes.length ? new KeyframeBuilder(blurKeyframes) : undefined; + this.zoomBlurBuilder = transitionCurve(0, transitionIn.blurKeyframes, transitionOut.blurKeyframes); - // 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" }]); - this.rotationOrigin = - inOrigin || outOrigin ? { x: originCurve(inOrigin?.x, outOrigin?.x), y: originCurve(inOrigin?.y, outOrigin?.y) } : undefined; + const originX = transitionCurve(0.5, transitionIn.originXKeyframes, transitionOut.originXKeyframes); + const originY = transitionCurve(0.5, transitionIn.originYKeyframes, transitionOut.originYKeyframes); + this.rotationOrigin = originX && originY ? { x: originX, y: originY } : undefined; } private getRotationOriginOffset(angle: number, scale: Vector): Vector { @@ -561,7 +507,8 @@ export abstract class Player extends Entity { } public getOpacity(): number { - return this.opacityKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1; + const opacity = this.opacityKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1; + return Math.min(1, Math.max(0, opacity)); } public getPosition(): Vector { diff --git a/src/core/animations/composed-keyframe-builder.ts b/src/core/animations/composed-keyframe-builder.ts deleted file mode 100644 index 842a4337..00000000 --- a/src/core/animations/composed-keyframe-builder.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { type AnimationKeyframe } from "@schemas"; - -import { KeyframeBuilder } from "./keyframe-builder"; - -type CompositionMode = "additive" | "multiplicative"; - -/** - * Composes multiple keyframe layers into a single value using additive or multiplicative blending. - * - * - **Additive mode**: `base + Σ(layer deltas)` - used for offset and rotation - * - **Multiplicative mode**: `base × Π(layer factors)` - used for scale and opacity - * - * This enables effects and transitions to run simultaneously without conflicts. - */ -export class ComposedKeyframeBuilder { - private readonly baseValue: number; - private readonly mode: CompositionMode; - private readonly layers: KeyframeBuilder[] = []; - private readonly clampRange?: { min: number; max: number }; - - constructor(baseValue: number, mode: CompositionMode, clampRange?: { min: number; max: number }) { - this.baseValue = baseValue; - this.mode = mode; - this.clampRange = clampRange; - } - - /** - * Add a keyframe layer to the composition. - * For additive mode, keyframes should represent deltas (e.g., 0 → 0.1 means "move by 0.1") - * For multiplicative mode, keyframes should represent factors (e.g., 1 → 1.3 means "scale by 1.3x") - */ - addLayer(keyframes: AnimationKeyframe[]): void { - if (keyframes.length === 0) return; - - this.layers.push(new KeyframeBuilder(keyframes)); - } - - /** - * Get the composed value at a specific time. - * Combines base value with all layer values using the composition mode. - */ - getValue(time: number): number { - if (this.layers.length === 0) { - return this.baseValue; - } - - if (this.mode === "additive") { - let result = this.baseValue; - for (const layer of this.layers) { - result += layer.getValue(time); - } - return result; - } - let result = this.baseValue; - for (const layer of this.layers) { - result *= layer.getValue(time); - } - // Clamp to range if specified (e.g., [0, 1] for opacity) - if (this.clampRange) { - result = Math.max(this.clampRange.min, Math.min(this.clampRange.max, result)); - } - return result; - } -} diff --git a/src/core/animations/effect-preset-builder.ts b/src/core/animations/effect-preset-builder.ts index ff367d85..eaaf5977 100644 --- a/src/core/animations/effect-preset-builder.ts +++ b/src/core/animations/effect-preset-builder.ts @@ -1,13 +1,15 @@ -import { type ResolvedClip, type Keyframe } from "@schemas"; +import { type ResolvedClip } from "@schemas"; import { type Size } from "../layouts/geometry"; +import { type PresetPoint } from "./keyframe-builder"; + export type EffectKeyframeSet = { - offsetXKeyframes: Keyframe[]; - offsetYKeyframes: Keyframe[]; - opacityKeyframes: Keyframe[]; - scaleKeyframes: Keyframe[]; - rotationKeyframes: Keyframe[]; + offsetXKeyframes: PresetPoint[]; + offsetYKeyframes: PresetPoint[]; + opacityKeyframes: PresetPoint[]; + scaleKeyframes: PresetPoint[]; + rotationKeyframes: PresetPoint[]; }; export type RelativeEffectKeyframeSet = EffectKeyframeSet; @@ -26,11 +28,11 @@ export class EffectPresetBuilder { } public buildRelative(editSize: Size, clipSize: Size): RelativeEffectKeyframeSet { - const offsetXKeyframes: Keyframe[] = []; - const offsetYKeyframes: Keyframe[] = []; - const opacityKeyframes: Keyframe[] = []; - const scaleKeyframes: Keyframe[] = []; - const rotationKeyframes: Keyframe[] = []; + const offsetXKeyframes: PresetPoint[] = []; + const offsetYKeyframes: PresetPoint[] = []; + const opacityKeyframes: PresetPoint[] = []; + const scaleKeyframes: PresetPoint[] = []; + const rotationKeyframes: PresetPoint[] = []; const { effect, length } = this.clipConfiguration; @@ -43,15 +45,20 @@ export class EffectPresetBuilder { switch (effectName) { case "zoomIn": { - const zoomSpeed = this.getZoomSpeed(); - // Factor: starts at 1x, ends at zoomSpeed (e.g., 1.3x) - scaleKeyframes.push({ from: 1, to: zoomSpeed, start, length, interpolation: "linear" }); + // The render adds the zoom to the scale it reads at the clip's start. + const amount = this.getZoomSpeed() - 1; + scaleKeyframes.push( + { time: start, value: 0, at: start, interpolation: "linear" }, + { time: length, value: amount, at: start, interpolation: "linear" } + ); break; } case "zoomOut": { - const zoomSpeed = this.getZoomSpeed(); - // Factor: starts at zoomSpeed (e.g., 1.3x), ends at 1x - scaleKeyframes.push({ from: zoomSpeed, to: 1, start, length, interpolation: "linear" }); + const amount = this.getZoomSpeed() - 1; + scaleKeyframes.push( + { time: start, value: amount, at: start, interpolation: "linear" }, + { time: length, value: 0, at: start, interpolation: "linear" } + ); break; } case "slideLeft": @@ -71,15 +78,16 @@ export class EffectPresetBuilder { let targetOffset = this.getSlideStart(); const scaleFactor = 1 + targetOffset * 2; + // Like the render's, a slide sets the offset on its axis outright, and the scale too when it enlarges the clip. if (fittedDimension < editDimension * scaleFactor) { - scaleKeyframes.push({ from: scaleFactor, to: scaleFactor, start, length, interpolation: "linear" }); + scaleKeyframes.push({ time: start, value: scaleFactor, interpolation: "constant" }); } else { targetOffset = (fittedDimension - editDimension) / 2 / editDimension; } const [from, to] = startsPositive ? [targetOffset, -targetOffset] : [-targetOffset, targetOffset]; const targetKeyframes = isHorizontal ? offsetXKeyframes : offsetYKeyframes; - targetKeyframes.push({ from, to, start, length }); + targetKeyframes.push({ time: start, value: from, interpolation: "linear" }, { time: length, value: to, interpolation: "linear" }); break; } default: diff --git a/src/core/animations/keyframe-builder.ts b/src/core/animations/keyframe-builder.ts index c177a92a..a776575d 100644 --- a/src/core/animations/keyframe-builder.ts +++ b/src/core/animations/keyframe-builder.ts @@ -1,4 +1,4 @@ -import { type AnimationKeyframe, type NumericKeyframe } from "@schemas"; +import { type Keyframe, type NumericKeyframe } from "@schemas"; import { CurveInterpolator } from "./curve-interpolator"; @@ -13,14 +13,17 @@ export const TIME_EPSILON = 1e-6; /** 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[] }; +/** A point a preset adds, with the render's default handles. `value` is absolute, or added to the curve's value at `at`. */ +export type PresetPoint = { time: number; value: number; interpolation: NonNullable; at?: number }; + 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"); export class KeyframeBuilder { - private readonly points: Point[]; + private points: Point[]; - constructor(value: AnimationKeyframe[] | number) { + constructor(value: Keyframe[] | number) { this.points = this.createPoints(value); } @@ -43,7 +46,16 @@ export class KeyframeBuilder { } } - private createPoints(value: AnimationKeyframe[] | number): Point[] { + /** Adds a preset as the render does: every value is read before any point is added, and a point replaces any at its time. */ + public addPresetPoints(points: PresetPoint[]): void { + const values = points.map(point => point.value + (point.at === undefined ? 0 : this.getValue(point.at))); + points.forEach(({ time, interpolation }, index) => { + const point = { time, value: values[index], interpolation, handleLeft: DEFAULT_LEFT, handleRight: DEFAULT_RIGHT }; + this.points = [...this.points.filter(existing => Math.abs(existing.time - time) > TIME_EPSILON), point].sort((a, b) => a.time - b.time); + }); + } + + private createPoints(value: Keyframe[] | number): Point[] { if (typeof value === "number") { return [{ time: 0, value, interpolation: "constant", handleLeft: DEFAULT_LEFT, handleRight: DEFAULT_RIGHT }]; } @@ -67,9 +79,9 @@ export class KeyframeBuilder { }); } - private createNormalizedKeyframes(keyframes: AnimationKeyframe[]): NumericKeyframe[] { + private createNormalizedKeyframes(keyframes: Keyframe[]): NumericKeyframe[] { return keyframes - .filter((kf): kf is AnimationKeyframe & { start: number; length: number } => Number.isFinite(kf.start) && Number.isFinite(kf.length)) + .filter((kf): kf is Keyframe & { start: number; length: number } => Number.isFinite(kf.start) && Number.isFinite(kf.length)) .sort((a, b) => a.start - b.start) .map(keyframe => ({ start: keyframe.start, diff --git a/src/core/animations/transition-preset-builder.ts b/src/core/animations/transition-preset-builder.ts index 35532856..4318834f 100644 --- a/src/core/animations/transition-preset-builder.ts +++ b/src/core/animations/transition-preset-builder.ts @@ -1,21 +1,23 @@ -import { type Vector } from "@layouts/geometry"; -import { type ResolvedClip, type AnimationKeyframe } from "@schemas"; +import { type ResolvedClip } from "@schemas"; + +import { type PresetPoint } from "./keyframe-builder"; export type TransitionKeyframeSet = { - offsetXKeyframes: AnimationKeyframe[]; - offsetYKeyframes: AnimationKeyframe[]; - opacityKeyframes: AnimationKeyframe[]; - scaleKeyframes: AnimationKeyframe[]; - rotationKeyframes: AnimationKeyframe[]; - maskXKeyframes: AnimationKeyframe[]; + offsetXKeyframes: PresetPoint[]; + offsetYKeyframes: PresetPoint[]; + opacityKeyframes: PresetPoint[]; + scaleKeyframes: PresetPoint[]; + rotationKeyframes: PresetPoint[]; + maskXKeyframes: PresetPoint[]; /** Wipe brightness sweep (+1 hidden → −1 revealed), applied through WipeFilter. */ - brightnessKeyframes: AnimationKeyframe[]; + brightnessKeyframes: PresetPoint[]; /** Zoom blur radius, in pixels of the clip before its scale applies. */ - blurKeyframes: AnimationKeyframe[]; + blurKeyframes: PresetPoint[]; + /** The point the clip rotates about, as a fraction of its size from the top left. */ + originXKeyframes: PresetPoint[]; + originYKeyframes: PresetPoint[]; /** Wipe reveal direction for this phase: true = from the right edge inward, false = from the left. */ wipeFromRight: boolean; - /** The point the clip rotates about, as a fraction of its size from the top left; the centre when unset. */ - origin?: Vector; }; export type RelativeTransitionKeyframeSet = { @@ -24,11 +26,7 @@ export type RelativeTransitionKeyframeSet = { }; type ParsedTransition = { name: string; speed: string | undefined }; - -// The render's presets use its default Bezier handles, cubic-bezier(0.5, 0, 0.5, 1). -const SMOOTH = { interpolation: "bezier", easing: "smooth" } as const; -const LINEAR = { interpolation: "linear" } as const; -type Curve = typeof SMOOTH | typeof LINEAR; +type Curve = PresetPoint["interpolation"]; // The render's shuffles swing the clip about a point well outside it: [degrees, origin x, origin y]. const SHUFFLES: Record = { @@ -76,6 +74,8 @@ export class TransitionPresetBuilder { maskXKeyframes: [], brightnessKeyframes: [], blurKeyframes: [], + originXKeyframes: [], + originYKeyframes: [], wipeFromRight: false }; } @@ -90,22 +90,27 @@ export class TransitionPresetBuilder { const start = direction === "in" ? 0 : this.clipConfiguration.length - length; const transitionName = this.getPresetName(direction); const isIn = direction === "in"; - - // The render eases between a point at the clip's edge and one where the transition meets the clip, and each - // segment takes its later point's curve: an in transition follows the inner point, an out transition the edge. - const ramp = (edge: number, rest: number, edgeCurve: Curve, innerCurve: Curve): AnimationKeyframe => - isIn ? { from: edge, to: rest, start, length, ...innerCurve } : { from: rest, to: edge, start, length, ...edgeCurve }; - const fade = ramp(0, 1, SMOOTH, SMOOTH); + const edgeTime = isIn ? 0 : this.clipConfiguration.length; + const innerTime = isIn ? length : start; + + // Like the render, a transition reads the property at both its ends, then adds a point at each: the clip's edge + // takes `edge` (added to the value read at `edgeAt`, if given) and the inner end keeps the value read there. + const ramp = (edge: number, edgeAt: number | undefined, edgeCurve: Curve, innerCurve: Curve): PresetPoint[] => { + const edgePoint = { time: edgeTime, value: edge, at: edgeAt, interpolation: edgeCurve }; + const innerPoint = { time: innerTime, value: 0, at: innerTime, interpolation: innerCurve }; + return isIn ? [edgePoint, innerPoint] : [innerPoint, edgePoint]; + }; + const fade = ramp(0, undefined, "bezier", "bezier"); switch (transitionName) { case "fade": { - keyframes.opacityKeyframes.push(fade); + keyframes.opacityKeyframes.push(...fade); break; } case "zoom": { - keyframes.scaleKeyframes.push(ramp(10, 1, SMOOTH, LINEAR)); - keyframes.opacityKeyframes.push(fade); - keyframes.blurKeyframes.push(ramp(5, 0, SMOOTH, SMOOTH)); + keyframes.scaleKeyframes.push(...ramp(9, edgeTime, "bezier", "linear")); + keyframes.opacityKeyframes.push(...fade); + keyframes.blurKeyframes.push(...ramp(5, undefined, "bezier", "bezier")); break; } case "slideLeft": @@ -126,19 +131,19 @@ export class TransitionPresetBuilder { const edge = (isIn === entersFromPositive ? 1 : -1) * distance; const targetKeyframes = isHorizontal ? keyframes.offsetXKeyframes : keyframes.offsetYKeyframes; - targetKeyframes.push(ramp(edge, 0, SMOOTH, LINEAR)); - if (isSlide) keyframes.opacityKeyframes.push(fade); + targetKeyframes.push(...ramp(edge, edgeTime, "bezier", "linear")); + if (isSlide) keyframes.opacityKeyframes.push(...fade); break; } case "reveal": { - keyframes.maskXKeyframes.push(ramp(0, 1, SMOOTH, SMOOTH)); + keyframes.maskXKeyframes.push(...ramp(0, undefined, "bezier", "bezier")); break; } case "wipeLeft": case "wipeRight": { // Brightness sweeps +1 (hidden) → −1 (revealed) in, and back out, applied via WipeFilter. // wipeLeft reveals from the right edge inward; wipeRight from the left. - keyframes.brightnessKeyframes.push(ramp(1, -1, SMOOTH, SMOOTH)); + keyframes.brightnessKeyframes.push(...ramp(1, undefined, "bezier", "bezier")); keyframes.wipeFromRight = transitionName === "wipeLeft" ? isIn : !isIn; break; } @@ -146,9 +151,11 @@ export class TransitionPresetBuilder { const shuffle = SHUFFLES[transitionName]; if (!shuffle) break; const [angle, x, y] = shuffle; - keyframes.rotationKeyframes.push(ramp(angle, 0, SMOOTH, SMOOTH)); - keyframes.scaleKeyframes.push(ramp(1.5, 1, SMOOTH, SMOOTH)); - keyframes.origin = { x, y }; + // The render measures both ends of the swing from the rotation where it meets the clip. + keyframes.rotationKeyframes.push(...ramp(angle, innerTime, "bezier", "bezier")); + keyframes.scaleKeyframes.push(...ramp(0.5, edgeTime, "bezier", "bezier")); + keyframes.originXKeyframes.push({ time: start, value: x, interpolation: "bezier" }); + keyframes.originYKeyframes.push({ time: start, value: y, interpolation: "bezier" }); break; } } diff --git a/src/core/schemas/index.ts b/src/core/schemas/index.ts index 5b582eb6..ac339e8d 100644 --- a/src/core/schemas/index.ts +++ b/src/core/schemas/index.ts @@ -122,16 +122,13 @@ export type ExtendedCaptionAsset = CaptionAsset & { // ─── Internal Animation Types ─────────────────────────────────────────────── // Keyframes with all required fields and numeric values for animation interpolation -/** A tween as the animation code reads it. `smooth` is the render's default curve, which its presets use. */ -export type AnimationKeyframe = Omit & { easing?: Tween["easing"] | "smooth" }; - export interface NumericKeyframe { start: number; length: number; from: number; to: number; interpolation?: Tween["interpolation"]; - easing?: AnimationKeyframe["easing"]; + easing?: Tween["easing"]; } // ─── Zod Schemas (for validation) ────────────────────────────────────────── diff --git a/src/core/shared/clip-utils.ts b/src/core/shared/clip-utils.ts index bc4e4013..f567efb9 100644 --- a/src/core/shared/clip-utils.ts +++ b/src/core/shared/clip-utils.ts @@ -23,9 +23,8 @@ export function isKeyframedValue(value: unknown): boolean { /** * True when any visual property is keyframed or bound. * - * Studio previews such clips without effect and transition layers, so the player - * and the toolbars must agree on the property list; keep this the only copy. - * Preview-only: rendered output composes presets over keyframes instead. + * The toolbars keep effects and transitions off such clips, so they must agree on + * the property list; keep this the only copy. */ export function hasKeyframedVisualProperty(clip: Clip): boolean { return [ diff --git a/tests/effect-preset-builder.test.ts b/tests/effect-preset-builder.test.ts index 75f1b100..67e5e577 100644 --- a/tests/effect-preset-builder.test.ts +++ b/tests/effect-preset-builder.test.ts @@ -14,6 +14,12 @@ const build = (effect: string, clipSize = EDIT, fit: ResolvedClip["fit"] = "crop effect: effect as ResolvedClip["effect"] }).buildRelative(EDIT, clipSize); +/** The offsets a slide effect pans between, over the 5 s clip. */ +const pan = (from: number, to: number) => [ + { time: 0, value: from, interpolation: "linear" }, + { time: 5, value: to, interpolation: "linear" } +]; + describe("EffectPresetBuilder", () => { it.each([ ["slideLeft", 0.08], @@ -22,27 +28,39 @@ describe("EffectPresetBuilder", () => { ])("pans %s by the render's %s of the frame, enlarged to cover it", (effect, distance) => { const { offsetXKeyframes, scaleKeyframes } = build(effect); - expect(offsetXKeyframes).toEqual([{ from: distance, to: -distance, start: 0, length: 5 }]); - expect(scaleKeyframes[0]).toMatchObject({ from: 1 + 2 * distance, to: 1 + 2 * distance }); + expect(offsetXKeyframes).toEqual(pan(distance, -distance)); + expect(scaleKeyframes).toEqual([{ time: 0, value: 1 + 2 * distance, interpolation: "constant" }]); }); it.each([ ["slideUp", -0.08, 0.08], ["slideDown", 0.08, -0.08] ])("pans %s the way its name says (offset y points up)", (effect, from, to) => { - expect(build(effect).offsetYKeyframes).toEqual([{ from, to, start: 0, length: 5 }]); + expect(build(effect).offsetYKeyframes).toEqual(pan(from, to)); }); it("enlarges narrow media by the same amount whatever its fit", () => { const { scaleKeyframes } = build("slideLeft", { width: 1080, height: 1920 }, "contain"); - expect(scaleKeyframes[0]).toMatchObject({ from: 1.16, to: 1.16 }); + expect(scaleKeyframes[0]).toMatchObject({ value: 1.16 }); }); it.each(["crop", "contain"] as const)("pans the overflow of media fitted to the frame's height (%s)", fit => { const { offsetXKeyframes, scaleKeyframes } = build("slideRight", { width: 4000, height: 1000 }, fit); - expect(offsetXKeyframes).toEqual([{ from: -0.625, to: 0.625, start: 0, length: 5 }]); + expect(offsetXKeyframes).toEqual(pan(-0.625, 0.625)); expect(scaleKeyframes).toEqual([]); }); + + it.each([ + ["zoomIn", 0, 0.3], + ["zoomInSlow", 0, 0.1], + ["zoomOutFast", 0.7, 0] + ])("zooms %s from the scale at the clip's start, adding %s then %s", (effect, from, to) => { + const [first, last] = build(effect).scaleKeyframes; + + expect(first).toEqual({ time: 0, value: from, at: 0, interpolation: "linear" }); + expect(last).toMatchObject({ time: 5, at: 0, interpolation: "linear" }); + expect(last.value).toBeCloseTo(to); + }); }); diff --git a/tests/player-presets.test.ts b/tests/player-presets.test.ts new file mode 100644 index 00000000..ac5a0e9e --- /dev/null +++ b/tests/player-presets.test.ts @@ -0,0 +1,87 @@ +import { Player, PlayerType } from "@canvas/players/player"; +import type { Edit } from "@core/edit-session"; +import { sec } from "@core/timing/types"; +import type { Size } from "@layouts/geometry"; +import type { ResolvedClip } from "@schemas"; + +jest.mock("pixi.js", () => ({ + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports -- jest.mock factories can't use imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, + Container: jest.fn().mockImplementation(() => ({ addChild: jest.fn() })) +})); + +const EDIT_SIZE = { width: 1920, height: 1080 }; + +/** A still that fills the frame. */ +class StillPlayer extends Player { + public override getSize(): Size { + return this.edit.size; + } +} + +/** The player for a 5 s clip, read at a given time. */ +const play = (fields: Partial): ((time: number) => Player) => { + const edit = { playbackTime: 0, size: EDIT_SIZE }; + const clip = { id: "clip-1", asset: { type: "image", src: "https://example.com/a.png" }, start: sec(0), length: sec(5), ...fields }; + const player = new StillPlayer(edit as unknown as Edit, clip as ResolvedClip, PlayerType.Image); + player.reconfigureAfterRestore(); + return time => { + edit.playbackTime = time; + return player; + }; +}; + +describe("Player presets", () => { + it("fades a static opacity in along the render's default curve", () => { + const at = play({ opacity: 0.8, transition: { in: "fade" } }); + + expect(at(0).getOpacity()).toBe(0); + expect(at(0.5).getOpacity()).toBeCloseTo(0.4); + expect(at(2).getOpacity()).toBeCloseTo(0.8); + }); + + it("plays an empty tween list as the property's default", () => { + expect(play({ opacity: [], transition: { in: "fade" } })(2).getOpacity()).toBe(1); + }); + + it("fades in from the keyframed opacity where the fade ends", () => { + const at = play({ opacity: [{ from: 0.5, to: 1, start: 0, length: 5, interpolation: "linear" }], transition: { in: "fade" } }); + + expect(at(0).getOpacity()).toBe(0); + expect(at(1).getOpacity()).toBeCloseTo(0.6); + expect(at(3).getOpacity()).toBeCloseTo(0.8); + }); + + it("keeps keyframes that fall inside a transition", () => { + const at = play({ + opacity: [ + { from: 1, to: 0.2, start: 0, length: 0.5, interpolation: "linear" }, + { from: 0.2, to: 1, start: 0.5, length: 4.5, interpolation: "linear" } + ], + transition: { in: "fade" } + }); + + expect(at(0.25).getOpacity()).toBeCloseTo(0.1); + expect(at(0.5).getOpacity()).toBeCloseTo(0.2); + }); + + it("zooms in from the render's distance added to keyframed scale", () => { + const at = play({ scale: [{ from: 0.5, to: 1, start: 0, length: 5, interpolation: "linear" }], transition: { in: "zoom" } }); + + expect(at(0).getScale()).toBeCloseTo(9.5); + expect(at(0.4).getScale()).toBeCloseTo(0.54); + }); + + it("applies the effect before the transitions", () => { + const at = play({ effect: "zoomIn", transition: { out: "zoom" } }); + + expect(at(5).getScale()).toBeCloseTo(10.3); + }); + + it("pans a slide effect from the render's offset, whatever the clip's own", () => { + const at = play({ offset: { x: 0.2, y: 0 }, effect: "slideLeft" }); + + expect(at(0).getPosition().x).toBeCloseTo(0.08 * EDIT_SIZE.width); + expect(at(5).getPosition().x).toBeCloseTo(-0.08 * EDIT_SIZE.width); + }); +}); diff --git a/tests/transition-preset-builder.test.ts b/tests/transition-preset-builder.test.ts index 1fb354bb..f404190b 100644 --- a/tests/transition-preset-builder.test.ts +++ b/tests/transition-preset-builder.test.ts @@ -11,7 +11,8 @@ const build = (transition: ResolvedClip["transition"]) => transition }).buildRelative(); -const SMOOTH = { interpolation: "bezier", easing: "smooth" }; +/** A preset point; `at` makes `value` relative to the curve there. */ +const point = (time: number, value: number, interpolation: "bezier" | "linear", at?: number) => ({ time, value, interpolation, at }); describe("TransitionPresetBuilder", () => { it.each([ @@ -28,50 +29,52 @@ describe("TransitionPresetBuilder", () => { ["shuffleTopRightFast", 0.5] ])("runs %s for the render's %s seconds", (name, length) => { const { in: phase } = build({ in: name as never }); - const [keyframe] = [...phase.offsetXKeyframes, ...phase.offsetYKeyframes, ...phase.opacityKeyframes, ...phase.rotationKeyframes]; + const [edge, inner] = [...phase.offsetXKeyframes, ...phase.offsetYKeyframes, ...phase.opacityKeyframes, ...phase.rotationKeyframes]; - expect(keyframe.length).toBe(length); + expect(inner.time - edge.time).toBe(length); }); it.each([ - ["shuffleBottomLeft", 17, { x: -1.6, y: 0.5 }], - ["shuffleBottomRight", -17, { x: 2.6, y: 0.5 }], - ["shuffleTopLeft", -17, { x: -1.6, y: 0.5 }], - ["shuffleTopRight", 17, { x: 2.6, y: 0.5 }], - ["shuffleLeftBottom", -25, { x: 0.5, y: 3.6 }], - ["shuffleLeftTop", 25, { x: 0.5, y: -2.6 }], - ["shuffleRightBottom", 25, { x: 0.5, y: 3.6 }], - ["shuffleRightTop", -25, { x: 0.5, y: -2.6 }] - ])("swings %s by %s degrees about the render's origin", (name, angle, origin) => { + ["shuffleBottomLeft", 17, -1.6, 0.5], + ["shuffleBottomRight", -17, 2.6, 0.5], + ["shuffleTopLeft", -17, -1.6, 0.5], + ["shuffleTopRight", 17, 2.6, 0.5], + ["shuffleLeftBottom", -25, 0.5, 3.6], + ["shuffleLeftTop", 25, 0.5, -2.6], + ["shuffleRightBottom", 25, 0.5, 3.6], + ["shuffleRightTop", -25, 0.5, -2.6] + ])("swings %s by %s degrees about the render's origin", (name, angle, x, y) => { const { in: phaseIn, out: phaseOut } = build({ in: name as never, out: name as never }); - expect(phaseIn.rotationKeyframes).toEqual([{ from: angle, to: 0, start: 0, length: 1, ...SMOOTH }]); - expect(phaseOut.rotationKeyframes).toEqual([{ from: 0, to: angle, start: 4, length: 1, ...SMOOTH }]); - expect(phaseIn.origin).toEqual(origin); - expect(phaseOut.origin).toEqual(origin); + expect(phaseIn.rotationKeyframes).toEqual([point(0, angle, "bezier", 1), point(1, 0, "bezier", 1)]); + expect(phaseOut.rotationKeyframes).toEqual([point(4, 0, "bezier", 4), point(5, angle, "bezier", 4)]); + expect(phaseIn.originXKeyframes).toEqual([point(0, x, "bezier")]); + expect(phaseIn.originYKeyframes).toEqual([point(0, y, "bezier")]); + expect(phaseOut.originXKeyframes).toEqual([point(4, x, "bezier")]); + expect(phaseOut.originYKeyframes).toEqual([point(4, y, "bezier")]); }); it("grows shuffles by half at the clip's edge without fading them", () => { const { in: phaseIn, out: phaseOut } = build({ in: "shuffleLeftTop", out: "shuffleLeftTop" }); - expect(phaseIn.scaleKeyframes).toEqual([{ from: 1.5, to: 1, start: 0, length: 1, ...SMOOTH }]); - expect(phaseOut.scaleKeyframes).toEqual([{ from: 1, to: 1.5, start: 4, length: 1, ...SMOOTH }]); + expect(phaseIn.scaleKeyframes).toEqual([point(0, 0.5, "bezier", 0), point(1, 0, "bezier", 1)]); + expect(phaseOut.scaleKeyframes).toEqual([point(4, 0, "bezier", 4), point(5, 0.5, "bezier", 5)]); expect([...phaseIn.opacityKeyframes, ...phaseOut.opacityKeyframes]).toEqual([]); }); - it("eases fades with the render's default curve", () => { + it("fades from nothing to the opacity where the fade meets the clip, along the render's default curve", () => { const { in: fadeIn, out: fadeOut } = build({ in: "fade", out: "fade" }); - expect(fadeIn.opacityKeyframes).toEqual([{ from: 0, to: 1, start: 0, length: 1, ...SMOOTH }]); - expect(fadeOut.opacityKeyframes).toEqual([{ from: 1, to: 0, start: 4, length: 1, ...SMOOTH }]); + expect(fadeIn.opacityKeyframes).toEqual([point(0, 0, "bezier"), point(1, 0, "bezier", 1)]); + expect(fadeOut.opacityKeyframes).toEqual([point(4, 0, "bezier", 4), point(5, 0, "bezier")]); }); it("moves slides linearly on the way in and along the default curve on the way out", () => { const { in: slideIn, out: slideOut } = build({ in: "slideLeft", out: "slideLeft" }); - expect(slideIn.offsetXKeyframes).toEqual([{ from: 0.025, to: 0, start: 0, length: 0.5, interpolation: "linear" }]); - expect(slideOut.offsetXKeyframes).toEqual([{ from: 0, to: -0.025, start: 4.5, length: 0.5, ...SMOOTH }]); - expect(slideIn.opacityKeyframes).toEqual([{ from: 0, to: 1, start: 0, length: 0.5, ...SMOOTH }]); + expect(slideIn.offsetXKeyframes).toEqual([point(0, 0.025, "bezier", 0), point(0.5, 0, "linear", 0.5)]); + expect(slideOut.offsetXKeyframes).toEqual([point(4.5, 0, "linear", 4.5), point(5, -0.025, "bezier", 5)]); + expect(slideIn.opacityKeyframes).toEqual([point(0, 0, "bezier"), point(0.5, 0, "bezier", 0.5)]); }); it.each([ @@ -79,38 +82,34 @@ describe("TransitionPresetBuilder", () => { ["slideDown", 0.025, -0.025], ["carouselUp", -1.05, 1.1], ["carouselDown", 1.05, -1.1] - ])("moves %s the way its name says (offset y points up)", (name, inFrom, outTo) => { + ])("moves %s the way its name says (offset y points up)", (name, inEdge, outEdge) => { const { in: phaseIn, out: phaseOut } = build({ in: name as never, out: name as never }); - expect(phaseIn.offsetYKeyframes[0]).toMatchObject({ from: inFrom, to: 0 }); - expect(phaseOut.offsetYKeyframes[0]).toMatchObject({ from: 0, to: outTo }); + expect(phaseIn.offsetYKeyframes[0]).toMatchObject({ time: 0, value: inEdge }); + expect(phaseOut.offsetYKeyframes[1]).toMatchObject({ time: 5, value: outEdge }); }); it("eases carousels out along the default curve", () => { - expect(build({ out: "carouselLeft" }).out.offsetXKeyframes).toEqual([{ from: 0, to: -1, start: 4.5, length: 0.5, ...SMOOTH }]); + expect(build({ out: "carouselLeft" }).out.offsetXKeyframes).toEqual([point(4.5, 0, "linear", 4.5), point(5, -1, "bezier", 5)]); }); - it("zooms in linearly, zooms out along the default curve and fades both ways on the default curve", () => { + it("zooms by adding the render's distance to the scale, linearly in and along the default curve out", () => { const { in: zoomIn, out: zoomOut } = build({ in: "zoom", out: "zoom" }); - expect(zoomIn.scaleKeyframes).toEqual([{ from: 10, to: 1, start: 0, length: 0.4, interpolation: "linear" }]); - expect(zoomOut.scaleKeyframes).toEqual([{ from: 1, to: 10, start: 4.6, length: 0.4, ...SMOOTH }]); - expect(zoomIn.opacityKeyframes[0]).toMatchObject(SMOOTH); - expect(zoomOut.opacityKeyframes[0]).toMatchObject(SMOOTH); + expect(zoomIn.scaleKeyframes).toEqual([point(0, 9, "bezier", 0), point(0.4, 0, "linear", 0.4)]); + expect(zoomOut.scaleKeyframes).toEqual([point(4.6, 0, "linear", 4.6), point(5, 9, "bezier", 5)]); + expect(zoomIn.opacityKeyframes.map(({ interpolation }) => interpolation)).toEqual(["bezier", "bezier"]); }); it("blurs zooms by 5 pixels at the clip's edge", () => { const { in: zoomIn, out: zoomOut } = build({ in: "zoom", out: "zoom" }); - expect(zoomIn.blurKeyframes).toEqual([{ from: 5, to: 0, start: 0, length: 0.4, ...SMOOTH }]); - expect(zoomOut.blurKeyframes).toEqual([{ from: 0, to: 5, start: 4.6, length: 0.4, ...SMOOTH }]); + expect(zoomIn.blurKeyframes).toEqual([point(0, 5, "bezier"), point(0.4, 0, "bezier", 0.4)]); + expect(zoomOut.blurKeyframes).toEqual([point(4.6, 0, "bezier", 4.6), point(5, 5, "bezier")]); }); it("eases reveals and wipes along the default curve", () => { - const { in: reveal } = build({ in: "reveal" }); - const { in: wipe } = build({ in: "wipeLeft" }); - - expect(reveal.maskXKeyframes[0]).toMatchObject(SMOOTH); - expect(wipe.brightnessKeyframes[0]).toMatchObject(SMOOTH); + expect(build({ in: "reveal" }).in.maskXKeyframes).toEqual([point(0, 0, "bezier"), point(1, 0, "bezier", 1)]); + expect(build({ in: "wipeLeft" }).in.brightnessKeyframes).toEqual([point(0, 1, "bezier"), point(1, 0, "bezier", 1)]); }); }); From 8987f09f1dcca79a1392d5953c055dda20ae0697 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 26 Oct 2026 17:38:02 +1100 Subject: [PATCH 7/7] 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);