feat: implement AI styling foundation and billing scope rework
This commit is contained in:
287
app/Services/AiEditing/Providers/RunwareAiImageProvider.php
Normal file
287
app/Services/AiEditing/Providers/RunwareAiImageProvider.php
Normal file
@@ -0,0 +1,287 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user