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, ]; } }