Using biometric data for actor tag face positioning if available.
This commit is contained in:
@@ -153,25 +153,30 @@
|
|||||||
>{{ tag.name }}</Link>
|
>{{ tag.name }}</Link>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
v-for="tagActor in tag.actors"
|
v-if="tag.actors.length > 0"
|
||||||
:key="`tagactor-${tagActor.id}`"
|
|
||||||
v-tooltip="{
|
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'],
|
triggers: ['hover', 'click'],
|
||||||
}"
|
}"
|
||||||
class="tag-frame"
|
class="tag-actors"
|
||||||
>
|
>
|
||||||
<img
|
<template
|
||||||
v-if="tagActor.avatar"
|
v-for="tagActor in tag.actors"
|
||||||
class="tag-avatar"
|
|
||||||
:src="getPath(tagActor.avatar, 'thumbnail')"
|
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
v-if="tagActor.avatar"
|
||||||
|
:key="`tagactor-${tagActor.id}`"
|
||||||
|
class="tag-avatar"
|
||||||
|
:style="tagActor.avatarStyle"
|
||||||
|
/>
|
||||||
|
|
||||||
<Icon
|
<Icon
|
||||||
v-else
|
v-else
|
||||||
icon="star-full"
|
:key="`tagactor-${tagActor.id}`"
|
||||||
class="tag-star"
|
icon="star-full"
|
||||||
/>
|
class="tag-star"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -462,14 +467,140 @@ const {
|
|||||||
|
|
||||||
const { scene } = pageProps;
|
const { scene } = pageProps;
|
||||||
|
|
||||||
/*
|
const avatarFrameSize = 36;
|
||||||
const tags = scene.tags.map((tag) => ({
|
const avatarMargin = 2.6;
|
||||||
...tag,
|
const avatarVerticalOffset = 0.07; // fraction of crop size; positive shifts crop down (frames more chin, less forehead)
|
||||||
actor: scene.actors.find((actor) => actor.id === tag.actorId) || null,
|
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
|
const tags = Array.from(scene.tags
|
||||||
.reduce((acc, tag) => {
|
.reduce((acc, tag) => {
|
||||||
@@ -731,6 +862,7 @@ function copySummary() {
|
|||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
box-shadow: 0 0 3px var(--shadow-weak-30);
|
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||||
@@ -752,11 +884,19 @@ function copySummary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-frame {
|
.tag-actors {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
justify-content: center;
|
align-items: center;
|
||||||
|
padding: 0 .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-avatar {
|
||||||
width: 2.25rem;
|
width: 2.25rem;
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0.1rem .05rem;
|
||||||
|
border-radius: .25rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
@@ -765,14 +905,11 @@ function copySummary() {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
box-shadow: inset 0 0 3px var(--shadow-weak-20);
|
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 */
|
pointer-events: none; /* so it doesn't block hover/click on the image */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-avatar {
|
|
||||||
height: 350%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-star {
|
.tag-star {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
fill: var(--primary);
|
fill: var(--primary);
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export function curateActor(actor, context = {}) {
|
|||||||
avatar: actor.avatar && curateMedia({
|
avatar: actor.avatar && curateMedia({
|
||||||
...actor.avatar,
|
...actor.avatar,
|
||||||
sfw_media: actor.sfw_avatar,
|
sfw_media: actor.sfw_avatar,
|
||||||
|
biometrics: actor.biometrics,
|
||||||
}),
|
}),
|
||||||
allowGlobalMatch: actor.allow_global_match,
|
allowGlobalMatch: actor.allow_global_match,
|
||||||
socials: context.socials?.map((social) => ({
|
socials: context.socials?.map((social) => ({
|
||||||
@@ -246,6 +247,7 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
|
|||||||
knex.raw('row_to_json(entities) as entity'),
|
knex.raw('row_to_json(entities) as entity'),
|
||||||
knex.raw('row_to_json(sfw_media) as sfw_avatar'),
|
knex.raw('row_to_json(sfw_media) as sfw_avatar'),
|
||||||
knex.raw('json_agg(aliases) filter (where aliases.id is not null) as aliases'),
|
knex.raw('json_agg(aliases) filter (where aliases.id is not null) as aliases'),
|
||||||
|
knex.raw('row_to_json(media_biometrics) as biometrics'),
|
||||||
)
|
)
|
||||||
.leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id')
|
.leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id')
|
||||||
.leftJoin('actors as aliases', 'aliases.alias_for', 'actors.id')
|
.leftJoin('actors as aliases', 'aliases.alias_for', 'actors.id')
|
||||||
@@ -253,8 +255,9 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
|
|||||||
.leftJoin('countries as residence_countries', 'residence_countries.alpha2', 'actors.residence_country_alpha2')
|
.leftJoin('countries as residence_countries', 'residence_countries.alpha2', 'actors.residence_country_alpha2')
|
||||||
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
|
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
|
||||||
.leftJoin('media as sfw_media', 'sfw_media.id', 'avatars.sfw_media_id')
|
.leftJoin('media as sfw_media', 'sfw_media.id', 'avatars.sfw_media_id')
|
||||||
|
.leftJoin('media_biometrics', 'media_biometrics.media_id', 'actors.avatar_media_id')
|
||||||
.leftJoin('entities', 'entities.id', 'actors.entity_id')
|
.leftJoin('entities', 'entities.id', 'actors.entity_id')
|
||||||
.groupBy('actors.id', 'avatars.id', 'sfw_media.id', 'entities.id', 'actors_meta.stashed', 'birth_countries.alpha2', 'residence_countries.alpha2');
|
.groupBy('actors.id', 'avatars.id', 'sfw_media.id', 'media_biometrics.id', 'entities.id', 'actors_meta.stashed', 'birth_countries.alpha2', 'residence_countries.alpha2');
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
knex('actors_profiles')
|
knex('actors_profiles')
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ export function curateMedia(media, context = {}) {
|
|||||||
type: context.type || null,
|
type: context.type || null,
|
||||||
sfw: curateMedia(media.sfw_media),
|
sfw: curateMedia(media.sfw_media),
|
||||||
isRestricted: context.isRestricted,
|
isRestricted: context.isRestricted,
|
||||||
|
biometrics: media.biometrics
|
||||||
|
? {
|
||||||
|
...media.biometrics.biometrics,
|
||||||
|
width: media.biometrics.width,
|
||||||
|
height: media.biometrics.height,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
createdAt: media.created_at,
|
createdAt: media.created_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
|
|||||||
'actors.*',
|
'actors.*',
|
||||||
knex.raw('row_to_json(avatars) as avatar'),
|
knex.raw('row_to_json(avatars) as avatar'),
|
||||||
knex.raw('row_to_json(sfw_media) as sfw_avatar'),
|
knex.raw('row_to_json(sfw_media) as sfw_avatar'),
|
||||||
|
knex.raw('row_to_json(media_biometrics) as biometrics'),
|
||||||
knex.raw('row_to_json(aliases) as alias'),
|
knex.raw('row_to_json(aliases) as alias'),
|
||||||
knex.raw('case when aliases.id is not null then json_build_object(\'id\', aliases.id, \'name\', aliases.name, \'slug\', aliases.slug) end as alias'),
|
knex.raw('case when aliases.id is not null then json_build_object(\'id\', aliases.id, \'name\', aliases.name, \'slug\', aliases.slug) end as alias'),
|
||||||
'countries.name as birth_country_name',
|
'countries.name as birth_country_name',
|
||||||
@@ -219,9 +220,10 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
|
|||||||
.leftJoin('actors as aliases', 'aliases.id', 'releases_actors.alias_id')
|
.leftJoin('actors as aliases', 'aliases.id', 'releases_actors.alias_id')
|
||||||
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
|
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
|
||||||
.leftJoin('media as sfw_media', 'sfw_media.id', 'avatars.sfw_media_id')
|
.leftJoin('media as sfw_media', 'sfw_media.id', 'avatars.sfw_media_id')
|
||||||
|
.leftJoin('media_biometrics', 'media_biometrics.media_id', 'actors.avatar_media_id')
|
||||||
.leftJoin('countries', 'countries.alpha2', 'actors.birth_country_alpha2')
|
.leftJoin('countries', 'countries.alpha2', 'actors.birth_country_alpha2')
|
||||||
.whereIn('release_id', sceneIds)
|
.whereIn('release_id', sceneIds)
|
||||||
.groupBy('actors.id', 'aliases.id', 'releases_actors.release_id', 'avatars.id', 'countries.name', 'countries.alias', 'sfw_media.id'),
|
.groupBy('actors.id', 'aliases.id', 'releases_actors.release_id', 'avatars.id', 'media_biometrics.id', 'countries.name', 'countries.alias', 'sfw_media.id'),
|
||||||
directors: knex('releases_directors')
|
directors: knex('releases_directors')
|
||||||
.whereIn('release_id', sceneIds)
|
.whereIn('release_id', sceneIds)
|
||||||
.leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),
|
.leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),
|
||||||
|
|||||||
Reference in New Issue
Block a user