mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
c8d0597359
FaceEditor previously required an HTMLImageElement | HTMLVideoElement prop to compute layout metrics and generate the face crop preview. This was unavailable for video assets, so the preview thumbnail in the Create Person modal was always missing, and face positions could be NaN during image load (naturalWidth is 0 before the image decodes). Replace the DOM element prop with assetSize: Size and containerSize: Size, using asset metadata dimensions that are always available from the API response. computeContentMetrics() is extracted as a pure utility alongside mapContentRectToNatural() for converting face rect coordinates back to original image space. For videos, VideoNativeViewer now captures the current frame to canvas when face edit mode opens and sets assetViewerManager.imgRef, giving FaceEditor the same image-based preview path as photo assets. Change-Id: I0e9da549e3af40211abad4ab2c0270706a6a6964
128 lines
3.9 KiB
TypeScript
128 lines
3.9 KiB
TypeScript
// Coordinate spaces used throughout the viewer:
|
||
//
|
||
// "Normalized": 0–1 range, (0,0) = top-left, (1,1) = bottom-right. Resolution-independent.
|
||
// Example: OCR coordinates, or face coords after dividing by metadata dimensions.
|
||
//
|
||
// "Content": pixel position within the container after scaling (scaleToFit/scaleToCover)
|
||
// and centering. Used for DOM overlay positioning (face boxes, OCR text).
|
||
//
|
||
// "Natural": pixel position in the original full-resolution image file (e.g. 4000×3000).
|
||
// Used when cropping or drawing on the source image.
|
||
//
|
||
// "Metadata pixel space": coordinates from face detection / OCR models, in pixels relative
|
||
// to face.imageWidth/imageHeight. Divide by those dimensions to get normalized coords.
|
||
|
||
export type Point = {
|
||
x: number;
|
||
y: number;
|
||
};
|
||
|
||
export type Size = {
|
||
width: number;
|
||
height: number;
|
||
};
|
||
|
||
export type ContentMetrics = {
|
||
contentWidth: number;
|
||
contentHeight: number;
|
||
offsetX: number;
|
||
offsetY: number;
|
||
};
|
||
|
||
export const scaleToCover = (dimensions: Size, container: Size): Size => {
|
||
const scaleX = container.width / dimensions.width;
|
||
const scaleY = container.height / dimensions.height;
|
||
const scale = Math.max(scaleX, scaleY);
|
||
return {
|
||
width: dimensions.width * scale,
|
||
height: dimensions.height * scale,
|
||
};
|
||
};
|
||
|
||
export const scaleToFit = (dimensions: Size, container: Size): Size => {
|
||
const scaleX = container.width / dimensions.width;
|
||
const scaleY = container.height / dimensions.height;
|
||
const scale = Math.min(scaleX, scaleY);
|
||
return {
|
||
width: dimensions.width * scale,
|
||
height: dimensions.height * scale,
|
||
};
|
||
};
|
||
|
||
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
|
||
if (element instanceof HTMLVideoElement) {
|
||
return { width: element.videoWidth, height: element.videoHeight };
|
||
}
|
||
return { width: element.naturalWidth, height: element.naturalHeight };
|
||
};
|
||
|
||
export function computeContentMetrics(content: Size, container: Size): ContentMetrics {
|
||
if (content.width === 0 || content.height === 0) {
|
||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||
}
|
||
const { width: contentWidth, height: contentHeight } = scaleToFit(content, container);
|
||
return {
|
||
contentWidth,
|
||
contentHeight,
|
||
offsetX: (container.width - contentWidth) / 2,
|
||
offsetY: (container.height - contentHeight) / 2,
|
||
};
|
||
}
|
||
|
||
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
|
||
if ('contentWidth' in sizeOrMetrics) {
|
||
return {
|
||
x: point.x * sizeOrMetrics.contentWidth + sizeOrMetrics.offsetX,
|
||
y: point.y * sizeOrMetrics.contentHeight + sizeOrMetrics.offsetY,
|
||
};
|
||
}
|
||
return {
|
||
x: point.x * sizeOrMetrics.width,
|
||
y: point.y * sizeOrMetrics.height,
|
||
};
|
||
}
|
||
|
||
export type Rect = {
|
||
top: number;
|
||
left: number;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
|
||
export function mapNormalizedRectToContent(
|
||
topLeft: Point,
|
||
bottomRight: Point,
|
||
sizeOrMetrics: Size | ContentMetrics,
|
||
): Rect {
|
||
const tl = mapNormalizedToContent(topLeft, sizeOrMetrics);
|
||
const br = mapNormalizedToContent(bottomRight, sizeOrMetrics);
|
||
return {
|
||
top: tl.y,
|
||
left: tl.x,
|
||
width: br.x - tl.x,
|
||
height: br.y - tl.y,
|
||
};
|
||
}
|
||
|
||
function mapContentToNatural(point: Point, metrics: ContentMetrics, naturalSize: Size): Point {
|
||
return {
|
||
x: ((point.x - metrics.offsetX) / metrics.contentWidth) * naturalSize.width,
|
||
y: ((point.y - metrics.offsetY) / metrics.contentHeight) * naturalSize.height,
|
||
};
|
||
}
|
||
|
||
export function mapContentRectToNatural(rect: Rect, metrics: ContentMetrics, naturalSize: Size): Rect {
|
||
const topLeft = mapContentToNatural({ x: rect.left, y: rect.top }, metrics, naturalSize);
|
||
const bottomRight = mapContentToNatural(
|
||
{ x: rect.left + rect.width, y: rect.top + rect.height },
|
||
metrics,
|
||
naturalSize,
|
||
);
|
||
return {
|
||
top: topLeft.y,
|
||
left: topLeft.x,
|
||
width: bottomRight.x - topLeft.x,
|
||
height: bottomRight.y - topLeft.y,
|
||
};
|
||
}
|