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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/components/canvas/players/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
89 changes: 37 additions & 52 deletions src/core/animations/transition-preset-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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;
Expand All @@ -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 };
}

Expand All @@ -53,6 +60,7 @@ export class TransitionPresetBuilder {
rotationKeyframes: [],
maskXKeyframes: [],
brightnessKeyframes: [],
blurKeyframes: [],
wipeFromRight: false
};
}
Expand All @@ -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;
}
Expand All @@ -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;
}
}
}
4 changes: 2 additions & 2 deletions src/core/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tween, "easing"> & { easing?: Tween["easing"] | "smooth" };

export interface NumericKeyframe {
start: number;
Expand Down
87 changes: 87 additions & 0 deletions tests/transition-preset-builder.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading