feat(canvas): separate mixer resize and crop semantics
This commit is contained in:
219
lib/mixer-crop-layout.ts
Normal file
219
lib/mixer-crop-layout.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user