Files
fotospiel-app/app/Services/AiEditing/Providers/RunwareAiImageProvider.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

787 lines
27 KiB
PHP

<?php
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,
private readonly EventStorageManager $eventStorageManager,
) {}
public function submit(AiEditRequest $request): AiProviderResult
{
if ($this->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<string, mixed>|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<string, mixed> $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<string, mixed> $body
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $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<int, array<string, mixed>> $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<int, array<string, mixed>> $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<int, array<string, mixed>> $items
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $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<int, array<string, mixed>> $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;
}
}