diff --git a/app/Filament/Resources/AiStyles/AiStyleResource.php b/app/Filament/Resources/AiStyles/AiStyleResource.php index 9d49f048..13c9bd17 100644 --- a/app/Filament/Resources/AiStyles/AiStyleResource.php +++ b/app/Filament/Resources/AiStyles/AiStyleResource.php @@ -5,6 +5,7 @@ namespace App\Filament\Resources\AiStyles; use App\Filament\Clusters\RareAdmin\RareAdminCluster; use App\Filament\Resources\AiStyles\Pages\ManageAiStyles; use App\Models\AiStyle; +use App\Services\AiEditing\RunwareModelSearchService; use App\Services\Audit\SuperAdminAuditLogger; use BackedEnum; use Filament\Actions; @@ -15,6 +16,8 @@ use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Resources\Resource; use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Table; @@ -82,10 +85,74 @@ class AiStyleResource extends Resource ]) ->required() ->default('runware'), - TextInput::make('provider_model') - ->maxLength(120), + Select::make('provider_model') + ->label('Runware model (AIR)') + ->searchable() + ->getSearchResultsUsing(static fn (string $search): array => app(RunwareModelSearchService::class)->searchOptions($search)) + ->getOptionLabelUsing(static fn (mixed $value): ?string => app(RunwareModelSearchService::class)->labelForModel($value)) + ->helperText('Start typing to search models from runware.ai.') + ->native(false) + ->live() + ->afterStateUpdated(static function (Set $set, ?string $state): void { + self::applySelectedRunwareModel($set, $state); + }), ]) ->columns(2), + Section::make('Runware Generation') + ->schema([ + TextInput::make('metadata.runware.generation.width') + ->label('Width') + ->numeric() + ->minValue(64) + ->maxValue(4096) + ->step(64) + ->helperText(static fn (Get $get): ?string => self::dimensionConstraintHint($get, 'width')), + TextInput::make('metadata.runware.generation.height') + ->label('Height') + ->numeric() + ->minValue(64) + ->maxValue(4096) + ->step(64) + ->helperText(static fn (Get $get): ?string => self::dimensionConstraintHint($get, 'height')), + TextInput::make('metadata.runware.generation.steps') + ->label('Steps') + ->numeric() + ->minValue(1) + ->maxValue(150) + ->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'steps')), + TextInput::make('metadata.runware.generation.cfg_scale') + ->label('CFG Scale') + ->numeric() + ->minValue(0) + ->maxValue(30) + ->step(0.1) + ->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'cfg_scale')), + TextInput::make('metadata.runware.generation.strength') + ->label('Strength') + ->numeric() + ->minValue(0) + ->maxValue(1) + ->step(0.01) + ->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'strength')), + Select::make('metadata.runware.generation.output_format') + ->label('Output format') + ->options([ + 'JPG' => 'JPG', + 'PNG' => 'PNG', + 'WEBP' => 'WEBP', + ]) + ->default('JPG') + ->native(false), + Select::make('metadata.runware.generation.delivery_method') + ->label('Delivery method') + ->options([ + 'async' => 'async (queue + poll)', + 'sync' => 'sync', + ]) + ->default('async') + ->native(false), + ]) + ->columns(3), Section::make('Prompts') ->schema([ Textarea::make('description') @@ -160,4 +227,79 @@ class AiStyleResource extends Resource 'index' => ManageAiStyles::route('/'), ]; } + + private static function applySelectedRunwareModel(Set $set, ?string $air): void + { + if (! is_string($air) || trim($air) === '') { + return; + } + + $model = app(RunwareModelSearchService::class)->findByAir($air); + if (! is_array($model)) { + return; + } + + $set('metadata.runware.model.air', $model['air']); + $set('metadata.runware.model.name', $model['name']); + $set('metadata.runware.model.architecture', $model['architecture']); + $set('metadata.runware.model.category', $model['category']); + + foreach ((array) ($model['constraints'] ?? []) as $key => $value) { + $set("metadata.runware.constraints.{$key}", $value); + } + + self::setIfNumeric($set, 'metadata.runware.generation.width', $model['defaults']['width'] ?? null); + self::setIfNumeric($set, 'metadata.runware.generation.height', $model['defaults']['height'] ?? null); + self::setIfNumeric($set, 'metadata.runware.generation.steps', $model['defaults']['steps'] ?? null); + self::setIfNumeric($set, 'metadata.runware.generation.cfg_scale', $model['defaults']['cfg_scale'] ?? null); + } + + private static function setIfNumeric(Set $set, string $path, mixed $value): void + { + if (is_numeric($value)) { + $set($path, $value); + } + } + + private static function dimensionConstraintHint(Get $get, string $dimension): ?string + { + $min = $get("metadata.runware.constraints.min_{$dimension}"); + $max = $get("metadata.runware.constraints.max_{$dimension}"); + $step = $get("metadata.runware.constraints.{$dimension}_step"); + + if (! is_numeric($min) && ! is_numeric($max) && ! is_numeric($step)) { + return null; + } + + $parts = []; + if (is_numeric($min) || is_numeric($max)) { + $parts[] = sprintf( + 'Model range: %s - %s', + is_numeric($min) ? (string) (int) $min : '?', + is_numeric($max) ? (string) (int) $max : '?' + ); + } + + if (is_numeric($step) && (int) $step > 0) { + $parts[] = sprintf('Step: %d', (int) $step); + } + + return $parts !== [] ? implode(' | ', $parts) : null; + } + + private static function rangeConstraintHint(Get $get, string $field): ?string + { + $min = $get("metadata.runware.constraints.min_{$field}"); + $max = $get("metadata.runware.constraints.max_{$field}"); + + if (! is_numeric($min) && ! is_numeric($max)) { + return null; + } + + return sprintf( + 'Model range: %s - %s', + is_numeric($min) ? trim((string) $min) : '?', + is_numeric($max) ? trim((string) $max) : '?' + ); + } } diff --git a/app/Http/Controllers/Api/EventPublicAiEditController.php b/app/Http/Controllers/Api/EventPublicAiEditController.php index 0421307a..592ad02c 100644 --- a/app/Http/Controllers/Api/EventPublicAiEditController.php +++ b/app/Http/Controllers/Api/EventPublicAiEditController.php @@ -20,6 +20,7 @@ use App\Support\ApiError; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -160,6 +161,12 @@ class EventPublicAiEditController extends BaseController } $metadata = (array) $request->input('metadata', []); + $styleMetadata = is_array($style?->metadata) ? $style->metadata : []; + $styleRunwareMetadata = Arr::get($styleMetadata, 'runware'); + if (is_array($styleRunwareMetadata)) { + $metadata['runware'] = $styleRunwareMetadata; + } + if (is_array($abuseSignal)) { $metadata['abuse'] = $abuseSignal; } diff --git a/app/Http/Controllers/Api/Tenant/AiEditController.php b/app/Http/Controllers/Api/Tenant/AiEditController.php index aa8ac671..1997d084 100644 --- a/app/Http/Controllers/Api/Tenant/AiEditController.php +++ b/app/Http/Controllers/Api/Tenant/AiEditController.php @@ -23,6 +23,7 @@ use App\Support\ApiError; use App\Support\TenantMemberPermissions; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; @@ -314,6 +315,12 @@ class AiEditController extends Controller } $metadata = (array) $request->input('metadata', []); + $styleMetadata = is_array($style->metadata) ? $style->metadata : []; + $styleRunwareMetadata = Arr::get($styleMetadata, 'runware'); + if (is_array($styleRunwareMetadata)) { + $metadata['runware'] = $styleRunwareMetadata; + } + if (is_array($abuseSignal)) { $metadata['abuse'] = $abuseSignal; } diff --git a/app/Services/AiEditing/Providers/RunwareAiImageProvider.php b/app/Services/AiEditing/Providers/RunwareAiImageProvider.php index 7cb98430..d216e22a 100644 --- a/app/Services/AiEditing/Providers/RunwareAiImageProvider.php +++ b/app/Services/AiEditing/Providers/RunwareAiImageProvider.php @@ -3,17 +3,23 @@ namespace App\Services\AiEditing\Providers; use App\Models\AiEditRequest; +use App\Models\Event; use App\Services\AiEditing\AiEditingRuntimeConfig; use App\Services\AiEditing\AiProviderResult; use App\Services\AiEditing\Contracts\AiImageProvider; +use App\Services\Storage\EventStorageManager; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Throwable; class RunwareAiImageProvider implements AiImageProvider { - public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig) {} + public function __construct( + private readonly AiEditingRuntimeConfig $runtimeConfig, + private readonly EventStorageManager $eventStorageManager, + ) {} public function submit(AiEditRequest $request): AiProviderResult { @@ -29,28 +35,13 @@ class RunwareAiImageProvider implements AiImageProvider ); } - $payload = [ - [ - 'taskType' => 'imageInference', - 'taskUUID' => (string) Str::uuid(), - 'positivePrompt' => (string) ($request->prompt ?? ''), - 'negativePrompt' => (string) ($request->negative_prompt ?? ''), - 'outputType' => 'URL', - 'outputFormat' => 'JPG', - 'includeCost' => true, - 'safety' => [ - 'checkContent' => true, - ], - ], - ]; - - if (is_string($request->provider_model) && $request->provider_model !== '') { - $payload[0]['model'] = $request->provider_model; + $task = $this->buildInferenceTask($request); + if ($task instanceof AiProviderResult) { + return $task; } - if (is_string($request->input_image_path) && $request->input_image_path !== '') { - $payload[0]['seedImage'] = $request->input_image_path; - } + $payload = [$task]; + $taskUuid = (string) Arr::get($task, 'taskUUID', ''); try { $response = Http::withToken($apiKey) @@ -59,14 +50,24 @@ class RunwareAiImageProvider implements AiImageProvider ->post($this->baseUrl(), $payload); $body = (array) $response->json(); - $data = Arr::first((array) ($body['data'] ?? []), []); - $providerTaskId = (string) ($data['taskUUID'] ?? ''); - $status = strtolower((string) ($data['status'] ?? '')); - $cost = is_numeric($data['cost'] ?? null) ? (float) $data['cost'] : null; - $imageUrl = $data['imageURL'] ?? $data['outputUrl'] ?? $data['url'] ?? null; - $providerNsfw = $this->toBool($data['NSFWContent'] ?? null) || $this->toBool($data['nsfwContent'] ?? null); + if ($error = $this->extractProviderError($body)) { + return AiProviderResult::failed( + $error['code'], + $error['message'], + requestPayload: ['tasks' => $payload], + responsePayload: $body, + httpStatus: $response->status(), + ); + } - if (is_string($imageUrl) && $imageUrl !== '') { + $items = $this->filterItemsForTask($body, $taskUuid); + $status = $this->resolveStatus($items); + $cost = $this->resolveCost($items); + $providerTaskId = $this->resolveTaskUuid($items, $taskUuid); + $outputs = $this->mapOutputs($items, $providerTaskId); + $providerNsfw = $this->containsNsfwContent($items); + + if ($outputs !== []) { if ($providerNsfw) { return AiProviderResult::blocked( failureCode: 'provider_nsfw_content', @@ -80,11 +81,7 @@ class RunwareAiImageProvider implements AiImageProvider } return AiProviderResult::succeeded( - outputs: [[ - 'provider_url' => $imageUrl, - 'provider_asset_id' => $providerTaskId !== '' ? $providerTaskId : null, - 'mime_type' => 'image/jpeg', - ]], + outputs: $outputs, costUsd: $cost, safetyState: 'passed', requestPayload: ['tasks' => $payload], @@ -93,7 +90,17 @@ class RunwareAiImageProvider implements AiImageProvider ); } - if ($providerTaskId !== '' || $status === 'processing') { + if (in_array($status, ['failed', 'error'], true)) { + return AiProviderResult::failed( + 'provider_failed', + $this->resolveFailureMessage($items, 'Runware reported a failed job.'), + requestPayload: ['tasks' => $payload], + responsePayload: $body, + httpStatus: $response->status(), + ); + } + + if ($providerTaskId !== '' || in_array($status, ['queued', 'pending', 'processing', 'running'], true)) { return AiProviderResult::processing( providerTaskId: $providerTaskId !== '' ? $providerTaskId : (string) Str::uuid(), costUsd: $cost, @@ -103,6 +110,16 @@ class RunwareAiImageProvider implements AiImageProvider ); } + if (! $response->successful()) { + return AiProviderResult::failed( + 'provider_http_error', + sprintf('Runware responded with HTTP %d.', $response->status()), + requestPayload: ['tasks' => $payload], + responsePayload: $body, + httpStatus: $response->status(), + ); + } + return AiProviderResult::failed( 'provider_unexpected_response', 'Runware returned an unexpected response format.', @@ -146,13 +163,23 @@ class RunwareAiImageProvider implements AiImageProvider ->post($this->baseUrl(), $payload); $body = (array) $response->json(); - $data = Arr::first((array) ($body['data'] ?? []), []); - $status = strtolower((string) ($data['status'] ?? '')); - $cost = is_numeric($data['cost'] ?? null) ? (float) $data['cost'] : null; - $imageUrl = $data['imageURL'] ?? $data['outputUrl'] ?? $data['url'] ?? null; - $providerNsfw = $this->toBool($data['NSFWContent'] ?? null) || $this->toBool($data['nsfwContent'] ?? null); + if ($error = $this->extractProviderError($body)) { + return AiProviderResult::failed( + $error['code'], + $error['message'], + requestPayload: ['tasks' => $payload], + responsePayload: $body, + httpStatus: $response->status(), + ); + } - if (is_string($imageUrl) && $imageUrl !== '') { + $items = $this->filterItemsForTask($body, $providerTaskId); + $status = $this->resolveStatus($items); + $cost = $this->resolveCost($items); + $outputs = $this->mapOutputs($items, $providerTaskId); + $providerNsfw = $this->containsNsfwContent($items); + + if ($outputs !== []) { if ($providerNsfw) { return AiProviderResult::blocked( failureCode: 'provider_nsfw_content', @@ -166,11 +193,7 @@ class RunwareAiImageProvider implements AiImageProvider } return AiProviderResult::succeeded( - outputs: [[ - 'provider_url' => $imageUrl, - 'provider_asset_id' => $providerTaskId, - 'mime_type' => 'image/jpeg', - ]], + outputs: $outputs, costUsd: $cost, safetyState: 'passed', requestPayload: ['tasks' => $payload], @@ -179,7 +202,7 @@ class RunwareAiImageProvider implements AiImageProvider ); } - if ($status === 'processing' || $status === '') { + if (in_array($status, ['queued', 'pending', 'processing', 'running'], true)) { return AiProviderResult::processing( providerTaskId: $providerTaskId, costUsd: $cost, @@ -192,7 +215,27 @@ class RunwareAiImageProvider implements AiImageProvider if (in_array($status, ['failed', 'error'], true)) { return AiProviderResult::failed( 'provider_failed', - (string) ($data['errorMessage'] ?? 'Runware reported a failed job.'), + $this->resolveFailureMessage($items, 'Runware reported a failed job.'), + requestPayload: ['tasks' => $payload], + responsePayload: $body, + httpStatus: $response->status(), + ); + } + + if ($status === '' && $response->successful()) { + return AiProviderResult::processing( + providerTaskId: $providerTaskId, + costUsd: $cost, + requestPayload: ['tasks' => $payload], + responsePayload: $body, + httpStatus: $response->status(), + ); + } + + if (! $response->successful()) { + return AiProviderResult::failed( + 'provider_http_error', + sprintf('Runware responded with HTTP %d.', $response->status()), requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), @@ -268,6 +311,462 @@ class RunwareAiImageProvider implements AiImageProvider return rtrim($base, '/'); } + /** + * @return array|AiProviderResult + */ + private function buildInferenceTask(AiEditRequest $request): array|AiProviderResult + { + $model = trim((string) ($request->provider_model ?? '')); + if ($model === '') { + return AiProviderResult::failed( + 'provider_invalid_request', + 'Runware model is required for image inference.' + ); + } + + $seedImage = $this->resolveSeedImage($request); + if (! is_string($seedImage) || trim($seedImage) === '') { + return AiProviderResult::failed( + 'provider_invalid_input_image', + 'Source image for Runware image-to-image is missing or not publicly accessible.' + ); + } + + $options = $this->resolveGenerationOptions($request); + $taskUuid = (string) Str::uuid(); + + return [ + 'taskType' => 'imageInference', + 'taskUUID' => $taskUuid, + 'model' => $model, + 'positivePrompt' => (string) ($request->prompt ?? ''), + 'negativePrompt' => (string) ($request->negative_prompt ?? ''), + 'seedImage' => $seedImage, + 'strength' => $options['strength'], + 'width' => $options['width'], + 'height' => $options['height'], + 'steps' => $options['steps'], + 'CFGScale' => $options['cfg_scale'], + 'deliveryMethod' => $options['delivery_method'], + 'numberResults' => 1, + 'outputType' => 'URL', + 'outputFormat' => $options['output_format'], + 'includeCost' => true, + 'safety' => [ + 'checkContent' => true, + ], + ]; + } + + /** + * @return array{width:int, height:int, steps:int, cfg_scale:float, strength:float, output_format:string, delivery_method:string} + */ + private function resolveGenerationOptions(AiEditRequest $request): array + { + $runwareMetadata = Arr::get($request->metadata ?? [], 'runware', []); + $generation = is_array(Arr::get($runwareMetadata, 'generation')) + ? (array) Arr::get($runwareMetadata, 'generation') + : []; + $constraints = is_array(Arr::get($runwareMetadata, 'constraints')) + ? (array) Arr::get($runwareMetadata, 'constraints') + : []; + $defaults = (array) config('ai-editing.providers.runware.defaults', []); + + $width = $this->coerceDimension( + Arr::get($generation, 'width'), + Arr::get($constraints, 'min_width'), + Arr::get($constraints, 'max_width'), + Arr::get($constraints, 'width_step'), + max(64, (int) Arr::get($defaults, 'width', 1024)) + ); + $height = $this->coerceDimension( + Arr::get($generation, 'height'), + Arr::get($constraints, 'min_height'), + Arr::get($constraints, 'max_height'), + Arr::get($constraints, 'height_step'), + max(64, (int) Arr::get($defaults, 'height', 1024)) + ); + + $steps = $this->coerceIntRange( + Arr::get($generation, 'steps'), + Arr::get($constraints, 'min_steps'), + Arr::get($constraints, 'max_steps'), + max(1, (int) Arr::get($defaults, 'steps', 28)) + ); + $cfgScale = $this->coerceFloatRange( + Arr::get($generation, 'cfg_scale'), + Arr::get($constraints, 'min_cfg_scale'), + Arr::get($constraints, 'max_cfg_scale'), + max(0.1, (float) Arr::get($defaults, 'cfg_scale', 7.0)) + ); + $strength = $this->coerceFloatRange( + Arr::get($generation, 'strength'), + Arr::get($constraints, 'min_strength'), + Arr::get($constraints, 'max_strength'), + (float) Arr::get($defaults, 'strength', 0.75), + 0.0, + 1.0, + ); + + $outputFormat = Str::upper(trim((string) Arr::get($generation, 'output_format', Arr::get($defaults, 'output_format', 'JPG')))); + if (! in_array($outputFormat, ['JPG', 'PNG', 'WEBP'], true)) { + $outputFormat = 'JPG'; + } + + $deliveryMethod = Str::lower(trim((string) Arr::get($generation, 'delivery_method', Arr::get($defaults, 'delivery_method', 'async')))); + if (! in_array($deliveryMethod, ['async', 'sync'], true)) { + $deliveryMethod = 'async'; + } + + return [ + 'width' => $width, + 'height' => $height, + 'steps' => $steps, + 'cfg_scale' => $cfgScale, + 'strength' => $strength, + 'output_format' => $outputFormat, + 'delivery_method' => $deliveryMethod, + ]; + } + + private function coerceDimension( + mixed $value, + mixed $minimum, + mixed $maximum, + mixed $step, + int $fallback, + ): int { + $resolved = is_numeric($value) ? (int) $value : $fallback; + $minValue = is_numeric($minimum) ? (int) $minimum : 64; + $maxValue = is_numeric($maximum) ? (int) $maximum : 4096; + if ($minValue > $maxValue) { + [$minValue, $maxValue] = [$maxValue, $minValue]; + } + + $resolved = max($minValue, min($maxValue, $resolved)); + $resolvedStep = is_numeric($step) ? max(1, (int) $step) : 64; + + if ($resolvedStep > 1) { + $offset = $resolved - $minValue; + $resolved = $minValue + ((int) round($offset / $resolvedStep) * $resolvedStep); + } + + return max($minValue, min($maxValue, $resolved)); + } + + private function coerceIntRange(mixed $value, mixed $minimum, mixed $maximum, int $fallback): int + { + $resolved = is_numeric($value) ? (int) $value : $fallback; + $minValue = is_numeric($minimum) ? (int) $minimum : 1; + $maxValue = is_numeric($maximum) ? (int) $maximum : 150; + if ($minValue > $maxValue) { + [$minValue, $maxValue] = [$maxValue, $minValue]; + } + + return max($minValue, min($maxValue, $resolved)); + } + + private function coerceFloatRange( + mixed $value, + mixed $minimum, + mixed $maximum, + float $fallback, + float $defaultMinimum = 0.0, + float $defaultMaximum = 30.0, + ): float { + $resolved = is_numeric($value) ? (float) $value : $fallback; + $minValue = is_numeric($minimum) ? (float) $minimum : $defaultMinimum; + $maxValue = is_numeric($maximum) ? (float) $maximum : $defaultMaximum; + if ($minValue > $maxValue) { + [$minValue, $maxValue] = [$maxValue, $minValue]; + } + + return max($minValue, min($maxValue, $resolved)); + } + + private function resolveSeedImage(AiEditRequest $request): ?string + { + $candidate = trim((string) ($request->input_image_path ?? '')); + if ($candidate === '') { + return null; + } + + if ($this->isAcceptedSeedReference($candidate)) { + return $candidate; + } + + $event = Event::query()->find($request->event_id); + $disks = array_values(array_unique(array_filter([ + $event ? $this->eventStorageManager->getHotDiskForEvent($event) : null, + (string) config('filesystems.default', 'local'), + 'public', + ]))); + + foreach ($disks as $disk) { + $temporaryUrl = $this->resolveTemporaryUrl($disk, $candidate); + if ($this->isPublicHttpUrl($temporaryUrl)) { + return $temporaryUrl; + } + + $diskUrl = $this->resolveDiskUrl($disk, $candidate); + if ($this->isPublicHttpUrl($diskUrl)) { + return $diskUrl; + } + } + + $absolutePathUrl = $this->absoluteAppUrl($candidate); + + return $this->isPublicHttpUrl($absolutePathUrl) ? $absolutePathUrl : null; + } + + private function isAcceptedSeedReference(string $value): bool + { + if ($this->isPublicHttpUrl($value)) { + return true; + } + + if (Str::startsWith(Str::lower($value), 'data:image/')) { + return true; + } + + return Str::isUuid($value); + } + + private function resolveTemporaryUrl(string $disk, string $path): ?string + { + try { + return (string) Storage::disk($disk)->temporaryUrl($path, now()->addMinutes(20)); + } catch (Throwable) { + return null; + } + } + + private function resolveDiskUrl(string $disk, string $path): ?string + { + try { + $url = (string) Storage::disk($disk)->url($path); + } catch (Throwable) { + return null; + } + + if ($url === '') { + return null; + } + + return $this->absoluteAppUrl($url); + } + + private function absoluteAppUrl(string $value): string + { + $trimmed = trim($value); + if ($trimmed === '') { + return ''; + } + + if (Str::startsWith($trimmed, ['http://', 'https://'])) { + return $trimmed; + } + + $appUrl = rtrim((string) config('app.url', ''), '/'); + $normalizedPath = Str::startsWith($trimmed, '/') ? $trimmed : '/'.ltrim($trimmed, '/'); + + return $appUrl !== '' ? $appUrl.$normalizedPath : $normalizedPath; + } + + private function isPublicHttpUrl(?string $value): bool + { + if (! is_string($value) || trim($value) === '') { + return false; + } + + return Str::startsWith($value, ['http://', 'https://']); + } + + /** + * @param array $body + * @return array{code:string, message:string}|null + */ + private function extractProviderError(array $body): ?array + { + $errors = (array) ($body['errors'] ?? []); + $error = Arr::first($errors, static fn (mixed $item): bool => is_array($item)); + if (! is_array($error)) { + return null; + } + + $code = trim((string) (Arr::get($error, 'errorCode') ?? Arr::get($error, 'code') ?? 'provider_api_error')); + $message = trim((string) (Arr::get($error, 'message') ?? Arr::get($error, 'error') ?? Arr::get($error, 'errorMessage') ?? 'Runware returned an API error.')); + + return [ + 'code' => $code !== '' ? Str::snake($code) : 'provider_api_error', + 'message' => $message !== '' ? $message : 'Runware returned an API error.', + ]; + } + + /** + * @param array $body + * @return array> + */ + private function filterItemsForTask(array $body, string $taskUuid): array + { + $items = array_values(array_filter((array) ($body['data'] ?? []), static fn (mixed $item): bool => is_array($item))); + if ($items === []) { + return []; + } + + $taskItems = array_values(array_filter($items, static function (array $item) use ($taskUuid): bool { + $itemTaskUuid = trim((string) Arr::get($item, 'taskUUID', '')); + + return $itemTaskUuid !== '' && $itemTaskUuid === $taskUuid; + })); + + return $taskItems !== [] ? $taskItems : $items; + } + + /** + * @param array> $items + */ + private function resolveStatus(array $items): string + { + foreach ($items as $item) { + $status = Str::lower(trim((string) Arr::get($item, 'status', ''))); + if ($status !== '') { + return $status; + } + } + + return ''; + } + + /** + * @param array> $items + */ + private function resolveCost(array $items): ?float + { + foreach ($items as $item) { + $cost = Arr::get($item, 'cost'); + if (is_numeric($cost)) { + return (float) $cost; + } + } + + return null; + } + + /** + * @param array> $items + */ + private function resolveTaskUuid(array $items, string $fallback): string + { + foreach ($items as $item) { + $taskUuid = trim((string) Arr::get($item, 'taskUUID', '')); + if ($taskUuid !== '') { + return $taskUuid; + } + } + + return $fallback; + } + + /** + * @param array> $items + * @return array> + */ + private function mapOutputs(array $items, string $fallbackTaskId): array + { + $outputs = []; + + foreach ($items as $item) { + $imageUrl = Arr::get($item, 'imageURL') + ?? Arr::get($item, 'outputUrl') + ?? Arr::get($item, 'url') + ?? Arr::get($item, 'image_url'); + if (! is_string($imageUrl) || trim($imageUrl) === '') { + continue; + } + + $providerAssetId = trim((string) ( + Arr::get($item, 'imageUUID') + ?? Arr::get($item, 'outputUUID') + ?? Arr::get($item, 'providerAssetId') + ?? Arr::get($item, 'taskUUID') + ?? $fallbackTaskId + )); + + $outputs[] = [ + 'provider_url' => $imageUrl, + 'provider_asset_id' => $providerAssetId !== '' ? $providerAssetId : $fallbackTaskId, + 'mime_type' => $this->inferMimeType($imageUrl, Arr::get($item, 'outputFormat')), + 'width' => is_numeric(Arr::get($item, 'imageWidth')) ? (int) Arr::get($item, 'imageWidth') : (is_numeric(Arr::get($item, 'width')) ? (int) Arr::get($item, 'width') : null), + 'height' => is_numeric(Arr::get($item, 'imageHeight')) ? (int) Arr::get($item, 'imageHeight') : (is_numeric(Arr::get($item, 'height')) ? (int) Arr::get($item, 'height') : null), + ]; + } + + return array_values(array_reduce($outputs, static function (array $carry, array $output): array { + $key = (string) ($output['provider_url'] ?? ''); + if ($key === '') { + return $carry; + } + + $carry[$key] = $output; + + return $carry; + }, [])); + } + + private function inferMimeType(string $url, mixed $outputFormat): string + { + $format = Str::upper(trim((string) $outputFormat)); + if ($format === 'JPG' || $format === 'JPEG') { + return 'image/jpeg'; + } + + if ($format === 'PNG') { + return 'image/png'; + } + + if ($format === 'WEBP') { + return 'image/webp'; + } + + $extension = Str::lower(pathinfo((string) parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION)); + + return match ($extension) { + 'png' => 'image/png', + 'webp' => 'image/webp', + default => 'image/jpeg', + }; + } + + /** + * @param array> $items + */ + private function containsNsfwContent(array $items): bool + { + foreach ($items as $item) { + if ($this->toBool(Arr::get($item, 'NSFWContent')) || $this->toBool(Arr::get($item, 'nsfwContent'))) { + return true; + } + } + + return false; + } + + /** + * @param array> $items + */ + private function resolveFailureMessage(array $items, string $fallback): string + { + foreach ($items as $item) { + $message = trim((string) (Arr::get($item, 'errorMessage') ?? Arr::get($item, 'error') ?? Arr::get($item, 'message') ?? '')); + if ($message !== '') { + return $message; + } + } + + return $fallback; + } + private function toBool(mixed $value): bool { if (is_bool($value)) { diff --git a/app/Services/AiEditing/RunwareModelSearchService.php b/app/Services/AiEditing/RunwareModelSearchService.php new file mode 100644 index 00000000..10de2352 --- /dev/null +++ b/app/Services/AiEditing/RunwareModelSearchService.php @@ -0,0 +1,261 @@ +, + * constraints: array + * }> + */ + 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 + */ + 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, + * constraints: array + * }|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, + * constraints: array + * }> + */ + 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 $item + * @return array{ + * air: string, + * name: string, + * architecture: ?string, + * category: ?string, + * defaults: array, + * constraints: array + * }|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, + * constraints: array + * } $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; + } +} diff --git a/config/ai-editing.php b/config/ai-editing.php index 84a91497..4d42ff2b 100644 --- a/config/ai-editing.php +++ b/config/ai-editing.php @@ -58,6 +58,18 @@ return [ 'providers' => [ 'runware' => [ 'mode' => env('AI_EDITING_RUNWARE_MODE', 'live'), + 'model_search_limit' => (int) env('AI_EDITING_RUNWARE_MODEL_SEARCH_LIMIT', 25), + 'model_search_min_chars' => (int) env('AI_EDITING_RUNWARE_MODEL_SEARCH_MIN_CHARS', 2), + 'model_search_cache_seconds' => (int) env('AI_EDITING_RUNWARE_MODEL_SEARCH_CACHE_SECONDS', 300), + 'defaults' => [ + 'width' => (int) env('AI_EDITING_RUNWARE_DEFAULT_WIDTH', 1024), + 'height' => (int) env('AI_EDITING_RUNWARE_DEFAULT_HEIGHT', 1024), + 'steps' => (int) env('AI_EDITING_RUNWARE_DEFAULT_STEPS', 28), + 'cfg_scale' => (float) env('AI_EDITING_RUNWARE_DEFAULT_CFG_SCALE', 7), + 'strength' => (float) env('AI_EDITING_RUNWARE_DEFAULT_STRENGTH', 0.75), + 'output_format' => env('AI_EDITING_RUNWARE_OUTPUT_FORMAT', 'JPG'), + 'delivery_method' => env('AI_EDITING_RUNWARE_DELIVERY_METHOD', 'async'), + ], ], ], diff --git a/tests/Unit/Services/RunwareAiImageProviderTest.php b/tests/Unit/Services/RunwareAiImageProviderTest.php new file mode 100644 index 00000000..2a538e5a --- /dev/null +++ b/tests/Unit/Services/RunwareAiImageProviderTest.php @@ -0,0 +1,215 @@ +create(array_merge( + AiEditingSetting::defaults(), + [ + 'runware_mode' => 'live', + 'queue_auto_dispatch' => false, + ] + )); + } + + public function test_submit_builds_image_inference_payload_with_model_defaults_and_constraints(): void + { + config([ + 'services.runware.api_key' => 'test-runware-key', + 'services.runware.base_url' => 'https://api.runware.ai/v1', + 'filesystems.default' => 'public', + 'filesystems.disks.public.url' => 'https://cdn.example.test/storage', + 'app.url' => 'https://app.example.test', + ]); + + Storage::fake('public'); + + $event = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + + $sourcePath = 'events/'.$event->slug.'/photos/source-image.jpg'; + Storage::disk('public')->put($sourcePath, 'source-image'); + + $style = AiStyle::query()->create([ + 'key' => 'provider-style', + 'name' => 'Provider Style', + 'provider' => 'runware', + 'provider_model' => 'runware:100@1', + 'requires_source_image' => true, + 'is_active' => true, + ]); + + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware:100@1', + 'status' => AiEditRequest::STATUS_QUEUED, + 'safety_state' => 'pending', + 'prompt' => 'cinematic portrait', + 'negative_prompt' => 'blurry', + 'input_image_path' => $sourcePath, + 'idempotency_key' => 'runware-provider-test-1', + 'queued_at' => now(), + 'metadata' => [ + 'runware' => [ + 'generation' => [ + 'width' => 1510, + 'height' => 986, + 'steps' => 32, + 'cfg_scale' => 5.5, + 'strength' => 0.62, + 'output_format' => 'PNG', + 'delivery_method' => 'async', + ], + 'constraints' => [ + 'min_width' => 768, + 'max_width' => 2048, + 'width_step' => 64, + 'min_height' => 512, + 'max_height' => 2048, + 'height_step' => 64, + 'min_steps' => 20, + 'max_steps' => 60, + 'min_cfg_scale' => 1.0, + 'max_cfg_scale' => 8.0, + 'min_strength' => 0.2, + 'max_strength' => 0.9, + ], + ], + ], + ]); + + $capturedPayload = null; + + Http::fake(function (Request $httpRequest) use (&$capturedPayload) { + $payload = $httpRequest->data(); + $capturedPayload = is_array($payload) ? $payload : []; + $taskUuid = (string) (($capturedPayload[0]['taskUUID'] ?? null) ?: 'task-1'); + + return Http::response([ + 'data' => [ + [ + 'taskUUID' => $taskUuid, + 'status' => 'completed', + 'imageURL' => 'https://cdn.runware.ai/outputs/image-1.png', + 'imageUUID' => 'image-uuid-1', + 'outputFormat' => 'PNG', + 'width' => 1536, + 'height' => 960, + 'cost' => 0.0125, + ], + [ + 'taskUUID' => $taskUuid, + 'status' => 'completed', + 'imageURL' => 'https://cdn.runware.ai/outputs/image-2.png', + 'imageUUID' => 'image-uuid-2', + 'outputFormat' => 'PNG', + ], + ], + ], 200); + }); + + $provider = app(RunwareAiImageProvider::class); + $result = $provider->submit($request); + + $this->assertSame('succeeded', $result->status); + $this->assertIsArray($capturedPayload); + $this->assertIsArray($capturedPayload[0] ?? null); + $this->assertSame('imageInference', $capturedPayload[0]['taskType'] ?? null); + $this->assertSame('runware:100@1', $capturedPayload[0]['model'] ?? null); + $this->assertSame('PNG', $capturedPayload[0]['outputFormat'] ?? null); + $this->assertSame('async', $capturedPayload[0]['deliveryMethod'] ?? null); + $this->assertSame(1536, $capturedPayload[0]['width'] ?? null); + $this->assertSame(960, $capturedPayload[0]['height'] ?? null); + $this->assertSame(32, $capturedPayload[0]['steps'] ?? null); + $this->assertEquals(5.5, $capturedPayload[0]['CFGScale'] ?? null); + $this->assertEquals(0.62, $capturedPayload[0]['strength'] ?? null); + $this->assertIsString($capturedPayload[0]['seedImage'] ?? null); + $this->assertMatchesRegularExpression('/^https?:\\/\\//', (string) ($capturedPayload[0]['seedImage'] ?? '')); + $this->assertSame(2, count($result->outputs)); + $this->assertSame('image/png', $result->outputs[0]['mime_type'] ?? null); + $this->assertSame('image-uuid-1', $result->outputs[0]['provider_asset_id'] ?? null); + $this->assertSame(0.0125, $result->costUsd); + } + + public function test_poll_maps_top_level_runware_errors_to_failed_result(): void + { + config([ + 'services.runware.api_key' => 'test-runware-key', + 'services.runware.base_url' => 'https://api.runware.ai/v1', + ]); + + $event = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'provider-poll-style', + 'name' => 'Provider Poll Style', + 'provider' => 'runware', + 'provider_model' => 'runware:100@1', + 'requires_source_image' => true, + 'is_active' => true, + ]); + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware:100@1', + 'status' => AiEditRequest::STATUS_PROCESSING, + 'safety_state' => 'pending', + 'prompt' => 'test prompt', + 'idempotency_key' => 'runware-provider-test-2', + 'queued_at' => now(), + 'started_at' => now()->subSeconds(30), + ]); + + Http::fake([ + 'https://api.runware.ai/v1' => Http::response([ + 'errors' => [ + [ + 'errorCode' => 'rate_limit_exceeded', + 'message' => 'Rate limit exceeded', + ], + ], + ], 429), + ]); + + $provider = app(RunwareAiImageProvider::class); + $result = $provider->poll($request, 'provider-task-123'); + + $this->assertSame('failed', $result->status); + $this->assertSame('rate_limit_exceeded', $result->failureCode); + $this->assertSame('Rate limit exceeded', $result->failureMessage); + $this->assertSame(429, $result->httpStatus); + } +} diff --git a/tests/Unit/Services/RunwareModelSearchServiceTest.php b/tests/Unit/Services/RunwareModelSearchServiceTest.php new file mode 100644 index 00000000..f506d1c6 --- /dev/null +++ b/tests/Unit/Services/RunwareModelSearchServiceTest.php @@ -0,0 +1,79 @@ + 'test-runware-key', + 'services.runware.base_url' => 'https://api.runware.ai/v1', + 'ai-editing.providers.runware.model_search_min_chars' => 2, + 'ai-editing.providers.runware.model_search_limit' => 25, + 'ai-editing.providers.runware.model_search_cache_seconds' => 300, + ]); + + Http::fake([ + 'https://api.runware.ai/v1' => Http::response([ + 'data' => [ + [ + 'taskType' => 'modelSearch', + 'air' => 'runware:100@1', + 'name' => 'Flux Portrait', + 'architecture' => 'flux', + 'category' => 'photo', + 'defaultWidth' => 1216, + 'defaultHeight' => 832, + 'defaultSteps' => 30, + 'defaultCFG' => 3.5, + 'minWidth' => 768, + 'maxWidth' => 2048, + 'widthStep' => 64, + 'minHeight' => 512, + 'maxHeight' => 2048, + 'heightStep' => 64, + ], + ], + ], 200), + ]); + + $service = app(RunwareModelSearchService::class); + $options = $service->searchOptions('flux'); + $model = $service->findByAir('runware:100@1'); + $label = $service->labelForModel('runware:100@1'); + + $this->assertArrayHasKey('runware:100@1', $options); + $this->assertStringContainsString('Flux Portrait', (string) $options['runware:100@1']); + $this->assertIsArray($model); + $this->assertSame(1216, $model['defaults']['width'] ?? null); + $this->assertSame(768, $model['constraints']['min_width'] ?? null); + $this->assertIsString($label); + $this->assertStringContainsString('runware:100@1', (string) $label); + + $service->searchOptions('flux'); + Http::assertSentCount(2); + } + + public function test_it_returns_empty_results_when_runware_api_key_is_missing(): void + { + config([ + 'services.runware.api_key' => null, + ]); + + $service = app(RunwareModelSearchService::class); + + $this->assertSame([], $service->search('flux')); + $this->assertSame([], $service->searchOptions('flux')); + $this->assertNull($service->findByAir('runware:100@1')); + } +}