diff --git a/pages/scene/+Page.vue b/pages/scene/+Page.vue index c39d187..94a01c1 100644 --- a/pages/scene/+Page.vue +++ b/pages/scene/+Page.vue @@ -153,25 +153,30 @@ >{{ tag.name }} - +
- + + @@ -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); diff --git a/src/actors.js b/src/actors.js index f4283e9..ba32697 100644 --- a/src/actors.js +++ b/src/actors.js @@ -143,6 +143,7 @@ export function curateActor(actor, context = {}) { avatar: actor.avatar && curateMedia({ ...actor.avatar, sfw_media: actor.sfw_avatar, + biometrics: actor.biometrics, }), allowGlobalMatch: actor.allow_global_match, 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(sfw_media) as sfw_avatar'), 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 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('media as avatars', 'avatars.id', 'actors.avatar_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') - .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') diff --git a/src/media.js b/src/media.js index 28c0ee4..fad37f2 100644 --- a/src/media.js +++ b/src/media.js @@ -32,6 +32,13 @@ export function curateMedia(media, context = {}) { type: context.type || null, sfw: curateMedia(media.sfw_media), isRestricted: context.isRestricted, + biometrics: media.biometrics + ? { + ...media.biometrics.biometrics, + width: media.biometrics.width, + height: media.biometrics.height, + } + : null, createdAt: media.created_at, }; } diff --git a/src/scenes.js b/src/scenes.js index dc45fba..d3cf26d 100644 --- a/src/scenes.js +++ b/src/scenes.js @@ -209,6 +209,7 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) { 'actors.*', knex.raw('row_to_json(avatars) as 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('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', @@ -219,9 +220,10 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) { .leftJoin('actors as aliases', 'aliases.id', 'releases_actors.alias_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_biometrics', 'media_biometrics.media_id', 'actors.avatar_media_id') .leftJoin('countries', 'countries.alpha2', 'actors.birth_country_alpha2') .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') .whereIn('release_id', sceneIds) .leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),