Files
SkinbaseNova/app/Http/Controllers/Web/SimilarArtworksPageController.php

318 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\Recommendations\HybridSimilarArtworksService;
use App\Services\ThumbnailPresenter;
use App\Services\Vision\VectorService;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use RuntimeException;
/**
* GET /art/{id}/similar
*
* Renders a full gallery page of artworks similar to the given source artwork.
*
* Priority:
* 1. Qdrant visual similarity (VectorService / vision gateway)
* 2. HybridSimilarArtworksService (precomputed tag, behavior, hybrid)
* 3. Meilisearch tag-overlap + category fallback
*/
final class SimilarArtworksPageController extends Controller
{
private const PER_PAGE = 24;
/** How many candidates to request from Qdrant (> PER_PAGE to allow pagination) */
private const QDRANT_LIMIT = 120;
public function __construct(
private readonly VectorService $vectors,
private readonly HybridSimilarArtworksService $hybridService,
) {}
public function __invoke(Request $request, int $id)
{
// ── Load source artwork ────────────────────────────────────────────────
$source = Artwork::public()
->published()
->with([
'tags:id,slug',
'categories:id,slug,name',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
])
->findOrFail($id);
$baseUrl = url("/art/{$id}/similar");
// ── Normalise source artwork for the view ──────────────────────────────
$primaryCat = $source->categories->sortBy('sort_order')->first();
$sourceMd = ThumbnailPresenter::present($source, 'md');
$sourceLg = ThumbnailPresenter::present($source, 'lg');
$sourceTitle = html_entity_decode((string) ($source->title ?? 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$sourceUrl = route('art.show', ['id' => $source->id, 'slug' => $source->slug]);
$sourceCard = (object) [
'id' => $source->id,
'title' => $sourceTitle,
'url' => $sourceUrl,
'thumb_md' => $sourceMd['url'] ?? null,
'thumb_lg' => $sourceLg['url'] ?? null,
'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null,
'author_name' => $source->user?->name ?? 'Artist',
'author_username' => $source->user?->username ?? '',
'author_avatar' => AvatarUrl::forUser(
(int) ($source->user_id ?? 0),
$source->user?->profile?->avatar_hash ?? null,
80
),
'category_name' => $primaryCat?->name ?? '',
'category_slug' => $primaryCat?->slug ?? '',
'content_type_name' => $primaryCat?->contentType?->name ?? '',
'content_type_slug' => $primaryCat?->contentType?->slug ?? '',
'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(),
'width' => $source->width ?? null,
'height' => $source->height ?? null,
];
return view('gallery.similar', [
'sourceArtwork' => $sourceCard,
'gallery_type' => 'similar',
'gallery_nav_section' => 'artworks',
'mainCategories' => collect(),
'subcategories' => collect(),
'contentType' => null,
'category' => null,
'spotlight' => collect(),
'current_sort' => 'trending',
'sort_options' => [],
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
'page_canonical' => $baseUrl,
'page_robots' => 'noindex,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'],
(object) ['name' => $sourceTitle, 'url' => $sourceUrl],
(object) ['name' => 'Similar Artworks', 'url' => $baseUrl],
]),
]);
}
/**
* GET /art/{id}/similar-results (JSON)
*
* Returns paginated similar artworks asynchronously so the page shell
* can render instantly while this slower query runs in the background.
*/
public function results(Request $request, int $id): JsonResponse
{
$source = Artwork::public()
->published()
->with([
'tags:id,slug',
'categories:id,slug,name',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
])
->findOrFail($id);
$page = max(1, (int) $request->query('page', 1));
$baseUrl = url("/art/{$id}/similar");
[$artworks, $similaritySource] = $this->resolveSimilarArtworks($source, $page, $baseUrl);
$galleryItems = $artworks->getCollection()->map(fn ($art) => [
'id' => $art->id ?? null,
'name' => $art->name ?? null,
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',
'width' => $art->width ?? null,
'height' => $art->height ?? null,
])->values();
return response()->json([
'data' => $galleryItems,
'similarity_source' => $similaritySource,
'total' => $artworks->total(),
'current_page' => $artworks->currentPage(),
'last_page' => $artworks->lastPage(),
'next_page_url' => $artworks->nextPageUrl(),
'prev_page_url' => $artworks->previousPageUrl(),
]);
}
// ── Similarity resolution ──────────────────────────────────────────────────
/**
* @return array{0: LengthAwarePaginator, 1: string}
*/
private function resolveSimilarArtworks(Artwork $source, int $page, string $baseUrl): array
{
// Priority 1 — Qdrant visual (vision) similarity
if ($this->vectors->isConfigured()) {
$qdrantItems = $this->resolveViaQdrant($source);
if ($qdrantItems !== null && $qdrantItems->isNotEmpty()) {
$paginator = $this->paginateCollection(
$qdrantItems->map(fn ($a) => $this->presentArtwork($a)),
$page,
$baseUrl,
);
return [$paginator, 'visual'];
}
}
// Priority 2 — precomputed hybrid list (tag / behavior / AI)
$hybridItems = $this->hybridService->forArtwork($source->id, self::QDRANT_LIMIT);
if ($hybridItems->isNotEmpty()) {
$paginator = $this->paginateCollection(
$hybridItems->map(fn ($a) => $this->presentArtwork($a)),
$page,
$baseUrl,
);
return [$paginator, 'hybrid'];
}
// Priority 3 — Meilisearch tag/category overlap
$paginator = $this->meilisearchFallback($source, $page);
return [$paginator, 'tags'];
}
/**
* Query Qdrant via VectorGateway, then re-hydrate full Artwork models
* (so we have category/dimension data for the masonry grid).
*
* Returns null when the gateway call fails, so the caller can fall through.
*/
private function resolveViaQdrant(Artwork $source): ?Collection
{
try {
$raw = $this->vectors->similarToArtwork($source, self::QDRANT_LIMIT);
} catch (RuntimeException) {
return null;
}
if (empty($raw)) {
return null;
}
// Preserve Qdrant relevance order; IDs are already filtered to public+published
$orderedIds = array_column($raw, 'id');
$artworks = Artwork::query()
->whereIn('id', $orderedIds)
->where('id', '!=', $source->id) // belt-and-braces exclusion
->public()
->published()
->with([
'categories:id,slug,name',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
])
->get()
->keyBy('id');
return collect($orderedIds)
->map(fn (int $id) => $artworks->get($id))
->filter()
->values();
}
/**
* Meilisearch tag-overlap query with category fallback.
*/
private function meilisearchFallback(Artwork $source, int $page): LengthAwarePaginator
{
$tagSlugs = $source->tags->pluck('slug')->values()->all();
$categorySlugs = $source->categories->pluck('slug')->values()->all();
$filterParts = [
'is_public = true',
'is_approved = true',
'id != ' . $source->id,
];
if ($tagSlugs !== []) {
$quoted = array_map(fn (string $t): string => 'tags = "' . addslashes($t) . '"', $tagSlugs);
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
} elseif ($categorySlugs !== []) {
$quoted = array_map(fn (string $c): string => 'category = "' . addslashes($c) . '"', $categorySlugs);
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
}
$results = Artwork::search('')->options([
'filter' => implode(' AND ', $filterParts),
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
])->paginate(self::PER_PAGE, 'page', $page);
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
return $results;
}
/**
* Wrap a Collection into a LengthAwarePaginator for the view.
*/
private function paginateCollection(
Collection $items,
int $page,
string $path,
): LengthAwarePaginator {
$perPage = self::PER_PAGE;
$total = $items->count();
$slice = $items->forPage($page, $perPage)->values();
return new LengthAwarePaginator($slice, $total, $perPage, $page, [
'path' => $path,
'query' => [],
]);
}
// ── Presenter ─────────────────────────────────────────────────────────────
private function presentArtwork(Artwork $artwork): object
{
$primary = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$avatarUrl = AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
return (object) [
'id' => $artwork->id,
'name' => $artwork->title,
'content_type_name' => $primary?->contentType?->name ?? '',
'content_type_slug' => $primary?->contentType?->slug ?? '',
'category_name' => $primary?->name ?? '',
'category_slug' => $primary?->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'avatar_url' => $avatarUrl,
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
];
}
}