Files
fotospiel-app/app/Services/AiEditing/RunwareModelSearchService.php
Codex Agent 6cc463fc70
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
tests / ui (push) Waiting to run
feat(ai): add runware model search and model-constrained img2img
2026-02-07 21:16:28 +01:00

262 lines
8.0 KiB
PHP

<?php
namespace App\Services\AiEditing;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class RunwareModelSearchService
{
/**
* @return array<int, array{
* air: string,
* name: string,
* architecture: ?string,
* category: ?string,
* defaults: array<string, int|float|null>,
* constraints: array<string, int|float|null>
* }>
*/
public function search(string $search, ?int $limit = null): array
{
$normalizedSearch = trim($search);
$minimumSearchLength = max(1, (int) config('ai-editing.providers.runware.model_search_min_chars', 2));
if (Str::length($normalizedSearch) < $minimumSearchLength) {
return [];
}
$apiKey = $this->apiKey();
if (! $apiKey) {
return [];
}
$resolvedLimit = $this->resolveLimit($limit);
$cacheTtl = max(30, (int) config('ai-editing.providers.runware.model_search_cache_seconds', 300));
$cacheKey = sprintf(
'ai_editing.runware.model_search.%s',
sha1(Str::lower($normalizedSearch).'|'.$resolvedLimit)
);
return Cache::remember(
$cacheKey,
now()->addSeconds($cacheTtl),
fn (): array => $this->performSearch($normalizedSearch, $resolvedLimit, $apiKey),
);
}
/**
* @return array<string, string>
*/
public function searchOptions(string $search, ?int $limit = null): array
{
return collect($this->search($search, $limit))
->mapWithKeys(fn (array $model): array => [$model['air'] => $this->formatLabel($model)])
->all();
}
public function labelForModel(mixed $air): ?string
{
if (! is_string($air) || trim($air) === '') {
return null;
}
$model = $this->findByAir($air);
return $model ? $this->formatLabel($model) : trim($air);
}
/**
* @return array{
* air: string,
* name: string,
* architecture: ?string,
* category: ?string,
* defaults: array<string, int|float|null>,
* constraints: array<string, int|float|null>
* }|null
*/
public function findByAir(string $air): ?array
{
$normalizedAir = trim($air);
if ($normalizedAir === '') {
return null;
}
$results = $this->search($normalizedAir, 50);
foreach ($results as $model) {
if (Str::lower($model['air']) === Str::lower($normalizedAir)) {
return $model;
}
}
return null;
}
/**
* @return array<int, array{
* air: string,
* name: string,
* architecture: ?string,
* category: ?string,
* defaults: array<string, int|float|null>,
* constraints: array<string, int|float|null>
* }>
*/
private function performSearch(string $search, int $limit, string $apiKey): array
{
$payload = [[
'taskType' => 'modelSearch',
'taskUUID' => (string) Str::uuid(),
'search' => $search,
'limit' => $limit,
'offset' => 0,
]];
$response = Http::withToken($apiKey)
->acceptJson()
->timeout((int) config('services.runware.timeout', 90))
->post($this->baseUrl(), $payload);
if (! $response->successful()) {
return [];
}
$body = (array) $response->json();
$items = (array) ($body['data'] ?? []);
$models = [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$normalized = $this->normalizeModel($item);
if (! is_array($normalized)) {
continue;
}
$models[$normalized['air']] = $normalized;
}
return array_values($models);
}
/**
* @param array<string, mixed> $item
* @return array{
* air: string,
* name: string,
* architecture: ?string,
* category: ?string,
* defaults: array<string, int|float|null>,
* constraints: array<string, int|float|null>
* }|null
*/
private function normalizeModel(array $item): ?array
{
$air = trim((string) (Arr::get($item, 'air') ?? Arr::get($item, 'model') ?? ''));
if ($air === '') {
return null;
}
$name = trim((string) (Arr::get($item, 'name') ?? Arr::get($item, 'title') ?? $air));
$architecture = $this->normalizeOptionalString((string) Arr::get($item, 'architecture', ''));
$category = $this->normalizeOptionalString((string) Arr::get($item, 'category', ''));
$defaults = [
'width' => $this->normalizeInteger(Arr::get($item, 'defaultWidth')),
'height' => $this->normalizeInteger(Arr::get($item, 'defaultHeight')),
'steps' => $this->normalizeInteger(Arr::get($item, 'defaultSteps')),
'cfg_scale' => $this->normalizeFloat(Arr::get($item, 'defaultCFG')),
];
$constraints = [
'min_width' => $this->normalizeInteger(Arr::get($item, 'minWidth')),
'max_width' => $this->normalizeInteger(Arr::get($item, 'maxWidth')),
'width_step' => $this->normalizeInteger(Arr::get($item, 'widthStep')),
'min_height' => $this->normalizeInteger(Arr::get($item, 'minHeight')),
'max_height' => $this->normalizeInteger(Arr::get($item, 'maxHeight')),
'height_step' => $this->normalizeInteger(Arr::get($item, 'heightStep')),
'min_steps' => $this->normalizeInteger(Arr::get($item, 'minSteps')),
'max_steps' => $this->normalizeInteger(Arr::get($item, 'maxSteps')),
'min_cfg_scale' => $this->normalizeFloat(Arr::get($item, 'minCFG')),
'max_cfg_scale' => $this->normalizeFloat(Arr::get($item, 'maxCFG')),
'min_strength' => $this->normalizeFloat(Arr::get($item, 'minStrength')),
'max_strength' => $this->normalizeFloat(Arr::get($item, 'maxStrength')),
];
return [
'air' => $air,
'name' => $name !== '' ? $name : $air,
'architecture' => $architecture,
'category' => $category,
'defaults' => $defaults,
'constraints' => $constraints,
];
}
/**
* @param array{
* air: string,
* name: string,
* architecture: ?string,
* category: ?string,
* defaults: array<string, int|float|null>,
* constraints: array<string, int|float|null>
* } $model
*/
private function formatLabel(array $model): string
{
$parts = [$model['name']];
if ($model['architecture']) {
$parts[] = $model['architecture'];
}
$parts[] = $model['air'];
return implode(' | ', array_filter($parts, static fn (mixed $value): bool => is_string($value) && trim($value) !== ''));
}
private function resolveLimit(?int $limit): int
{
$defaultLimit = max(1, (int) config('ai-editing.providers.runware.model_search_limit', 25));
$resolved = $limit ?? $defaultLimit;
return max(1, min(100, $resolved));
}
private function apiKey(): ?string
{
$apiKey = config('services.runware.api_key');
return is_string($apiKey) && trim($apiKey) !== '' ? trim($apiKey) : null;
}
private function baseUrl(): string
{
return rtrim((string) config('services.runware.base_url', 'https://api.runware.ai/v1'), '/');
}
private function normalizeOptionalString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
private function normalizeInteger(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private function normalizeFloat(mixed $value): ?float
{
return is_numeric($value) ? (float) $value : null;
}
}