Using biometric data for actor tag face positioning if available.

This commit is contained in:
2026-07-05 03:42:40 +02:00
parent 885fe7c9e9
commit 804cdcbf59
4 changed files with 178 additions and 29 deletions

View File

@@ -153,25 +153,30 @@
>{{ tag.name }}</Link>
<span
v-for="tagActor in tag.actors"
:key="`tagactor-${tagActor.id}`"
v-if="tag.actors.length > 0"
v-tooltip="{
content: `Performed by ${tagActor.name}`,
content: `For ${tag.actors.slice(0, -1).map((tagActor) => tagActor.name).join(', ')}${tag.actors.length > 0 ? ` and ${tag.actors.at(-1).name}` : ''}`,
triggers: ['hover', 'click'],
}"
class="tag-frame"
class="tag-actors"
>
<img
v-if="tagActor.avatar"
class="tag-avatar"
:src="getPath(tagActor.avatar, 'thumbnail')"
<template
v-for="tagActor in tag.actors"
>
<div
v-if="tagActor.avatar"
:key="`tagactor-${tagActor.id}`"
class="tag-avatar"
:style="tagActor.avatarStyle"
/>
<Icon
v-else
icon="star-full"
class="tag-star"
/>
<Icon
v-else
:key="`tagactor-${tagActor.id}`"
icon="star-full"
class="tag-star"
/>
</template>
</span>
</li>
</ul>
@@ -462,14 +467,140 @@ const {
const { scene } = pageProps;
/*
const tags = scene.tags.map((tag) => ({
...tag,
actor: scene.actors.find((actor) => actor.id === tag.actorId) || null,
}));
*/
const avatarFrameSize = 36;
const avatarMargin = 2.6;
const avatarVerticalOffset = 0.07; // fraction of crop size; positive shifts crop down (frames more chin, less forehead)
const fallbackZoom = 2.25; // zoom level when no biometrics are available
const actorsById = Object.fromEntries(scene.actors.map((actor) => [actor.id, actor]));
function clampCrop(crop) {
const size = Math.min(crop.size, crop.imageWidth, crop.imageHeight);
const maxLeft = Math.max(0, crop.imageWidth - size);
const maxTop = Math.max(0, crop.imageHeight - size);
return {
...crop,
size,
left: Math.min(Math.max(crop.left, 0), maxLeft),
top: Math.min(Math.max(crop.top, 0), maxTop),
};
}
function getFaceCrop(biometrics) {
if (!biometrics) {
return null;
}
const {
box,
leftEye,
rightEye,
mouth,
width: imageWidth,
height: imageHeight,
} = biometrics;
if (!imageWidth || !imageHeight) {
return null;
}
if (leftEye && rightEye && mouth) {
const xs = [leftEye[0], rightEye[0], mouth[0]];
const ys = [leftEye[1], rightEye[1], mouth[1]];
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const triangleWidth = maxX - minX;
const triangleHeight = maxY - minY;
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const size = Math.max(triangleWidth, triangleHeight) * avatarMargin;
return clampCrop({
left: centerX - size / 2,
top: centerY - size / 2 - size * avatarVerticalOffset,
size,
imageWidth,
imageHeight,
});
}
// fallback to the plain detection box if landmarks are missing
if (box) {
const [x, y, width, height] = box;
const size = Math.max(width, height);
return clampCrop({
left: x + width / 2 - size / 2,
top: y + height / 2 - size / 2 - size * avatarVerticalOffset,
size,
imageWidth,
imageHeight,
});
}
return null;
}
function getFallbackCrop(imageWidth, imageHeight) {
const size = Math.min(imageWidth, imageHeight) / fallbackZoom;
return clampCrop({
left: (imageWidth - size) / 2, // centered horizontally
top: 0, // anchored to top
size,
imageWidth,
imageHeight,
});
}
function getAvatarBackgroundStyle(actorAvatar) {
if (!actorAvatar) {
return null;
}
const avatarUrl = getPath(actorAvatar, 'thumbnail');
const crop = getFaceCrop(actorAvatar.biometrics)
|| (actorAvatar.width && actorAvatar.height ? getFallbackCrop(actorAvatar.width, actorAvatar.height) : null);
if (!crop) {
// no dimensions available at all — last-resort plain cover
return {
backgroundImage: `url(${avatarUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
};
}
const scale = avatarFrameSize / crop.size;
const backgroundWidth = crop.imageWidth * scale;
const backgroundHeight = crop.imageHeight * scale;
const backgroundPositionX = -(crop.left * scale);
const backgroundPositionY = -(crop.top * scale);
return {
backgroundImage: `url(${avatarUrl})`,
backgroundSize: `${backgroundWidth}px ${backgroundHeight}px`,
backgroundPosition: `${backgroundPositionX}px ${backgroundPositionY}px`,
backgroundRepeat: 'no-repeat',
};
}
const actorsById = Object.fromEntries(scene.actors.map((actor) => {
const curatedActor = {
...actor,
avatarStyle: getAvatarBackgroundStyle(actor.avatar),
};
return [actor.id, curatedActor];
}));
const tags = Array.from(scene.tags
.reduce((acc, tag) => {
@@ -731,6 +862,7 @@ function copySummary() {
.tag {
display: flex;
align-items: center;
border-radius: .25rem;
background: var(--background);
box-shadow: 0 0 3px var(--shadow-weak-30);
@@ -752,11 +884,19 @@ function copySummary() {
}
}
.tag-frame {
display: inline-flex;
justify-content: center;
.tag-actors {
display: flex;
align-items: center;
padding: 0 .1rem;
}
.tag-avatar {
width: 2.25rem;
height: 2.25rem;
display: inline-flex;
justify-content: center;
margin: 0.1rem .05rem;
border-radius: .25rem;
position: relative;
overflow: hidden;
@@ -765,14 +905,11 @@ function copySummary() {
position: absolute;
inset: 0;
box-shadow: inset 0 0 3px var(--shadow-weak-20);
border-radius: inherit;
pointer-events: none; /* so it doesn't block hover/click on the image */
}
}
.tag-avatar {
height: 350%;
}
.tag-star {
height: 100%;
fill: var(--primary);