isFakeMode()) { return $this->fakeResult($request); } $apiKey = $this->apiKey(); if (! $apiKey) { return AiProviderResult::failed( 'provider_not_configured', 'Runware API key is not configured.' ); } $task = $this->buildInferenceTask($request); if ($task instanceof AiProviderResult) { return $task; } $payload = [$task]; $taskUuid = (string) Arr::get($task, 'taskUUID', ''); try { $response = Http::withToken($apiKey) ->acceptJson() ->timeout((int) config('services.runware.timeout', 90)) ->post($this->baseUrl(), $payload); $body = (array) $response->json(); if ($error = $this->extractProviderError($body)) { return AiProviderResult::failed( $error['code'], $error['message'], requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } $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', failureMessage: 'Provider flagged generated content as unsafe.', safetyState: 'blocked', safetyReasons: ['provider_nsfw_content'], requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } return AiProviderResult::succeeded( outputs: $outputs, costUsd: $cost, safetyState: 'passed', requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } 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, 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(), ); } return AiProviderResult::failed( 'provider_unexpected_response', 'Runware returned an unexpected response format.', requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } catch (Throwable $exception) { return AiProviderResult::failed( 'provider_exception', $exception->getMessage(), requestPayload: ['tasks' => $payload], ); } } public function poll(AiEditRequest $request, string $providerTaskId): AiProviderResult { if ($this->isFakeMode()) { return $this->fakeResult($request, $providerTaskId); } $apiKey = $this->apiKey(); if (! $apiKey) { return AiProviderResult::failed( 'provider_not_configured', 'Runware API key is not configured.' ); } $payload = [[ 'taskType' => 'getResponse', 'taskUUID' => $providerTaskId, 'includeCost' => true, ]]; try { $response = Http::withToken($apiKey) ->acceptJson() ->timeout((int) config('services.runware.timeout', 90)) ->post($this->baseUrl(), $payload); $body = (array) $response->json(); if ($error = $this->extractProviderError($body)) { return AiProviderResult::failed( $error['code'], $error['message'], requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } $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', failureMessage: 'Provider flagged generated content as unsafe.', safetyState: 'blocked', safetyReasons: ['provider_nsfw_content'], requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } return AiProviderResult::succeeded( outputs: $outputs, costUsd: $cost, safetyState: 'passed', requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } if (in_array($status, ['queued', 'pending', 'processing', 'running'], true)) { return AiProviderResult::processing( providerTaskId: $providerTaskId, costUsd: $cost, requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } 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 ($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(), ); } return AiProviderResult::failed( 'provider_unexpected_response', 'Runware returned an unexpected poll response format.', requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } catch (Throwable $exception) { return AiProviderResult::failed( 'provider_exception', $exception->getMessage(), requestPayload: ['tasks' => $payload], ); } } private function fakeResult(AiEditRequest $request, ?string $taskId = null): AiProviderResult { $resolvedTaskId = $taskId ?: 'runware-fake-'.Str::uuid()->toString(); $fakeNsfw = (bool) Arr::get($request->metadata ?? [], 'fake_nsfw', false); return AiProviderResult::succeeded( outputs: [[ 'provider_url' => sprintf('https://cdn.example.invalid/ai/%s.jpg', $resolvedTaskId), 'provider_asset_id' => $resolvedTaskId, 'mime_type' => 'image/jpeg', 'width' => 1024, 'height' => 1024, ]], costUsd: 0.01, safetyState: $fakeNsfw ? 'blocked' : 'passed', safetyReasons: $fakeNsfw ? ['provider_nsfw_content'] : [], requestPayload: [ 'prompt' => $request->prompt, 'provider_model' => $request->provider_model, 'task_id' => $resolvedTaskId, ], responsePayload: [ 'mode' => 'fake', 'status' => 'succeeded', 'data' => [ [ 'taskUUID' => $resolvedTaskId, 'NSFWContent' => $fakeNsfw, ], ], ] ); } private function isFakeMode(): bool { return $this->runtimeConfig->runwareMode() === 'fake'; } private function apiKey(): ?string { $apiKey = config('services.runware.api_key'); return is_string($apiKey) && $apiKey !== '' ? $apiKey : null; } private function baseUrl(): string { $base = (string) config('services.runware.base_url', 'https://api.runware.ai/v1'); 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)) { return $value; } if (is_numeric($value)) { return (int) $value === 1; } if (is_string($value)) { return in_array(Str::lower(trim($value)), ['1', 'true', 'yes'], true); } return false; } }