247 lines
9.2 KiB
PHP
247 lines
9.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Studio;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Services\TagNormalizer;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class StudioAiSuggestionBuilder
|
|
{
|
|
private const GENERIC_TAGS = [
|
|
'image', 'picture', 'artwork', 'art', 'design', 'visual', 'graphic', 'photo of', 'image of',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly TagNormalizer $normalizer,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $analysis
|
|
*/
|
|
public function detectMode(Artwork $artwork, array $analysis): string
|
|
{
|
|
$signals = Collection::make([
|
|
$artwork->title,
|
|
$artwork->description,
|
|
$artwork->file_name,
|
|
$analysis['blip_caption'] ?? null,
|
|
...Collection::make((array) ($analysis['clip_tags'] ?? []))->pluck('tag')->all(),
|
|
...Collection::make((array) ($analysis['yolo_objects'] ?? []))->pluck('tag')->all(),
|
|
])->filter()->implode(' ');
|
|
|
|
return preg_match('/\b(screenshot|screen|ui|interface|menu|hud|dashboard|settings|launcher|app|game)\b/i', $signals) === 1
|
|
? 'screenshot'
|
|
: 'artwork';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $analysis
|
|
* @return array<int, array{text: string, confidence: float}>
|
|
*/
|
|
public function buildTitleSuggestions(Artwork $artwork, array $analysis, string $mode): array
|
|
{
|
|
$caption = $this->cleanCaption((string) ($analysis['blip_caption'] ?? ''));
|
|
$topTerms = $this->topTerms($analysis, 4);
|
|
$titleSeeds = Collection::make([
|
|
$this->titleCase($caption),
|
|
$this->titleCase($this->limitWords($caption, 6)),
|
|
$mode === 'screenshot'
|
|
? $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 2)) . ' Screen'))
|
|
: $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 3)))),
|
|
$mode === 'screenshot'
|
|
? $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 2)) . ' Interface'))
|
|
: $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 2)) . ' Study')),
|
|
$mode === 'screenshot'
|
|
? $this->titleCase(trim(($topTerms[0] ?? 'Interface') . ' View'))
|
|
: $this->titleCase(trim(($topTerms[0] ?? 'Artwork') . ' Composition')),
|
|
])
|
|
->filter(fn (?string $value): bool => is_string($value) && trim($value) !== '')
|
|
->map(fn (string $value): string => Str::limit(trim($value), 80, ''))
|
|
->unique()
|
|
->take(5)
|
|
->values();
|
|
|
|
return $titleSeeds->map(fn (string $text, int $index): array => [
|
|
'text' => $text,
|
|
'confidence' => round(max(0.55, 0.92 - ($index * 0.07)), 2),
|
|
])->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $analysis
|
|
* @return array<int, array{variant: string, text: string, confidence: float}>
|
|
*/
|
|
public function buildDescriptionSuggestions(Artwork $artwork, array $analysis, string $mode): array
|
|
{
|
|
$caption = $this->cleanCaption((string) ($analysis['blip_caption'] ?? ''));
|
|
$terms = $this->topTerms($analysis, 5);
|
|
$termSentence = $terms !== [] ? implode(', ', array_slice($terms, 0, 3)) : null;
|
|
|
|
$short = $caption !== ''
|
|
? Str::ucfirst(Str::finish($caption, '.'))
|
|
: ($mode === 'screenshot'
|
|
? 'A clear screenshot with interface-focused visual details.'
|
|
: 'A visually focused artwork with clear subject and style cues.');
|
|
|
|
$normal = $short;
|
|
if ($termSentence) {
|
|
$normal .= ' It highlights ' . $termSentence . ' without overclaiming details.';
|
|
}
|
|
|
|
$seo = $artwork->title !== ''
|
|
? $artwork->title . ' is presented with ' . ($termSentence ?: ($mode === 'screenshot' ? 'useful interface context' : 'strong visual detail')) . ' for discovery on Skinbase.'
|
|
: $normal;
|
|
|
|
return [
|
|
['variant' => 'short', 'text' => Str::limit($short, 180, ''), 'confidence' => 0.89],
|
|
['variant' => 'normal', 'text' => Str::limit($normal, 280, ''), 'confidence' => 0.85],
|
|
['variant' => 'seo', 'text' => Str::limit($seo, 220, ''), 'confidence' => 0.8],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $analysis
|
|
* @return array<int, array{tag: string, confidence: float|null}>
|
|
*/
|
|
public function buildTagSuggestions(Artwork $artwork, array $analysis, string $mode): array
|
|
{
|
|
$rawTags = Collection::make()
|
|
->merge((array) ($analysis['clip_tags'] ?? []))
|
|
->merge((array) ($analysis['yolo_objects'] ?? []))
|
|
->map(function (mixed $item): array {
|
|
if (is_string($item)) {
|
|
return ['tag' => $item, 'confidence' => null];
|
|
}
|
|
|
|
return [
|
|
'tag' => (string) ($item['tag'] ?? ''),
|
|
'confidence' => isset($item['confidence']) && is_numeric($item['confidence']) ? (float) $item['confidence'] : null,
|
|
];
|
|
});
|
|
|
|
foreach ($this->extractCaptionTags((string) ($analysis['blip_caption'] ?? '')) as $captionTag) {
|
|
$rawTags->push(['tag' => $captionTag, 'confidence' => 0.62]);
|
|
}
|
|
|
|
if ($mode === 'screenshot') {
|
|
foreach (['screenshot', 'ui'] as $fallbackTag) {
|
|
$rawTags->push(['tag' => $fallbackTag, 'confidence' => 0.58]);
|
|
}
|
|
}
|
|
|
|
$suggestions = $rawTags
|
|
->map(function (array $row): ?array {
|
|
$tag = $this->normalizer->normalize((string) ($row['tag'] ?? ''));
|
|
if ($tag === '' || in_array($tag, self::GENERIC_TAGS, true)) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'tag' => $tag,
|
|
'confidence' => isset($row['confidence']) && is_numeric($row['confidence']) ? round((float) $row['confidence'], 2) : null,
|
|
];
|
|
})
|
|
->filter()
|
|
->unique('tag')
|
|
->sortByDesc(fn (array $row): float => (float) ($row['confidence'] ?? 0.0))
|
|
->take(15)
|
|
->values();
|
|
|
|
return $suggestions->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $analysis
|
|
* @return array<int, string>
|
|
*/
|
|
public function buildSignals(Artwork $artwork, array $analysis): array
|
|
{
|
|
return Collection::make([
|
|
$artwork->title,
|
|
$artwork->description,
|
|
$artwork->file_name,
|
|
$analysis['blip_caption'] ?? null,
|
|
...Collection::make((array) ($analysis['clip_tags'] ?? []))->pluck('tag')->all(),
|
|
...Collection::make((array) ($analysis['yolo_objects'] ?? []))->pluck('tag')->all(),
|
|
...$artwork->tags->pluck('slug')->all(),
|
|
])
|
|
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $analysis
|
|
* @return array<int, string>
|
|
*/
|
|
private function topTerms(array $analysis, int $limit): array
|
|
{
|
|
return Collection::make()
|
|
->merge((array) ($analysis['clip_tags'] ?? []))
|
|
->merge((array) ($analysis['yolo_objects'] ?? []))
|
|
->map(fn (mixed $item): string => trim((string) (is_array($item) ? ($item['tag'] ?? '') : $item)))
|
|
->filter()
|
|
->flatMap(fn (string $term): array => preg_split('/\s+/', Str::of($term)->replace('-', ' ')->value()) ?: [])
|
|
->filter(fn (string $term): bool => strlen($term) >= 3)
|
|
->map(fn (string $term): string => Str::title($term))
|
|
->unique()
|
|
->take($limit)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function extractCaptionTags(string $caption): array
|
|
{
|
|
$clean = Str::of($caption)
|
|
->lower()
|
|
->replaceMatches('/[^a-z0-9\s\-]+/', ' ')
|
|
->replace('-', ' ')
|
|
->squish()
|
|
->value();
|
|
|
|
if ($clean === '') {
|
|
return [];
|
|
}
|
|
|
|
$tokens = Collection::make(explode(' ', $clean))
|
|
->filter(fn (string $value): bool => strlen($value) >= 3)
|
|
->reject(fn (string $value): bool => in_array($value, ['with', 'from', 'into', 'over', 'under', 'image', 'picture', 'artwork'], true))
|
|
->values();
|
|
|
|
$bigrams = [];
|
|
for ($index = 0; $index < $tokens->count() - 1; $index++) {
|
|
$bigrams[] = $tokens[$index] . ' ' . $tokens[$index + 1];
|
|
}
|
|
|
|
return $tokens->merge($bigrams)->unique()->take(10)->all();
|
|
}
|
|
|
|
private function cleanCaption(string $caption): string
|
|
{
|
|
return Str::of($caption)
|
|
->replaceMatches('/^(a|an|the)\s+/i', '')
|
|
->replaceMatches('/^(image|photo|screenshot) of\s+/i', '')
|
|
->squish()
|
|
->value();
|
|
}
|
|
|
|
private function titleCase(string $value): string
|
|
{
|
|
return Str::title(trim($value));
|
|
}
|
|
|
|
private function limitWords(string $value, int $maxWords): string
|
|
{
|
|
$words = preg_split('/\s+/', trim($value)) ?: [];
|
|
|
|
return implode(' ', array_slice($words, 0, $maxWords));
|
|
}
|
|
} |