optimizations
This commit is contained in:
247
app/Services/Studio/StudioAiSuggestionBuilder.php
Normal file
247
app/Services/Studio/StudioAiSuggestionBuilder.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user