Skip to content

Commit 85f56b6

Browse files
authored
fix: preview transitions with the render's durations, curves and directions (#250)
1 parent 68e2574 commit 85f56b6

4 files changed

Lines changed: 156 additions & 54 deletions

File tree

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ export abstract class Player extends Entity {
101101
private wipeOutFromRight: boolean = false;
102102
private wipeOutStart: number = Number.POSITIVE_INFINITY;
103103
private wipeFilter: WipeFilter | null = null;
104+
private zoomBlurBuilder?: KeyframeBuilder;
105+
private zoomBlurFilter: pixi.BlurFilter | null = null;
104106

105107
private wipeMask: pixi.Graphics | null;
106108
protected lumaWrapper: pixi.Container;
@@ -236,6 +238,9 @@ export abstract class Player extends Entity {
236238
this.wipeOutFromRight = transitionSet.out.wipeFromRight;
237239
this.wipeOutStart = transitionSet.out.brightnessKeyframes[0]?.start ?? Number.POSITIVE_INFINITY;
238240
}
241+
242+
const blurKeyframes = [...transitionSet.in.blurKeyframes, ...transitionSet.out.blurKeyframes];
243+
this.zoomBlurBuilder = blurKeyframes.length ? new KeyframeBuilder(blurKeyframes, length) : undefined;
239244
}
240245

241246
protected recordLoadError(error: unknown): void {
@@ -299,6 +304,7 @@ export abstract class Player extends Entity {
299304
// Update wipe/reveal mask animation
300305
this.updateWipeMask();
301306
this.updateWipeFilter();
307+
this.updateZoomBlur();
302308

303309
if (this.shouldDiscardFrame()) {
304310
this.contentContainer.alpha = 0;
@@ -352,11 +358,35 @@ export abstract class Player extends Entity {
352358
this.wipeFilter = null;
353359
}
354360

361+
private updateZoomBlur(): void {
362+
if (!this.zoomBlurBuilder) {
363+
if (!this.zoomBlurFilter) return;
364+
this.getContainer().filters = [];
365+
this.zoomBlurFilter.destroy();
366+
this.zoomBlurFilter = null;
367+
return;
368+
}
369+
if (!this.zoomBlurFilter) {
370+
this.zoomBlurFilter = new pixi.BlurFilter();
371+
this.getContainer().filters = [this.zoomBlurFilter];
372+
}
373+
// The render blurs by a whole-pixel radius before applying the clip's scale, so on screen the blur grows with scale
374+
// and the canvas zoom. pixi's Gaussian approximates the render's three box-blur passes.
375+
const time = this.getPlaybackTime();
376+
const radius = Math.trunc(this.zoomBlurBuilder.getValue(time));
377+
const canvas = this.getContainer().parent?.worldTransform;
378+
const canvasZoom = canvas ? Math.hypot(canvas.a, canvas.b) : 1;
379+
this.zoomBlurFilter.enabled = radius > 0;
380+
this.zoomBlurFilter.strength = radius * (this.scaleKeyframeBuilder?.getValue(time) ?? 1) * canvasZoom;
381+
}
382+
355383
public override dispose(): void {
356384
this.wipeMask?.destroy();
357385
this.wipeMask = null;
358386
this.wipeFilter?.destroy();
359387
this.wipeFilter = null;
388+
this.zoomBlurFilter?.destroy();
389+
this.zoomBlurFilter = null;
360390

361391
this.contentContainer?.destroy();
362392
this.lumaWrapper?.destroy();

‎src/core/animations/transition-preset-builder.ts‎

Lines changed: 37 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export type TransitionKeyframeSet = {
99
maskXKeyframes: AnimationKeyframe[];
1010
/** Wipe brightness sweep (+1 hidden → −1 revealed), applied through WipeFilter. */
1111
brightnessKeyframes: AnimationKeyframe[];
12+
/** Zoom blur radius, in pixels of the clip before its scale applies. */
13+
blurKeyframes: AnimationKeyframe[];
1214
/** Wipe reveal direction for this phase: true = from the right edge inward, false = from the left. */
1315
wipeFromRight: boolean;
1416
};
@@ -20,6 +22,11 @@ export type RelativeTransitionKeyframeSet = {
2022

2123
type ParsedTransition = { name: string; speed: string | undefined };
2224

25+
// The render's presets use its default Bezier handles, cubic-bezier(0.5, 0, 0.5, 1).
26+
const SMOOTH = { interpolation: "bezier", easing: "smooth" } as const;
27+
const LINEAR = { interpolation: "linear" } as const;
28+
type Curve = typeof SMOOTH | typeof LINEAR;
29+
2330
export class TransitionPresetBuilder {
2431
private readonly clipConfiguration: ResolvedClip;
2532
private readonly inPreset: ParsedTransition;
@@ -33,7 +40,7 @@ export class TransitionPresetBuilder {
3340
}
3441

3542
private parseTransition(value: string | undefined): ParsedTransition {
36-
const [name, speed] = (value ?? "").split(/(Slow|Fast|VeryFast)/);
43+
const [name, speed] = (value ?? "").split(/(Slow|Fast)/);
3744
return { name, speed };
3845
}
3946

@@ -53,6 +60,7 @@ export class TransitionPresetBuilder {
5360
rotationKeyframes: [],
5461
maskXKeyframes: [],
5562
brightnessKeyframes: [],
63+
blurKeyframes: [],
5664
wipeFromRight: false
5765
};
5866
}
@@ -68,75 +76,54 @@ export class TransitionPresetBuilder {
6876
const transitionName = this.getPresetName(direction);
6977
const isIn = direction === "in";
7078

79+
// The render eases between a point at the clip's edge and one where the transition meets the clip, and each
80+
// segment takes its later point's curve: an in transition follows the inner point, an out transition the edge.
81+
const ramp = (edge: number, rest: number, edgeCurve: Curve, innerCurve: Curve): AnimationKeyframe =>
82+
isIn ? { from: edge, to: rest, start, length, ...innerCurve } : { from: rest, to: edge, start, length, ...edgeCurve };
83+
const fade = ramp(0, 1, SMOOTH, SMOOTH);
84+
7185
switch (transitionName) {
7286
case "fade": {
73-
const [from, to] = isIn ? [0, 1] : [1, 0];
74-
keyframes.opacityKeyframes.push({ from, to, start, length, interpolation: "bezier", easing: "easeInOut" });
87+
keyframes.opacityKeyframes.push(fade);
7588
break;
7689
}
7790
case "zoom": {
78-
const [scaleFrom, scaleTo] = isIn ? [10, 1] : [1, 10];
79-
const [opacityFrom, opacityTo] = isIn ? [0, 1] : [1, 0];
80-
const easing = isIn ? "easeIn" : "easeOut";
81-
if (isIn) {
82-
keyframes.scaleKeyframes.push({ from: scaleFrom, to: scaleTo, start, length, interpolation: "linear" });
83-
} else {
84-
keyframes.scaleKeyframes.push({ from: scaleFrom, to: scaleTo, start, length, interpolation: "bezier", easing });
85-
}
86-
keyframes.opacityKeyframes.push({ from: opacityFrom, to: opacityTo, start, length, interpolation: "bezier", easing });
91+
keyframes.scaleKeyframes.push(ramp(10, 1, SMOOTH, LINEAR));
92+
keyframes.opacityKeyframes.push(fade);
93+
keyframes.blurKeyframes.push(ramp(5, 0, SMOOTH, SMOOTH));
8794
break;
8895
}
8996
case "slideLeft":
9097
case "slideRight":
9198
case "slideUp":
92-
case "slideDown": {
93-
const isHorizontal = transitionName === "slideLeft" || transitionName === "slideRight";
94-
const isNegative = transitionName === "slideLeft" || transitionName === "slideUp";
95-
const offset = 0.025;
96-
97-
const [offsetFrom, offsetTo] = isIn ? [isNegative ? offset : -offset, 0] : [0, isNegative ? -offset : offset];
98-
const interpolation = isIn ? "linear" : "bezier";
99-
const [opacityFrom, opacityTo] = isIn ? [0, 1] : [1, 0];
100-
101-
const targetKeyframes = isHorizontal ? keyframes.offsetXKeyframes : keyframes.offsetYKeyframes;
102-
targetKeyframes.push(
103-
isIn
104-
? { from: offsetFrom, to: offsetTo, start, length, interpolation }
105-
: { from: offsetFrom, to: offsetTo, start, length, interpolation, easing: "ease" }
106-
);
107-
keyframes.opacityKeyframes.push({ from: opacityFrom, to: opacityTo, start, length, interpolation: "bezier", easing: "ease" });
108-
break;
109-
}
99+
case "slideDown":
110100
case "carouselLeft":
111101
case "carouselRight":
112102
case "carouselUp":
113103
case "carouselDown": {
114-
const isHorizontal = transitionName === "carouselLeft" || transitionName === "carouselRight";
115-
const isNegative = transitionName === "carouselLeft" || transitionName === "carouselUp";
116-
117-
// Carousel uses different offsets for in vs out, and different interpolation
118-
const baseOffset = (isHorizontal && 1) || (isIn ? 1.05 : 1.1);
119-
const [offsetFrom, offsetTo] = isIn ? [isNegative ? baseOffset : -baseOffset, 0] : [0, isNegative ? -baseOffset : baseOffset];
104+
const isSlide = transitionName.startsWith("slide");
105+
const isHorizontal = transitionName.endsWith("Left") || transitionName.endsWith("Right");
106+
// Fractions of the frame, matching the render; vertical carousels travel further to clear the edge.
107+
let distance = isSlide ? 0.025 : 1;
108+
if (!isSlide && !isHorizontal) distance = isIn ? 1.05 : 1.1;
109+
// Left and down enter from the positive side of their axis (offset y points up) and leave by the other.
110+
const entersFromPositive = transitionName.endsWith("Left") || transitionName.endsWith("Down");
111+
const edge = (isIn === entersFromPositive ? 1 : -1) * distance;
120112

121113
const targetKeyframes = isHorizontal ? keyframes.offsetXKeyframes : keyframes.offsetYKeyframes;
122-
targetKeyframes.push(
123-
isIn
124-
? { from: offsetFrom, to: offsetTo, start, length, interpolation: "linear" }
125-
: { from: offsetFrom, to: offsetTo, start, length, interpolation: "bezier", easing: "ease" }
126-
);
114+
targetKeyframes.push(ramp(edge, 0, SMOOTH, LINEAR));
115+
if (isSlide) keyframes.opacityKeyframes.push(fade);
127116
break;
128117
}
129118
case "reveal": {
130-
const [from, to] = isIn ? [0, 1] : [1, 0];
131-
keyframes.maskXKeyframes.push({ from, to, start, length, interpolation: "bezier", easing: "ease" });
119+
keyframes.maskXKeyframes.push(ramp(0, 1, SMOOTH, SMOOTH));
132120
break;
133121
}
134122
case "wipeLeft":
135123
case "wipeRight": {
136124
// Brightness sweeps +1 (hidden) → −1 (revealed) in, and back out, applied via WipeFilter.
137125
// wipeLeft reveals from the right edge inward; wipeRight from the left.
138-
const [from, to] = isIn ? [1, -1] : [-1, 1];
139-
keyframes.brightnessKeyframes.push({ from, to, start, length, interpolation: "bezier", easing: "easeInOut" });
126+
keyframes.brightnessKeyframes.push(ramp(1, -1, SMOOTH, SMOOTH));
140127
keyframes.wipeFromRight = transitionName === "wipeLeft" ? isIn : !isIn;
141128
break;
142129
}
@@ -153,20 +140,18 @@ export class TransitionPresetBuilder {
153140

154141
private getPresetLength(direction: "in" | "out"): number {
155142
const { name, speed } = direction === "in" ? this.inPreset : this.outPreset;
156-
const isCarousel = name.startsWith("carousel");
157-
const isSlide = name.startsWith("slide");
158143

159144
if (name === "zoom") return 0.4;
160145

146+
// Matches the render: slides and carousels take half as long as other transitions at every speed.
147+
const base = name.startsWith("slide") || name.startsWith("carousel") ? 0.5 : 1;
161148
switch (speed) {
162149
case "Slow":
163-
return 2;
150+
return base * 2;
164151
case "Fast":
165-
return isCarousel || isSlide ? 0.25 : 0.5;
166-
case "VeryFast":
167-
return 0.25;
152+
return base / 2;
168153
default:
169-
return isCarousel || isSlide ? 0.5 : 1;
154+
return base;
170155
}
171156
}
172157
}

‎src/core/schemas/index.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,8 @@ export type ExtendedCaptionAsset = CaptionAsset & {
122122
// ─── Internal Animation Types ───────────────────────────────────────────────
123123
// Keyframes with all required fields and numeric values for animation interpolation
124124

125-
/** A tween as the animation code reads it. */
126-
export type AnimationKeyframe = Tween;
125+
/** A tween as the animation code reads it. `smooth` is the render's default curve, which its presets use. */
126+
export type AnimationKeyframe = Omit<Tween, "easing"> & { easing?: Tween["easing"] | "smooth" };
127127

128128
export interface NumericKeyframe {
129129
start: number;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { TransitionPresetBuilder } from "@animations/transition-preset-builder";
2+
import { sec } from "@core/timing/types";
3+
import type { ResolvedClip } from "@schemas";
4+
5+
const build = (transition: ResolvedClip["transition"]) =>
6+
new TransitionPresetBuilder({
7+
id: "clip-1",
8+
asset: { type: "image", src: "https://example.com/a.png" },
9+
start: sec(0),
10+
length: sec(5),
11+
transition
12+
}).buildRelative();
13+
14+
const SMOOTH = { interpolation: "bezier", easing: "smooth" };
15+
16+
describe("TransitionPresetBuilder", () => {
17+
it.each([
18+
["slideLeftSlow", 1],
19+
["slideLeft", 0.5],
20+
["slideLeftFast", 0.25],
21+
["carouselUpSlow", 1],
22+
["carouselUpFast", 0.25],
23+
["fadeSlow", 2],
24+
["fadeFast", 0.5],
25+
["zoom", 0.4]
26+
])("runs %s for the render's %s seconds", (name, length) => {
27+
const { in: phase } = build({ in: name as never });
28+
const [keyframe] = [...phase.offsetXKeyframes, ...phase.offsetYKeyframes, ...phase.opacityKeyframes];
29+
30+
expect(keyframe.length).toBe(length);
31+
});
32+
33+
it("eases fades with the render's default curve", () => {
34+
const { in: fadeIn, out: fadeOut } = build({ in: "fade", out: "fade" });
35+
36+
expect(fadeIn.opacityKeyframes).toEqual([{ from: 0, to: 1, start: 0, length: 1, ...SMOOTH }]);
37+
expect(fadeOut.opacityKeyframes).toEqual([{ from: 1, to: 0, start: 4, length: 1, ...SMOOTH }]);
38+
});
39+
40+
it("moves slides linearly on the way in and along the default curve on the way out", () => {
41+
const { in: slideIn, out: slideOut } = build({ in: "slideLeft", out: "slideLeft" });
42+
43+
expect(slideIn.offsetXKeyframes).toEqual([{ from: 0.025, to: 0, start: 0, length: 0.5, interpolation: "linear" }]);
44+
expect(slideOut.offsetXKeyframes).toEqual([{ from: 0, to: -0.025, start: 4.5, length: 0.5, ...SMOOTH }]);
45+
expect(slideIn.opacityKeyframes).toEqual([{ from: 0, to: 1, start: 0, length: 0.5, ...SMOOTH }]);
46+
});
47+
48+
it.each([
49+
["slideUp", -0.025, 0.025],
50+
["slideDown", 0.025, -0.025],
51+
["carouselUp", -1.05, 1.1],
52+
["carouselDown", 1.05, -1.1]
53+
])("moves %s the way its name says (offset y points up)", (name, inFrom, outTo) => {
54+
const { in: phaseIn, out: phaseOut } = build({ in: name as never, out: name as never });
55+
56+
expect(phaseIn.offsetYKeyframes[0]).toMatchObject({ from: inFrom, to: 0 });
57+
expect(phaseOut.offsetYKeyframes[0]).toMatchObject({ from: 0, to: outTo });
58+
});
59+
60+
it("eases carousels out along the default curve", () => {
61+
expect(build({ out: "carouselLeft" }).out.offsetXKeyframes).toEqual([{ from: 0, to: -1, start: 4.5, length: 0.5, ...SMOOTH }]);
62+
});
63+
64+
it("zooms in linearly, zooms out along the default curve and fades both ways on the default curve", () => {
65+
const { in: zoomIn, out: zoomOut } = build({ in: "zoom", out: "zoom" });
66+
67+
expect(zoomIn.scaleKeyframes).toEqual([{ from: 10, to: 1, start: 0, length: 0.4, interpolation: "linear" }]);
68+
expect(zoomOut.scaleKeyframes).toEqual([{ from: 1, to: 10, start: 4.6, length: 0.4, ...SMOOTH }]);
69+
expect(zoomIn.opacityKeyframes[0]).toMatchObject(SMOOTH);
70+
expect(zoomOut.opacityKeyframes[0]).toMatchObject(SMOOTH);
71+
});
72+
73+
it("blurs zooms by 5 pixels at the clip's edge", () => {
74+
const { in: zoomIn, out: zoomOut } = build({ in: "zoom", out: "zoom" });
75+
76+
expect(zoomIn.blurKeyframes).toEqual([{ from: 5, to: 0, start: 0, length: 0.4, ...SMOOTH }]);
77+
expect(zoomOut.blurKeyframes).toEqual([{ from: 0, to: 5, start: 4.6, length: 0.4, ...SMOOTH }]);
78+
});
79+
80+
it("eases reveals and wipes along the default curve", () => {
81+
const { in: reveal } = build({ in: "reveal" });
82+
const { in: wipe } = build({ in: "wipeLeft" });
83+
84+
expect(reveal.maskXKeyframes[0]).toMatchObject(SMOOTH);
85+
expect(wipe.brightnessKeyframes[0]).toMatchObject(SMOOTH);
86+
});
87+
});

0 commit comments

Comments
 (0)