220 lines
6.0 KiB
TypeScript
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,
|
|
);
|
|
}
|