Added logo animation to login screen, initial work

This commit is contained in:
2026-02-15 13:49:15 +00:00
parent 7fbf1dcb95
commit 83b941262e
16 changed files with 506 additions and 76 deletions
+323
View File
@@ -0,0 +1,323 @@
import { useMemo } from "react";
import { AbsoluteFill, Easing, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
type PillProps = {
src: string;
x: number;
y: number;
rotation: number;
zIndex: number;
};
type PillTransform = {
x: number;
y: number;
rotation: number;
};
const PILL_WIDTH = 200;
const PILL_HEIGHT = 450;
const RISE_DURATION_FRAMES = 150;
const RISE_SETTLE_PAUSE_FRAMES = 0;
const GAP_AFTER_RISE_FRAMES = 30;
const FAN_DURATION_FRAMES = 236;
const FAN_START_FRAME = RISE_DURATION_FRAMES + RISE_SETTLE_PAUSE_FRAMES + GAP_AFTER_RISE_FRAMES;
const OVERLAY_BLEND_START_PROGRESS = 0.5;
const OVERLAY_BLEND_TRANSITION_FRAMES = 150;
const OVERLAP_BLEND_MAX_OPACITY = 0.2;
const BACKGROUND_COLOR = "#efefef";
const COLOR_CODE = "#E38B16";
const COLOR_RX = "#0E7A7D";
const COLOR_DATA = "#109E6C";
const Pill: React.FC<PillProps> = ({ src, x, y, rotation, zIndex }) => {
return (
<Img
src={src}
style={{
position: "absolute",
width: PILL_WIDTH,
height: PILL_HEIGHT,
left: "50%",
top: "50%",
transform: `translate(-50%, -50%) translate(${x}px, ${y}px) rotate(${rotation}deg)`,
transformOrigin: "50% 100%",
zIndex,
}}
/>
);
};
const withCapsuleTransform = ({
ctx,
transform,
videoWidth,
videoHeight,
draw,
}: {
ctx: CanvasRenderingContext2D;
transform: PillTransform;
videoWidth: number;
videoHeight: number;
draw: () => void;
}) => {
ctx.save();
// Match the CSS transform order and origin used for the visible pills.
ctx.translate(videoWidth / 2, videoHeight / 2);
ctx.translate(-PILL_WIDTH / 2, -PILL_HEIGHT / 2);
ctx.translate(transform.x, transform.y);
ctx.translate(PILL_WIDTH / 2, PILL_HEIGHT);
ctx.rotate((transform.rotation * Math.PI) / 180);
ctx.translate(-PILL_WIDTH / 2, -PILL_HEIGHT);
draw();
ctx.restore();
};
const drawCapsuleFill = ({
ctx,
transform,
fill,
videoWidth,
videoHeight,
}: {
ctx: CanvasRenderingContext2D;
transform: PillTransform;
fill: string;
videoWidth: number;
videoHeight: number;
}) => {
withCapsuleTransform({
ctx,
transform,
videoWidth,
videoHeight,
draw: () => {
ctx.fillStyle = fill;
ctx.beginPath();
ctx.roundRect(0, 0, PILL_WIDTH, PILL_HEIGHT, PILL_WIDTH / 2);
ctx.fill();
},
});
};
const buildPairBlendIntersection = ({
outputCtx,
pairCtx,
maskCtx,
colorA,
colorB,
transformA,
transformB,
videoWidth,
videoHeight,
}: {
outputCtx: CanvasRenderingContext2D;
pairCtx: CanvasRenderingContext2D;
maskCtx: CanvasRenderingContext2D;
colorA: string;
colorB: string;
transformA: PillTransform;
transformB: PillTransform;
videoWidth: number;
videoHeight: number;
}) => {
// Build blended color result for the two pills.
pairCtx.clearRect(0, 0, videoWidth, videoHeight);
pairCtx.globalCompositeOperation = "source-over";
drawCapsuleFill({ ctx: pairCtx, transform: transformA, fill: colorA, videoWidth, videoHeight });
pairCtx.globalCompositeOperation = "multiply";
drawCapsuleFill({ ctx: pairCtx, transform: transformB, fill: colorB, videoWidth, videoHeight });
pairCtx.globalCompositeOperation = "source-over";
// Build hard intersection mask for the same pair.
maskCtx.clearRect(0, 0, videoWidth, videoHeight);
maskCtx.globalCompositeOperation = "source-over";
drawCapsuleFill({ ctx: maskCtx, transform: transformA, fill: "#fff", videoWidth, videoHeight });
maskCtx.globalCompositeOperation = "source-in";
drawCapsuleFill({ ctx: maskCtx, transform: transformB, fill: "#fff", videoWidth, videoHeight });
maskCtx.globalCompositeOperation = "source-over";
// Keep only overlap area from blended result.
pairCtx.globalCompositeOperation = "destination-in";
pairCtx.drawImage(maskCtx.canvas, 0, 0);
pairCtx.globalCompositeOperation = "source-over";
// Accumulate pair overlap into final output.
outputCtx.drawImage(pairCtx.canvas, 0, 0);
};
export const MyComposition: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const overlayBlendEndProgress = Math.min(
1,
OVERLAY_BLEND_START_PROGRESS + OVERLAY_BLEND_TRANSITION_FRAMES / FAN_DURATION_FRAMES,
);
const codeSrc = staticFile("logo/capsuleCode.svg");
const rxSrc = staticFile("logo/capsuleRx.svg");
const dataSrc = staticFile("logo/capsuleData.svg");
const rise = interpolate(frame, [0, RISE_DURATION_FRAMES], [0, 1], {
easing: Easing.out(Easing.cubic),
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const fan = spring({
frame: frame - FAN_START_FRAME,
fps,
durationInFrames: FAN_DURATION_FRAMES,
config: { damping: 200 },
});
const centerY = interpolate(rise, [0, 1], [560, 76], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const leftY = interpolate(fan, [0, 1], [centerY, 74], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const rightY = interpolate(fan, [0, 1], [centerY, 74], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const leftX = interpolate(fan, [0, 1], [0, -36], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const rightX = interpolate(fan, [0, 1], [0, 36], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const leftRotation = interpolate(fan, [0, 1], [0, -50], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const rightRotation = interpolate(fan, [0, 1], [0, 50], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const overlapBlendProgress =
overlayBlendEndProgress >= 1
? interpolate(fan, [0, OVERLAY_BLEND_START_PROGRESS, 1], [0, 0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
})
: interpolate(
fan,
[0, OVERLAY_BLEND_START_PROGRESS, overlayBlendEndProgress, 1],
[0, 0, 1, 1],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
},
);
const overlapMaskSrc = useMemo(() => {
const centerTransform: PillTransform = { x: 0, y: centerY, rotation: 0 };
const leftTransform: PillTransform = { x: leftX, y: leftY, rotation: leftRotation };
const rightTransform: PillTransform = { x: rightX, y: rightY, rotation: rightRotation };
const outputCanvas = document.createElement("canvas");
outputCanvas.width = width;
outputCanvas.height = height;
const outputCtx = outputCanvas.getContext("2d");
const pairCanvas = document.createElement("canvas");
pairCanvas.width = width;
pairCanvas.height = height;
const pairCtx = pairCanvas.getContext("2d");
const maskCanvas = document.createElement("canvas");
maskCanvas.width = width;
maskCanvas.height = height;
const maskCtx = maskCanvas.getContext("2d");
if (!outputCtx || !pairCtx || !maskCtx) {
return null;
}
outputCtx.clearRect(0, 0, width, height);
buildPairBlendIntersection({
outputCtx,
pairCtx,
maskCtx,
colorA: COLOR_CODE,
colorB: COLOR_RX,
transformA: centerTransform,
transformB: leftTransform,
videoWidth: width,
videoHeight: height,
});
buildPairBlendIntersection({
outputCtx,
pairCtx,
maskCtx,
colorA: COLOR_CODE,
colorB: COLOR_DATA,
transformA: centerTransform,
transformB: rightTransform,
videoWidth: width,
videoHeight: height,
});
buildPairBlendIntersection({
outputCtx,
pairCtx,
maskCtx,
colorA: COLOR_RX,
colorB: COLOR_DATA,
transformA: leftTransform,
transformB: rightTransform,
videoWidth: width,
videoHeight: height,
});
const pixels = outputCtx.getImageData(0, 0, width, height).data;
let hasAlphaPixels = false;
for (let i = 3; i < pixels.length; i += 4) {
if (pixels[i] > 0) {
hasAlphaPixels = true;
break;
}
}
if (!hasAlphaPixels) {
return null;
}
return outputCanvas.toDataURL("image/png");
}, [centerY, height, leftRotation, leftX, leftY, rightRotation, rightX, rightY, width]);
return (
<AbsoluteFill style={{ backgroundColor: BACKGROUND_COLOR }}>
<Pill src={codeSrc} x={0} y={centerY} rotation={0} zIndex={3} />
<Pill src={rxSrc} x={leftX} y={leftY} rotation={leftRotation} zIndex={1} />
<Pill src={dataSrc} x={rightX} y={rightY} rotation={rightRotation} zIndex={2} />
{overlapMaskSrc ? (
<Img
src={overlapMaskSrc}
style={{
position: "absolute",
inset: 0,
opacity: OVERLAP_BLEND_MAX_OPACITY * overlapBlendProgress,
pointerEvents: "none",
zIndex: 20,
}}
/>
) : null}
</AbsoluteFill>
);
};