269 lines
7.1 KiB
TypeScript
269 lines
7.1 KiB
TypeScript
export interface RenderRect {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export interface StyledRenderRect extends RenderRect {
|
|
borderRadius: number;
|
|
}
|
|
|
|
export interface Size {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack";
|
|
|
|
export interface WebcamLayoutShadow {
|
|
color: string;
|
|
blur: number;
|
|
offsetX: number;
|
|
offsetY: number;
|
|
}
|
|
|
|
interface BorderRadiusRule {
|
|
max: number;
|
|
min: number;
|
|
fraction: number;
|
|
}
|
|
|
|
interface OverlayTransform {
|
|
type: "overlay";
|
|
maxStageFraction: number;
|
|
marginFraction: number;
|
|
minMargin: number;
|
|
minSize: number;
|
|
}
|
|
|
|
interface StackTransform {
|
|
type: "stack";
|
|
gap: number;
|
|
}
|
|
|
|
export interface WebcamLayoutPresetDefinition {
|
|
label: string;
|
|
transform: OverlayTransform | StackTransform;
|
|
borderRadius: BorderRadiusRule;
|
|
shadow: WebcamLayoutShadow | null;
|
|
}
|
|
|
|
export interface WebcamCompositeLayout {
|
|
screenRect: RenderRect;
|
|
webcamRect: StyledRenderRect | null;
|
|
}
|
|
|
|
const MAX_STAGE_FRACTION = 0.18;
|
|
const MARGIN_FRACTION = 0.02;
|
|
const MIN_SIZE = 96;
|
|
const MAX_BORDER_RADIUS = 24;
|
|
const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDefinition> = {
|
|
"picture-in-picture": {
|
|
label: "Picture in Picture",
|
|
transform: {
|
|
type: "overlay",
|
|
maxStageFraction: MAX_STAGE_FRACTION,
|
|
marginFraction: MARGIN_FRACTION,
|
|
minMargin: 12,
|
|
minSize: MIN_SIZE,
|
|
},
|
|
borderRadius: {
|
|
max: MAX_BORDER_RADIUS,
|
|
min: 12,
|
|
fraction: 0.12,
|
|
},
|
|
shadow: {
|
|
color: "rgba(0,0,0,0.35)",
|
|
blur: 24,
|
|
offsetX: 0,
|
|
offsetY: 10,
|
|
},
|
|
},
|
|
"vertical-stack": {
|
|
label: "Vertical Stack",
|
|
transform: {
|
|
type: "stack",
|
|
gap: 0,
|
|
},
|
|
borderRadius: {
|
|
max: 0,
|
|
min: 0,
|
|
fraction: 0,
|
|
},
|
|
shadow: null,
|
|
},
|
|
};
|
|
|
|
export const WEBCAM_LAYOUT_PRESETS = Object.entries(WEBCAM_LAYOUT_PRESET_MAP).map(
|
|
([value, preset]) => ({
|
|
value: value as WebcamLayoutPreset,
|
|
label: preset.label,
|
|
}),
|
|
);
|
|
|
|
export function getWebcamLayoutPresetDefinition(
|
|
preset: WebcamLayoutPreset = "picture-in-picture",
|
|
): WebcamLayoutPresetDefinition {
|
|
return WEBCAM_LAYOUT_PRESET_MAP[preset];
|
|
}
|
|
|
|
export function getWebcamLayoutCssBoxShadow(
|
|
preset: WebcamLayoutPreset = "picture-in-picture",
|
|
): string {
|
|
const shadow = getWebcamLayoutPresetDefinition(preset).shadow;
|
|
return shadow
|
|
? `${shadow.offsetX}px ${shadow.offsetY}px ${shadow.blur}px ${shadow.color}`
|
|
: "none";
|
|
}
|
|
|
|
export function computeCompositeLayout(params: {
|
|
canvasSize: Size;
|
|
maxContentSize?: Size;
|
|
screenSize: Size;
|
|
webcamSize?: Size | null;
|
|
layoutPreset?: WebcamLayoutPreset;
|
|
webcamPosition?: { cx: number; cy: number } | null;
|
|
}): WebcamCompositeLayout | null {
|
|
const {
|
|
canvasSize,
|
|
maxContentSize = canvasSize,
|
|
screenSize,
|
|
webcamSize,
|
|
layoutPreset = "picture-in-picture",
|
|
webcamPosition,
|
|
} = params;
|
|
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
|
const { width: maxContentWidth, height: maxContentHeight } = maxContentSize;
|
|
const { width: screenWidth, height: screenHeight } = screenSize;
|
|
const webcamWidth = webcamSize?.width;
|
|
const webcamHeight = webcamSize?.height;
|
|
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
|
|
|
|
if (canvasWidth <= 0 || canvasHeight <= 0 || screenWidth <= 0 || screenHeight <= 0) {
|
|
return null;
|
|
}
|
|
|
|
if (preset.transform.type === "stack") {
|
|
if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
|
|
return {
|
|
screenRect: centerRect({
|
|
canvasSize,
|
|
size: screenSize,
|
|
maxSize: maxContentSize,
|
|
}),
|
|
webcamRect: null,
|
|
};
|
|
}
|
|
|
|
const gap = preset.transform.gap;
|
|
const normalizedWebcamHeight = webcamHeight * (screenWidth / webcamWidth);
|
|
const combinedHeight = screenHeight + gap + normalizedWebcamHeight;
|
|
const scale = Math.min(maxContentWidth / screenWidth, maxContentHeight / combinedHeight, 1);
|
|
const clampedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
|
const resolvedScreenHeight = Math.round(screenHeight * clampedScale);
|
|
const resolvedScreenWidth = Math.round(screenWidth * clampedScale);
|
|
const resolvedWebcamHeight = Math.round(normalizedWebcamHeight * clampedScale);
|
|
const resolvedGap = Math.round(gap * clampedScale);
|
|
const totalHeight = resolvedScreenHeight + resolvedGap + resolvedWebcamHeight;
|
|
const top = Math.max(0, Math.floor((canvasHeight - totalHeight) / 2));
|
|
const left = Math.max(0, Math.floor((canvasWidth - resolvedScreenWidth) / 2));
|
|
const screenRect = {
|
|
x: left,
|
|
y: top,
|
|
width: resolvedScreenWidth,
|
|
height: resolvedScreenHeight,
|
|
};
|
|
|
|
return {
|
|
screenRect,
|
|
webcamRect: {
|
|
x: left,
|
|
y: top + resolvedScreenHeight + resolvedGap,
|
|
width: resolvedScreenWidth,
|
|
height: resolvedWebcamHeight,
|
|
borderRadius: Math.min(
|
|
preset.borderRadius.max,
|
|
Math.max(
|
|
preset.borderRadius.min,
|
|
Math.round(
|
|
Math.min(resolvedScreenWidth, resolvedWebcamHeight) * preset.borderRadius.fraction,
|
|
),
|
|
),
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
const transform = preset.transform;
|
|
const screenRect = centerRect({
|
|
canvasSize,
|
|
size: screenSize,
|
|
maxSize: maxContentSize,
|
|
});
|
|
|
|
if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
|
|
return { screenRect, webcamRect: null };
|
|
}
|
|
|
|
const margin = Math.max(
|
|
transform.minMargin,
|
|
Math.round(Math.min(canvasWidth, canvasHeight) * transform.marginFraction),
|
|
);
|
|
const maxWidth = Math.max(transform.minSize, canvasWidth * transform.maxStageFraction);
|
|
const maxHeight = Math.max(transform.minSize, canvasHeight * transform.maxStageFraction);
|
|
const scale = Math.min(maxWidth / webcamWidth, maxHeight / webcamHeight);
|
|
const width = Math.round(webcamWidth * scale);
|
|
const height = Math.round(webcamHeight * scale);
|
|
|
|
let webcamX: number;
|
|
let webcamY: number;
|
|
|
|
if (webcamPosition) {
|
|
// Custom position: cx/cy represent the center of the webcam as a fraction of the canvas
|
|
webcamX = Math.round(webcamPosition.cx * canvasWidth - width / 2);
|
|
webcamY = Math.round(webcamPosition.cy * canvasHeight - height / 2);
|
|
// Clamp to stay within canvas bounds
|
|
webcamX = Math.max(0, Math.min(canvasWidth - width, webcamX));
|
|
webcamY = Math.max(0, Math.min(canvasHeight - height, webcamY));
|
|
} else {
|
|
// Default: bottom-right with margin
|
|
webcamX = Math.max(0, Math.round(canvasWidth - margin - width));
|
|
webcamY = Math.max(0, Math.round(canvasHeight - margin - height));
|
|
}
|
|
|
|
return {
|
|
screenRect,
|
|
webcamRect: {
|
|
x: webcamX,
|
|
y: webcamY,
|
|
width,
|
|
height,
|
|
borderRadius: Math.min(
|
|
preset.borderRadius.max,
|
|
Math.max(
|
|
preset.borderRadius.min,
|
|
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
|
|
),
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
function centerRect(params: { canvasSize: Size; size: Size; maxSize: Size }): RenderRect {
|
|
const { canvasSize, size, maxSize } = params;
|
|
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
|
const { width, height } = size;
|
|
const { width: maxWidth, height: maxHeight } = maxSize;
|
|
const scale = Math.min(maxWidth / width, maxHeight / height, 1);
|
|
const resolvedWidth = Math.round(width * scale);
|
|
const resolvedHeight = Math.round(height * scale);
|
|
|
|
return {
|
|
x: Math.max(0, Math.floor((canvasWidth - resolvedWidth) / 2)),
|
|
y: Math.max(0, Math.floor((canvasHeight - resolvedHeight) / 2)),
|
|
width: resolvedWidth,
|
|
height: resolvedHeight,
|
|
};
|
|
}
|