Files
lemonspace_app/lib/mixer-crop-layout.ts

220 lines
6.0 KiB
TypeScript

const MIN_CROP_REMAINING_SIZE = 0.1;
type MixerSurfaceFit = "contain" | "cover";
function formatPercent(value: number): string {
const normalized = Math.abs(value) < 1e-10 ? 0 : value;
return `${normalized}%`;
}
function computeFittedRect(args: {
sourceWidth: number;
sourceHeight: number;
boundsX: number;
boundsY: number;
boundsWidth: number;
boundsHeight: number;
fit?: MixerSurfaceFit;
}): { x: number; y: number; width: number; height: number } {
const {
sourceWidth,
sourceHeight,
boundsX,
boundsY,
boundsWidth,
boundsHeight,
fit = "contain",
} = args;
if (sourceWidth <= 0 || sourceHeight <= 0 || boundsWidth <= 0 || boundsHeight <= 0) {
return {
x: boundsX,
y: boundsY,
width: boundsWidth,
height: boundsHeight,
};
}
const scale =
fit === "cover"
? Math.max(boundsWidth / sourceWidth, boundsHeight / sourceHeight)
: Math.min(boundsWidth / sourceWidth, boundsHeight / sourceHeight);
if (!Number.isFinite(scale) || scale <= 0) {
return {
x: boundsX,
y: boundsY,
width: boundsWidth,
height: boundsHeight,
};
}
const width = sourceWidth * scale;
const height = sourceHeight * scale;
return {
x: boundsX + (boundsWidth - width) / 2,
y: boundsY + (boundsHeight - height) / 2,
width,
height,
};
}
export function computeMixerFrameRectInSurface(args: {
surfaceWidth: number;
surfaceHeight: number;
baseWidth: number;
baseHeight: number;
overlayX: number;
overlayY: number;
overlayWidth: number;
overlayHeight: number;
fit?: MixerSurfaceFit;
}): { x: number; y: number; width: number; height: number } | null {
if (args.baseWidth <= 0 || args.baseHeight <= 0 || args.surfaceWidth <= 0 || args.surfaceHeight <= 0) {
return null;
}
const baseRect = computeFittedRect({
sourceWidth: args.baseWidth,
sourceHeight: args.baseHeight,
boundsX: 0,
boundsY: 0,
boundsWidth: args.surfaceWidth,
boundsHeight: args.surfaceHeight,
fit: args.fit,
});
return {
x: (baseRect.x + args.overlayX * baseRect.width) / args.surfaceWidth,
y: (baseRect.y + args.overlayY * baseRect.height) / args.surfaceHeight,
width: (args.overlayWidth * baseRect.width) / args.surfaceWidth,
height: (args.overlayHeight * baseRect.height) / args.surfaceHeight,
};
}
export function computeVisibleMixerContentRect(args: {
frameAspectRatio: number;
sourceWidth: number;
sourceHeight: number;
cropLeft: number;
cropTop: number;
cropRight: number;
cropBottom: number;
}): { x: number; y: number; width: number; height: number } | null {
if (args.sourceWidth <= 0 || args.sourceHeight <= 0) {
return null;
}
const cropWidth = Math.max(1 - args.cropLeft - args.cropRight, MIN_CROP_REMAINING_SIZE);
const cropHeight = Math.max(1 - args.cropTop - args.cropBottom, MIN_CROP_REMAINING_SIZE);
const frameAspectRatio = args.frameAspectRatio > 0 ? args.frameAspectRatio : 1;
const rect = computeFittedRect({
sourceWidth: args.sourceWidth * cropWidth,
sourceHeight: args.sourceHeight * cropHeight,
boundsX: 0,
boundsY: 0,
boundsWidth: frameAspectRatio,
boundsHeight: 1,
});
return {
x: rect.x / frameAspectRatio,
y: rect.y,
width: rect.width / frameAspectRatio,
height: rect.height,
};
}
export function computeMixerCropImageStyle(args: {
frameAspectRatio: number;
sourceWidth: number;
sourceHeight: number;
cropLeft: number;
cropTop: number;
cropRight: number;
cropBottom: number;
}) {
const safeWidth = Math.max(1 - args.cropLeft - args.cropRight, MIN_CROP_REMAINING_SIZE);
const safeHeight = Math.max(1 - args.cropTop - args.cropBottom, MIN_CROP_REMAINING_SIZE);
const visibleRect = computeVisibleMixerContentRect(args);
if (!visibleRect) {
return {
left: formatPercent((-args.cropLeft / safeWidth) * 100),
top: formatPercent((-args.cropTop / safeHeight) * 100),
width: formatPercent((1 / safeWidth) * 100),
height: formatPercent((1 / safeHeight) * 100),
} as const;
}
const imageWidth = visibleRect.width / safeWidth;
const imageHeight = visibleRect.height / safeHeight;
return {
left: formatPercent((visibleRect.x - (args.cropLeft / safeWidth) * visibleRect.width) * 100),
top: formatPercent((visibleRect.y - (args.cropTop / safeHeight) * visibleRect.height) * 100),
width: formatPercent(imageWidth * 100),
height: formatPercent(imageHeight * 100),
} as const;
}
export function computeMixerCompareOverlayImageStyle(args: {
surfaceWidth: number;
surfaceHeight: number;
baseWidth: number;
baseHeight: number;
overlayX: number;
overlayY: number;
overlayWidth: number;
overlayHeight: number;
sourceWidth: number;
sourceHeight: number;
cropLeft: number;
cropTop: number;
cropRight: number;
cropBottom: number;
}) {
const frameRect = computeMixerFrameRectInSurface({
surfaceWidth: args.surfaceWidth,
surfaceHeight: args.surfaceHeight,
baseWidth: args.baseWidth,
baseHeight: args.baseHeight,
overlayX: args.overlayX,
overlayY: args.overlayY,
overlayWidth: args.overlayWidth,
overlayHeight: args.overlayHeight,
});
const frameAspectRatio =
frameRect && frameRect.width > 0 && frameRect.height > 0
? (frameRect.width * args.surfaceWidth) / (frameRect.height * args.surfaceHeight)
: args.overlayWidth > 0 && args.overlayHeight > 0
? args.overlayWidth / args.overlayHeight
: 1;
return computeMixerCropImageStyle({
frameAspectRatio,
sourceWidth: args.sourceWidth,
sourceHeight: args.sourceHeight,
cropLeft: args.cropLeft,
cropTop: args.cropTop,
cropRight: args.cropRight,
cropBottom: args.cropBottom,
});
}
export function isMixerCropImageReady(args: {
currentOverlayUrl: string | null | undefined;
loadedOverlayUrl: string | null;
sourceWidth: number;
sourceHeight: number;
}): boolean {
return Boolean(
args.currentOverlayUrl &&
args.loadedOverlayUrl === args.currentOverlayUrl &&
args.sourceWidth > 0 &&
args.sourceHeight > 0,
);
}