Skip to content

Commit 493268c

Browse files
committed
fix: preview shuffle transitions
1 parent c3710d7 commit 493268c

6 files changed

Lines changed: 111 additions & 11 deletions

File tree

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
} from "@core/timing/types";
2020
import { Pointer } from "@inputs/pointer";
2121
import { type Size, type Vector } from "@layouts/geometry";
22-
import { relativeToAbsolute, absoluteToRelative } from "@layouts/position-builder";
22+
import { relativeToAbsolute, absoluteToRelative, rotationOriginOffset } from "@layouts/position-builder";
2323
import { type Clip, type ResolvedClip } from "@schemas";
2424
import * as pixi from "pixi.js";
2525

@@ -103,6 +103,7 @@ export abstract class Player extends Entity {
103103
private wipeFilter: WipeFilter | null = null;
104104
private zoomBlurBuilder?: KeyframeBuilder;
105105
private zoomBlurFilter: pixi.BlurFilter | null = null;
106+
private rotationOrigin?: { x: KeyframeBuilder; y: KeyframeBuilder };
106107

107108
private wipeMask: pixi.Graphics | null;
108109
protected lumaWrapper: pixi.Container;
@@ -241,6 +242,23 @@ export abstract class Player extends Entity {
241242

242243
const blurKeyframes = [...transitionSet.in.blurKeyframes, ...transitionSet.out.blurKeyframes];
243244
this.zoomBlurBuilder = blurKeyframes.length ? new KeyframeBuilder(blurKeyframes, length) : undefined;
245+
246+
// 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.
247+
const { origin: inOrigin } = transitionSet.in;
248+
const { origin: outOrigin } = transitionSet.out;
249+
const outStart = transitionSet.out.rotationKeyframes[0]?.start ?? length;
250+
const originCurve = (from = 0.5, to?: number) =>
251+
new KeyframeBuilder(to === undefined ? from : [{ from, to, start: 0, length: outStart, interpolation: "bezier", easing: "smooth" }], length);
252+
this.rotationOrigin =
253+
inOrigin || outOrigin ? { x: originCurve(inOrigin?.x, outOrigin?.x), y: originCurve(inOrigin?.y, outOrigin?.y) } : undefined;
254+
}
255+
256+
private getRotationOriginOffset(angle: number, scale: Vector): Vector {
257+
if (!this.rotationOrigin) return { x: 0, y: 0 };
258+
const time = this.getPlaybackTime();
259+
const size = this.getSize();
260+
const origin = { x: this.rotationOrigin.x.getValue(time), y: this.rotationOrigin.y.getValue(time) };
261+
return rotationOriginOffset({ width: size.width * scale.x, height: size.height * scale.y }, origin, angle);
244262
}
245263

246264
protected recordLoadError(error: unknown): void {
@@ -284,12 +302,12 @@ export abstract class Player extends Entity {
284302
const pivot = this.getPivot();
285303
const position = this.getPosition();
286304
const scaleVector = this.getContainerScale();
305+
const angle = this.getRotation();
306+
const turn = this.getRotationOriginOffset(angle, scaleVector);
287307

288308
this.getContainer().scale.set(scaleVector.x, scaleVector.y);
289309
this.getContainer().pivot.set(pivot.x, pivot.y);
290-
this.getContainer().position.set(position.x + pivot.x, position.y + pivot.y);
291-
292-
const angle = this.getRotation();
310+
this.getContainer().position.set(position.x + pivot.x + turn.x, position.y + pivot.y + turn.y);
293311

294312
this.contentContainer.alpha = this.getOpacity();
295313
this.getContainer().angle = angle;

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { type Vector } from "@layouts/geometry";
12
import { type ResolvedClip, type AnimationKeyframe } from "@schemas";
23

34
export type TransitionKeyframeSet = {
@@ -13,6 +14,8 @@ export type TransitionKeyframeSet = {
1314
blurKeyframes: AnimationKeyframe[];
1415
/** Wipe reveal direction for this phase: true = from the right edge inward, false = from the left. */
1516
wipeFromRight: boolean;
17+
/** The point the clip rotates about, as a fraction of its size from the top left; the centre when unset. */
18+
origin?: Vector;
1619
};
1720

1821
export type RelativeTransitionKeyframeSet = {
@@ -27,6 +30,18 @@ const SMOOTH = { interpolation: "bezier", easing: "smooth" } as const;
2730
const LINEAR = { interpolation: "linear" } as const;
2831
type Curve = typeof SMOOTH | typeof LINEAR;
2932

33+
// The render's shuffles swing the clip about a point well outside it: [degrees, origin x, origin y].
34+
const SHUFFLES: Record<string, [number, number, number]> = {
35+
shuffleBottomLeft: [17, -1.6, 0.5],
36+
shuffleBottomRight: [-17, 2.6, 0.5],
37+
shuffleTopLeft: [-17, -1.6, 0.5],
38+
shuffleTopRight: [17, 2.6, 0.5],
39+
shuffleLeftBottom: [-25, 0.5, 3.6],
40+
shuffleLeftTop: [25, 0.5, -2.6],
41+
shuffleRightBottom: [25, 0.5, 3.6],
42+
shuffleRightTop: [-25, 0.5, -2.6]
43+
};
44+
3045
export class TransitionPresetBuilder {
3146
private readonly clipConfiguration: ResolvedClip;
3247
private readonly inPreset: ParsedTransition;
@@ -127,8 +142,15 @@ export class TransitionPresetBuilder {
127142
keyframes.wipeFromRight = transitionName === "wipeLeft" ? isIn : !isIn;
128143
break;
129144
}
130-
default:
145+
default: {
146+
const shuffle = SHUFFLES[transitionName];
147+
if (!shuffle) break;
148+
const [angle, x, y] = shuffle;
149+
keyframes.rotationKeyframes.push(ramp(angle, 0, SMOOTH, SMOOTH));
150+
keyframes.scaleKeyframes.push(ramp(1.5, 1, SMOOTH, SMOOTH));
151+
keyframes.origin = { x, y };
131152
break;
153+
}
132154
}
133155

134156
return keyframes;

‎src/core/layouts/position-builder.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,16 @@ export function absoluteToRelative(containerSize: Size, entitySize: Size, anchor
103103
};
104104
}
105105
}
106+
107+
/**
108+
* How far a clip turned `angle` degrees about its centre sits from where turning about `origin` would put it.
109+
* `origin` is a fraction of `size` from the top left, as the render's rotation origin is.
110+
*/
111+
export function rotationOriginOffset(size: Size, origin: Vector, angle: number): Vector {
112+
const x = (origin.x - 0.5) * size.width;
113+
const y = (origin.y - 0.5) * size.height;
114+
const radians = (angle * Math.PI) / 180;
115+
const cos = Math.cos(radians);
116+
const sin = Math.sin(radians);
117+
return { x: x - (x * cos - y * sin), y: y - (x * sin + y * cos) };
118+
}

‎tests/export-preflight.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,11 @@ describe("findEditIssues", () => {
108108
});
109109

110110
it("reports a transition the presets don't animate as transition", () => {
111-
expect(codes([clip({ asset: { type: "image", src: "https://example.com/a.png" }, transition: { in: "shuffleTopRight", out: "fade" } })])).toEqual(
112-
["transition"]
113-
);
111+
// Every schema transition has a preset today; an unknown name stands in for one the schema adds later.
112+
const name = "spin" as NonNullable<ResolvedClip["transition"]>["in"];
113+
expect(codes([clip({ asset: { type: "image", src: "https://example.com/a.png" }, transition: { in: name, out: "fade" } })])).toEqual([
114+
"transition"
115+
]);
114116
});
115117

116118
it("reports a font that is neither built in nor in the edit's fonts as font", () => {

‎tests/position-builder.test.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { relativeToAbsolute, absoluteToRelative } from "@core/layouts/position-builder";
1+
import { relativeToAbsolute, absoluteToRelative, rotationOriginOffset } from "@core/layouts/position-builder";
22

33
describe("PositionBuilder", () => {
44
describe("relativeToAbsolute", () => {
@@ -75,4 +75,20 @@ describe("PositionBuilder", () => {
7575
expect(horizontal.y).not.toBe(vertical.y);
7676
});
7777
});
78+
79+
describe("rotationOriginOffset", () => {
80+
it("leaves a turn about the centre where it is", () => {
81+
const offset = rotationOriginOffset({ width: 100, height: 50 }, { x: 0.5, y: 0.5 }, 30);
82+
83+
expect(offset.x).toBeCloseTo(0);
84+
expect(offset.y).toBeCloseTo(0);
85+
});
86+
87+
it("moves the centre the way a clockwise turn about the left edge would", () => {
88+
const offset = rotationOriginOffset({ width: 100, height: 50 }, { x: 0, y: 0.5 }, 90);
89+
90+
expect(offset.x).toBeCloseTo(-50);
91+
expect(offset.y).toBeCloseTo(50);
92+
});
93+
});
7894
});

‎tests/transition-preset-builder.test.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,43 @@ describe("TransitionPresetBuilder", () => {
2222
["carouselUpFast", 0.25],
2323
["fadeSlow", 2],
2424
["fadeFast", 0.5],
25-
["zoom", 0.4]
25+
["zoom", 0.4],
26+
["shuffleTopRight", 1],
27+
["shuffleTopRightSlow", 2],
28+
["shuffleTopRightFast", 0.5]
2629
])("runs %s for the render's %s seconds", (name, length) => {
2730
const { in: phase } = build({ in: name as never });
28-
const [keyframe] = [...phase.offsetXKeyframes, ...phase.offsetYKeyframes, ...phase.opacityKeyframes];
31+
const [keyframe] = [...phase.offsetXKeyframes, ...phase.offsetYKeyframes, ...phase.opacityKeyframes, ...phase.rotationKeyframes];
2932

3033
expect(keyframe.length).toBe(length);
3134
});
3235

36+
it.each([
37+
["shuffleBottomLeft", 17, { x: -1.6, y: 0.5 }],
38+
["shuffleBottomRight", -17, { x: 2.6, y: 0.5 }],
39+
["shuffleTopLeft", -17, { x: -1.6, y: 0.5 }],
40+
["shuffleTopRight", 17, { x: 2.6, y: 0.5 }],
41+
["shuffleLeftBottom", -25, { x: 0.5, y: 3.6 }],
42+
["shuffleLeftTop", 25, { x: 0.5, y: -2.6 }],
43+
["shuffleRightBottom", 25, { x: 0.5, y: 3.6 }],
44+
["shuffleRightTop", -25, { x: 0.5, y: -2.6 }]
45+
])("swings %s by %s degrees about the render's origin", (name, angle, origin) => {
46+
const { in: phaseIn, out: phaseOut } = build({ in: name as never, out: name as never });
47+
48+
expect(phaseIn.rotationKeyframes).toEqual([{ from: angle, to: 0, start: 0, length: 1, ...SMOOTH }]);
49+
expect(phaseOut.rotationKeyframes).toEqual([{ from: 0, to: angle, start: 4, length: 1, ...SMOOTH }]);
50+
expect(phaseIn.origin).toEqual(origin);
51+
expect(phaseOut.origin).toEqual(origin);
52+
});
53+
54+
it("grows shuffles by half at the clip's edge without fading them", () => {
55+
const { in: phaseIn, out: phaseOut } = build({ in: "shuffleLeftTop", out: "shuffleLeftTop" });
56+
57+
expect(phaseIn.scaleKeyframes).toEqual([{ from: 1.5, to: 1, start: 0, length: 1, ...SMOOTH }]);
58+
expect(phaseOut.scaleKeyframes).toEqual([{ from: 1, to: 1.5, start: 4, length: 1, ...SMOOTH }]);
59+
expect([...phaseIn.opacityKeyframes, ...phaseOut.opacityKeyframes]).toEqual([]);
60+
});
61+
3362
it("eases fades with the render's default curve", () => {
3463
const { in: fadeIn, out: fadeOut } = build({ in: "fade", out: "fade" });
3564

0 commit comments

Comments
 (0)