From c3710d797d4aed5f47803ed558077ab5a8567797 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 26 Oct 2026 17:29:08 +1100 Subject: [PATCH] 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 f742c83e..7d5b2686 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -101,6 +101,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; @@ -236,6 +238,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 { @@ -299,6 +304,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; @@ -352,11 +358,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); + }); +});