From a632b89172cd5d8cba1b9933b07277a002f00c6a Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 26 Oct 2026 17:32:43 +1100 Subject: [PATCH] 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 7d5b2686..ac014ee9 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -19,7 +19,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"; @@ -103,6 +103,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; @@ -241,6 +242,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 { @@ -284,12 +302,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" });