Files
fotospiel-app/app/Services/AiEditing/Providers/RunwareAiImageProvider.php
Codex Agent 36bed12ff9
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
feat: implement AI styling foundation and billing scope rework
2026-02-06 20:01:58 +01:00

288 lines
10 KiB
PHP

<?php
namespace App\Services\AiEditing\Providers;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiProviderResult;
use App\Services\AiEditing\Contracts\AiImageProvider;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Throwable;
class RunwareAiImageProvider implements AiImageProvider
{
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig) {}
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.'
);
}
$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;
}
if (is_string($request->input_image_path) && $request->input_image_path !== '') {
$payload[0]['seedImage'] = $request->input_image_path;
}
try {
$response = Http::withToken($apiKey)
->acceptJson()
->timeout((int) config('services.runware.timeout', 90))
->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 (is_string($imageUrl) && $imageUrl !== '') {
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: [[
'provider_url' => $imageUrl,
'provider_asset_id' => $providerTaskId !== '' ? $providerTaskId : null,
'mime_type' => 'image/jpeg',
]],
costUsd: $cost,
safetyState: 'passed',
requestPayload: ['tasks' => $payload],
responsePayload: $body,
httpStatus: $response->status(),
);
}
if ($providerTaskId !== '' || $status === 'processing') {
return AiProviderResult::processing(
providerTaskId: $providerTaskId !== '' ? $providerTaskId : (string) Str::uuid(),
costUsd: $cost,
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();
$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 (is_string($imageUrl) && $imageUrl !== '') {
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: [[
'provider_url' => $imageUrl,
'provider_asset_id' => $providerTaskId,
'mime_type' => 'image/jpeg',
]],
costUsd: $cost,
safetyState: 'passed',
requestPayload: ['tasks' => $payload],
responsePayload: $body,
httpStatus: $response->status(),
);
}
if ($status === 'processing' || $status === '') {
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',
(string) ($data['errorMessage'] ?? 'Runware reported a failed job.'),
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, '/');
}
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;
}
}