288 lines
10 KiB
PHP
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;
|
|
}
|
|
}
|