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
26 changes: 22 additions & 4 deletions src/components/canvas/players/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
24 changes: 23 additions & 1 deletion src/core/animations/transition-preset-builder.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type Vector } from "@layouts/geometry";
import { type ResolvedClip, type AnimationKeyframe } from "@schemas";

export type TransitionKeyframeSet = {
Expand All @@ -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 = {
Expand All @@ -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<string, [number, number, number]> = {
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;
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions src/core/layouts/position-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
}
8 changes: 5 additions & 3 deletions tests/export-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResolvedClip["transition"]>["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", () => {
Expand Down
18 changes: 17 additions & 1 deletion tests/position-builder.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
});
33 changes: 31 additions & 2 deletions tests/transition-preset-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand Down
Loading