Files
SkinbaseNova/app/Services/ArtworkEvolutionService.php

644 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\ArtworkRelation;
use App\Models\User;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Vision\VectorService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class ArtworkEvolutionService
{
/**
* @return list<string>
*/
public static function relationTypes(): array
{
return [
ArtworkRelation::TYPE_REMAKE_OF,
ArtworkRelation::TYPE_REMASTER_OF,
ArtworkRelation::TYPE_REVISION_OF,
ArtworkRelation::TYPE_INSPIRED_BY,
ArtworkRelation::TYPE_VARIATION_OF,
];
}
public function __construct(
private readonly ArtworkMaturityService $maturity,
private readonly GroupService $groups,
private readonly VectorService $vectors,
) {
}
/**
* @return array<int, array<string, string>>
*/
public function relationTypeOptions(): array
{
return array_map(fn (string $type): array => [
'value' => $type,
'label' => $this->relationTypeLabel($type),
'short_label' => $this->relationTypeShortLabel($type),
], self::relationTypes());
}
/**
* @param array{target_artwork_id?: int|null, relation_type?: string|null, note?: string|null} $payload
*/
public function syncPrimaryRelation(Artwork $sourceArtwork, User $actor, array $payload): ?ArtworkRelation
{
$this->ensureManageable($actor, $sourceArtwork, 'You can only update evolution links for artworks you manage.');
$targetArtworkId = (int) ($payload['target_artwork_id'] ?? 0);
$relationType = $this->normalizeRelationType((string) ($payload['relation_type'] ?? ArtworkRelation::TYPE_REMAKE_OF));
$note = $this->normalizeNote($payload['note'] ?? null);
if ($targetArtworkId <= 0) {
ArtworkRelation::query()->where('source_artwork_id', (int) $sourceArtwork->id)->delete();
return null;
}
if ($targetArtworkId === (int) $sourceArtwork->id) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose an older artwork, not the artwork you are editing right now.',
]);
}
$targetArtwork = Artwork::query()
->with(['group.members'])
->find($targetArtworkId);
if (! $targetArtwork) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose a valid artwork to link as the original version.',
]);
}
$this->ensureManageable($actor, $targetArtwork, 'You can only link artworks that you are allowed to manage.');
if (! $this->isPubliclyVisible($targetArtwork)) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose a published public artwork for the original version.',
]);
}
if (! $this->isOlderVersionCandidate($sourceArtwork, $targetArtwork)) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose an older artwork as the original version for this Then & Now story.',
]);
}
return DB::transaction(function () use ($sourceArtwork, $targetArtwork, $actor, $relationType, $note): ArtworkRelation {
ArtworkRelation::query()
->where('source_artwork_id', (int) $sourceArtwork->id)
->delete();
return ArtworkRelation::query()->create([
'source_artwork_id' => (int) $sourceArtwork->id,
'target_artwork_id' => (int) $targetArtwork->id,
'relation_type' => $relationType,
'note' => $note,
'sort_order' => 0,
'created_by_user_id' => (int) $actor->id,
])->load([
'targetArtwork.user.profile',
'targetArtwork.group',
'targetArtwork.categories.contentType',
]);
});
}
/**
* @return array<string, mixed>|null
*/
public function editorRelation(Artwork $artwork, User $actor): ?array
{
$relation = ArtworkRelation::query()
->with(['targetArtwork.user.profile', 'targetArtwork.group', 'targetArtwork.categories.contentType'])
->where('source_artwork_id', (int) $artwork->id)
->orderBy('sort_order')
->orderBy('id')
->first();
if (! $relation || ! $relation->targetArtwork) {
return null;
}
return [
'id' => (int) $relation->id,
'relation_type' => (string) $relation->relation_type,
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
'short_label' => $this->relationTypeShortLabel((string) $relation->relation_type),
'note' => $relation->note,
'target_artwork' => $this->mapStudioOption($relation->targetArtwork, $actor),
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function manageableSearchOptions(Artwork $sourceArtwork, User $actor, string $search = '', int $limit = 18): array
{
$this->ensureManageable($actor, $sourceArtwork, 'You can only search evolution links for artworks you manage.');
$manageableGroupIds = collect($this->groups->studioOptionsForUser($actor))
->filter(fn (array $group): bool => (bool) data_get($group, 'permissions.can_publish_artworks', false))
->pluck('id')
->map(static fn ($id): int => (int) $id)
->filter()
->values();
$term = trim($search);
$safeLimit = max(1, min($limit, 36));
$rankedOptions = [];
$rankedIds = [];
if ($this->vectors->isConfigured()) {
$rankedOptions = $this->similarityRankedOptions($sourceArtwork, $actor, $manageableGroupIds->all(), $term, $safeLimit);
$rankedIds = array_map(static fn (array $option): int => (int) ($option['id'] ?? 0), $rankedOptions);
$rankedIds = array_values(array_filter($rankedIds));
}
if (count($rankedOptions) >= $safeLimit) {
return array_slice($rankedOptions, 0, $safeLimit);
}
$fallbackOptions = $this->fallbackSearchOptions(
$sourceArtwork,
$actor,
$manageableGroupIds->all(),
$term,
$safeLimit,
$rankedIds,
);
return collect(array_merge($rankedOptions, $fallbackOptions))
->unique('id')
->take($safeLimit)
->values()
->all();
}
/**
* @return array<string, mixed>|null
*/
public function publicPayload(Artwork $artwork, ?User $viewer = null): ?array
{
$primaryRelation = ArtworkRelation::query()
->with([
'sourceArtwork.user.profile',
'sourceArtwork.group',
'sourceArtwork.categories.contentType',
'targetArtwork.user.profile',
'targetArtwork.group',
'targetArtwork.categories.contentType',
])
->where('source_artwork_id', (int) $artwork->id)
->orderBy('sort_order')
->orderBy('id')
->first();
$incomingRelations = ArtworkRelation::query()
->with([
'sourceArtwork.user.profile',
'sourceArtwork.group',
'sourceArtwork.categories.contentType',
'targetArtwork.user.profile',
'targetArtwork.group',
'targetArtwork.categories.contentType',
])
->where('target_artwork_id', (int) $artwork->id)
->orderByDesc('updated_at')
->orderByDesc('id')
->limit(4)
->get();
$primary = $primaryRelation ? $this->mapPrimaryPanel($primaryRelation, $viewer) : null;
$updates = $incomingRelations
->map(fn (ArtworkRelation $relation): ?array => $this->mapIncomingUpdate($relation, $viewer))
->filter()
->values()
->all();
if ($primary === null && $updates === []) {
return null;
}
return [
'eyebrow' => 'Artwork Evolution',
'primary' => $primary,
'updates' => $updates,
];
}
private function ensureManageable(User $actor, Artwork $artwork, string $message): void
{
if (! Gate::forUser($actor)->allows('update', $artwork)) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => $message,
]);
}
}
private function isPubliclyVisible(Artwork $artwork): bool
{
return ! $artwork->trashed()
&& (bool) $artwork->is_public
&& (bool) $artwork->is_approved
&& $artwork->published_at !== null
&& $artwork->published_at->lte(now());
}
private function isOlderVersionCandidate(Artwork $sourceArtwork, Artwork $targetArtwork): bool
{
$sourceTimestamp = $this->comparisonTimestamp($sourceArtwork);
$targetTimestamp = $this->comparisonTimestamp($targetArtwork);
if ($sourceTimestamp === null || $targetTimestamp === null) {
return true;
}
return $targetTimestamp->lt($sourceTimestamp);
}
private function comparisonTimestamp(Artwork $artwork): ?Carbon
{
$value = $artwork->published_at ?: $artwork->created_at;
return $value instanceof Carbon ? $value : ($value ? Carbon::parse($value) : null);
}
private function normalizeRelationType(string $type): string
{
$normalized = Str::lower(trim($type));
return in_array($normalized, self::relationTypes(), true)
? $normalized
: ArtworkRelation::TYPE_REMAKE_OF;
}
private function normalizeNote(mixed $note): ?string
{
$resolved = trim((string) $note);
return $resolved !== '' ? $resolved : null;
}
/**
* @return array<string, mixed>
*/
private function mapStudioOption(Artwork $artwork, User $actor, array $context = []): array
{
$category = $artwork->categories->sortBy('sort_order')->first();
$publishedAt = $artwork->published_at;
$year = $publishedAt?->year ?: $artwork->created_at?->year;
$similarityScore = array_key_exists('similarity_score', $context) && is_numeric($context['similarity_score'])
? round((float) $context['similarity_score'], 5)
: null;
return [
'id' => (int) $artwork->id,
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'publisher' => $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
'year' => $year,
'published_at' => optional($publishedAt)->toIsoString(),
'thumbnail' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? null,
'url' => route('art.show', [
'id' => (int) $artwork->id,
'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id,
]),
'studio_edit_url' => route('studio.artworks.edit', ['id' => (int) $artwork->id]),
'content_type' => $category?->contentType?->name,
'category' => $category?->name,
'is_manageable' => Gate::forUser($actor)->allows('update', $artwork),
'similarity_score' => $similarityScore,
'sort_source' => (string) ($context['sort_source'] ?? 'fallback'),
];
}
/**
* @param list<int> $manageableGroupIds
* @return list<array<string, mixed>>
*/
private function similarityRankedOptions(Artwork $sourceArtwork, User $actor, array $manageableGroupIds, string $term, int $limit): array
{
try {
$matches = $this->vectors->similarToArtwork($sourceArtwork, min(120, max($limit * 4, 48)));
} catch (\Throwable) {
return [];
}
$orderedIds = [];
$scores = [];
foreach ($matches as $match) {
$candidateId = (int) ($match['id'] ?? 0);
if ($candidateId <= 0 || isset($scores[$candidateId])) {
continue;
}
$orderedIds[] = $candidateId;
$scores[$candidateId] = (float) ($match['score'] ?? 0.0);
}
if ($orderedIds === []) {
return [];
}
$candidates = $this->manageableCandidatesQuery($sourceArtwork, $actor, $manageableGroupIds, $term)
->whereIn('id', $orderedIds)
->get()
->keyBy('id');
$options = [];
foreach ($orderedIds as $candidateId) {
/** @var Artwork|null $candidate */
$candidate = $candidates->get($candidateId);
if (! $candidate) {
continue;
}
$options[] = $this->mapStudioOption($candidate, $actor, [
'similarity_score' => $scores[$candidateId] ?? null,
'sort_source' => 'vector_similarity',
]);
if (count($options) >= $limit) {
break;
}
}
return $options;
}
/**
* @param list<int> $manageableGroupIds
* @param list<int> $excludeIds
* @return list<array<string, mixed>>
*/
private function fallbackSearchOptions(Artwork $sourceArtwork, User $actor, array $manageableGroupIds, string $term, int $limit, array $excludeIds = []): array
{
$query = $this->manageableCandidatesQuery($sourceArtwork, $actor, $manageableGroupIds, $term);
if ($excludeIds !== []) {
$query->whereNotIn('id', $excludeIds);
}
return $query
->orderByRaw('CASE WHEN user_id = ? THEN 0 ELSE 1 END', [(int) $actor->id])
->orderByRaw('CASE WHEN published_at IS NULL THEN 1 ELSE 0 END')
->orderByDesc('published_at')
->limit(max($limit * 2, 36))
->get()
->map(fn (Artwork $candidate): array => $this->mapStudioOption($candidate, $actor))
->values()
->all();
}
/**
* @param list<int> $manageableGroupIds
*/
private function manageableCandidatesQuery(Artwork $sourceArtwork, User $actor, array $manageableGroupIds, string $term): Builder
{
$query = Artwork::query()
->with(['user.profile', 'group', 'categories.contentType'])
->whereKeyNot((int) $sourceArtwork->id)
->whereNull('deleted_at')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->where('published_at', '<=', now())
->where(function ($builder) use ($actor, $manageableGroupIds): void {
$builder->where('user_id', (int) $actor->id);
if ($manageableGroupIds !== []) {
$builder->orWhereIn('group_id', $manageableGroupIds);
}
});
$referenceTimestamp = $this->comparisonTimestamp($sourceArtwork);
if ($referenceTimestamp !== null) {
$query->where('published_at', '<=', $referenceTimestamp);
}
if ($term !== '') {
$like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $term) . '%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhereHas('group', fn ($groupQuery) => $groupQuery->where('name', 'like', $like))
->orWhereHas('user', fn ($userQuery) => $userQuery
->where('name', 'like', $like)
->orWhere('username', 'like', $like));
});
}
return $query;
}
/**
* @return array<string, mixed>|null
*/
private function mapPrimaryPanel(ArtworkRelation $relation, ?User $viewer): ?array
{
$beforeArtwork = $relation->targetArtwork;
$afterArtwork = $relation->sourceArtwork;
if (! $beforeArtwork || ! $afterArtwork) {
return null;
}
if (! $this->isPubliclyVisible($beforeArtwork) || ! $this->isPubliclyVisible($afterArtwork)) {
return null;
}
$before = $this->mapPublicCard($beforeArtwork, $viewer, 'Original');
$after = $this->mapPublicCard($afterArtwork, $viewer, $this->relationTypeShortLabel((string) $relation->relation_type));
if ($this->shouldOmitForViewer($before) || $this->shouldOmitForViewer($after)) {
return null;
}
$beforeYear = $before['year'] ?? null;
$afterYear = $after['year'] ?? null;
$yearsApart = $this->yearsApart($beforeYear, $afterYear);
return [
'id' => (int) $relation->id,
'relation_type' => (string) $relation->relation_type,
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
'heading' => 'Then & Now',
'summary' => $this->primarySummary($beforeYear, $yearsApart),
'years_apart' => $yearsApart,
'years_apart_label' => $yearsApart !== null && $yearsApart > 0 ? $yearsApart . ' years later' : null,
'note' => $relation->note,
'before' => $before,
'after' => $after,
'compare' => [
'available' => $this->compareAvailable($before, $after),
'title' => 'Then & Now comparison',
],
];
}
/**
* @return array<string, mixed>|null
*/
private function mapIncomingUpdate(ArtworkRelation $relation, ?User $viewer): ?array
{
$beforeArtwork = $relation->targetArtwork;
$afterArtwork = $relation->sourceArtwork;
if (! $beforeArtwork || ! $afterArtwork) {
return null;
}
if (! $this->isPubliclyVisible($beforeArtwork) || ! $this->isPubliclyVisible($afterArtwork)) {
return null;
}
$before = $this->mapPublicCard($beforeArtwork, $viewer, 'Original');
$after = $this->mapPublicCard($afterArtwork, $viewer, $this->relationTypeShortLabel((string) $relation->relation_type));
if ($this->shouldOmitForViewer($before) || $this->shouldOmitForViewer($after)) {
return null;
}
$yearsApart = $this->yearsApart($before['year'] ?? null, $after['year'] ?? null);
return [
'id' => (int) $relation->id,
'relation_type' => (string) $relation->relation_type,
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
'heading' => 'Updated Version',
'summary' => $this->incomingSummary($after['year'] ?? null, $yearsApart),
'years_apart' => $yearsApart,
'years_apart_label' => $yearsApart !== null && $yearsApart > 0 ? $yearsApart . ' years later' : null,
'note' => $relation->note,
'before' => $before,
'after' => $after,
'compare' => [
'available' => $this->compareAvailable($before, $after),
'title' => 'Compare versions',
],
];
}
/**
* @return array<string, mixed>
*/
private function mapPublicCard(Artwork $artwork, ?User $viewer, string $roleLabel): array
{
$category = $artwork->categories->sortBy('sort_order')->first();
$md = ThumbnailPresenter::present($artwork, 'md');
$lg = ThumbnailPresenter::present($artwork, 'lg');
$xl = ThumbnailPresenter::present($artwork, 'xl');
$publishedAt = $artwork->published_at;
return $this->maturity->decoratePayload([
'id' => (int) $artwork->id,
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('art.show', [
'id' => (int) $artwork->id,
'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id,
]),
'publisher' => $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
'published_at' => optional($publishedAt)->toIsoString(),
'year' => $publishedAt?->year ?: $artwork->created_at?->year,
'role_label' => $roleLabel,
'thumbnail' => $md['url'] ?? null,
'image_md' => $md['url'] ?? null,
'image_lg' => $lg['url'] ?? null,
'image_xl' => $xl['url'] ?? null,
'width' => (int) ($artwork->width ?? 0),
'height' => (int) ($artwork->height ?? 0),
'content_type' => $category?->contentType?->name,
'category' => $category?->name,
], $artwork, $viewer);
}
/**
* @param array<string, mixed> $card
*/
private function shouldOmitForViewer(array $card): bool
{
return (bool) data_get($card, 'maturity.should_hide', false);
}
/**
* @param array<string, mixed> $before
* @param array<string, mixed> $after
*/
private function compareAvailable(array $before, array $after): bool
{
return ! empty($before['image_lg']) && ! empty($after['image_lg']);
}
private function yearsApart(mixed $beforeYear, mixed $afterYear): ?int
{
if (! is_numeric($beforeYear) || ! is_numeric($afterYear)) {
return null;
}
return max(0, (int) $afterYear - (int) $beforeYear);
}
private function primarySummary(mixed $beforeYear, ?int $yearsApart): string
{
if (is_numeric($beforeYear) && $yearsApart !== null && $yearsApart > 0) {
return sprintf('This artwork revisits an earlier version from %d, %d years later.', (int) $beforeYear, $yearsApart);
}
if (is_numeric($beforeYear)) {
return sprintf('This artwork revisits an earlier version from %d.', (int) $beforeYear);
}
return 'This artwork revisits an earlier version from the creator archive.';
}
private function incomingSummary(mixed $afterYear, ?int $yearsApart): string
{
if (is_numeric($afterYear) && $yearsApart !== null && $yearsApart > 0) {
return sprintf('This artwork was later revisited in %d, %d years later.', (int) $afterYear, $yearsApart);
}
if (is_numeric($afterYear)) {
return sprintf('This artwork was later revisited in %d.', (int) $afterYear);
}
return 'This artwork later received an updated version from the same creator.';
}
private function relationTypeLabel(string $type): string
{
return match ($type) {
ArtworkRelation::TYPE_REMASTER_OF => 'Remaster of',
ArtworkRelation::TYPE_REVISION_OF => 'Revision of',
ArtworkRelation::TYPE_INSPIRED_BY => 'Inspired by',
ArtworkRelation::TYPE_VARIATION_OF => 'Variation of',
default => 'Remake of',
};
}
private function relationTypeShortLabel(string $type): string
{
return match ($type) {
ArtworkRelation::TYPE_REMASTER_OF => 'Remaster',
ArtworkRelation::TYPE_REVISION_OF => 'Update',
ArtworkRelation::TYPE_INSPIRED_BY => 'Inspired take',
ArtworkRelation::TYPE_VARIATION_OF => 'Variation',
default => 'Remake',
};
}
}