feat(ai): add runware model search and model-constrained img2img
This commit is contained in:
@@ -5,6 +5,7 @@ namespace App\Filament\Resources\AiStyles;
|
|||||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||||
use App\Filament\Resources\AiStyles\Pages\ManageAiStyles;
|
use App\Filament\Resources\AiStyles\Pages\ManageAiStyles;
|
||||||
use App\Models\AiStyle;
|
use App\Models\AiStyle;
|
||||||
|
use App\Services\AiEditing\RunwareModelSearchService;
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@@ -15,6 +16,8 @@ use Filament\Forms\Components\TextInput;
|
|||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
@@ -82,10 +85,74 @@ class AiStyleResource extends Resource
|
|||||||
])
|
])
|
||||||
->required()
|
->required()
|
||||||
->default('runware'),
|
->default('runware'),
|
||||||
TextInput::make('provider_model')
|
Select::make('provider_model')
|
||||||
->maxLength(120),
|
->label('Runware model (AIR)')
|
||||||
|
->searchable()
|
||||||
|
->getSearchResultsUsing(static fn (string $search): array => app(RunwareModelSearchService::class)->searchOptions($search))
|
||||||
|
->getOptionLabelUsing(static fn (mixed $value): ?string => app(RunwareModelSearchService::class)->labelForModel($value))
|
||||||
|
->helperText('Start typing to search models from runware.ai.')
|
||||||
|
->native(false)
|
||||||
|
->live()
|
||||||
|
->afterStateUpdated(static function (Set $set, ?string $state): void {
|
||||||
|
self::applySelectedRunwareModel($set, $state);
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
|
Section::make('Runware Generation')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('metadata.runware.generation.width')
|
||||||
|
->label('Width')
|
||||||
|
->numeric()
|
||||||
|
->minValue(64)
|
||||||
|
->maxValue(4096)
|
||||||
|
->step(64)
|
||||||
|
->helperText(static fn (Get $get): ?string => self::dimensionConstraintHint($get, 'width')),
|
||||||
|
TextInput::make('metadata.runware.generation.height')
|
||||||
|
->label('Height')
|
||||||
|
->numeric()
|
||||||
|
->minValue(64)
|
||||||
|
->maxValue(4096)
|
||||||
|
->step(64)
|
||||||
|
->helperText(static fn (Get $get): ?string => self::dimensionConstraintHint($get, 'height')),
|
||||||
|
TextInput::make('metadata.runware.generation.steps')
|
||||||
|
->label('Steps')
|
||||||
|
->numeric()
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(150)
|
||||||
|
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'steps')),
|
||||||
|
TextInput::make('metadata.runware.generation.cfg_scale')
|
||||||
|
->label('CFG Scale')
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->maxValue(30)
|
||||||
|
->step(0.1)
|
||||||
|
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'cfg_scale')),
|
||||||
|
TextInput::make('metadata.runware.generation.strength')
|
||||||
|
->label('Strength')
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->maxValue(1)
|
||||||
|
->step(0.01)
|
||||||
|
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'strength')),
|
||||||
|
Select::make('metadata.runware.generation.output_format')
|
||||||
|
->label('Output format')
|
||||||
|
->options([
|
||||||
|
'JPG' => 'JPG',
|
||||||
|
'PNG' => 'PNG',
|
||||||
|
'WEBP' => 'WEBP',
|
||||||
|
])
|
||||||
|
->default('JPG')
|
||||||
|
->native(false),
|
||||||
|
Select::make('metadata.runware.generation.delivery_method')
|
||||||
|
->label('Delivery method')
|
||||||
|
->options([
|
||||||
|
'async' => 'async (queue + poll)',
|
||||||
|
'sync' => 'sync',
|
||||||
|
])
|
||||||
|
->default('async')
|
||||||
|
->native(false),
|
||||||
|
])
|
||||||
|
->columns(3),
|
||||||
Section::make('Prompts')
|
Section::make('Prompts')
|
||||||
->schema([
|
->schema([
|
||||||
Textarea::make('description')
|
Textarea::make('description')
|
||||||
@@ -160,4 +227,79 @@ class AiStyleResource extends Resource
|
|||||||
'index' => ManageAiStyles::route('/'),
|
'index' => ManageAiStyles::route('/'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function applySelectedRunwareModel(Set $set, ?string $air): void
|
||||||
|
{
|
||||||
|
if (! is_string($air) || trim($air) === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model = app(RunwareModelSearchService::class)->findByAir($air);
|
||||||
|
if (! is_array($model)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$set('metadata.runware.model.air', $model['air']);
|
||||||
|
$set('metadata.runware.model.name', $model['name']);
|
||||||
|
$set('metadata.runware.model.architecture', $model['architecture']);
|
||||||
|
$set('metadata.runware.model.category', $model['category']);
|
||||||
|
|
||||||
|
foreach ((array) ($model['constraints'] ?? []) as $key => $value) {
|
||||||
|
$set("metadata.runware.constraints.{$key}", $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::setIfNumeric($set, 'metadata.runware.generation.width', $model['defaults']['width'] ?? null);
|
||||||
|
self::setIfNumeric($set, 'metadata.runware.generation.height', $model['defaults']['height'] ?? null);
|
||||||
|
self::setIfNumeric($set, 'metadata.runware.generation.steps', $model['defaults']['steps'] ?? null);
|
||||||
|
self::setIfNumeric($set, 'metadata.runware.generation.cfg_scale', $model['defaults']['cfg_scale'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function setIfNumeric(Set $set, string $path, mixed $value): void
|
||||||
|
{
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
$set($path, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function dimensionConstraintHint(Get $get, string $dimension): ?string
|
||||||
|
{
|
||||||
|
$min = $get("metadata.runware.constraints.min_{$dimension}");
|
||||||
|
$max = $get("metadata.runware.constraints.max_{$dimension}");
|
||||||
|
$step = $get("metadata.runware.constraints.{$dimension}_step");
|
||||||
|
|
||||||
|
if (! is_numeric($min) && ! is_numeric($max) && ! is_numeric($step)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
if (is_numeric($min) || is_numeric($max)) {
|
||||||
|
$parts[] = sprintf(
|
||||||
|
'Model range: %s - %s',
|
||||||
|
is_numeric($min) ? (string) (int) $min : '?',
|
||||||
|
is_numeric($max) ? (string) (int) $max : '?'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($step) && (int) $step > 0) {
|
||||||
|
$parts[] = sprintf('Step: %d', (int) $step);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts !== [] ? implode(' | ', $parts) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function rangeConstraintHint(Get $get, string $field): ?string
|
||||||
|
{
|
||||||
|
$min = $get("metadata.runware.constraints.min_{$field}");
|
||||||
|
$max = $get("metadata.runware.constraints.max_{$field}");
|
||||||
|
|
||||||
|
if (! is_numeric($min) && ! is_numeric($max)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Model range: %s - %s',
|
||||||
|
is_numeric($min) ? trim((string) $min) : '?',
|
||||||
|
is_numeric($max) ? trim((string) $max) : '?'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Support\ApiError;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -160,6 +161,12 @@ class EventPublicAiEditController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$metadata = (array) $request->input('metadata', []);
|
$metadata = (array) $request->input('metadata', []);
|
||||||
|
$styleMetadata = is_array($style?->metadata) ? $style->metadata : [];
|
||||||
|
$styleRunwareMetadata = Arr::get($styleMetadata, 'runware');
|
||||||
|
if (is_array($styleRunwareMetadata)) {
|
||||||
|
$metadata['runware'] = $styleRunwareMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
if (is_array($abuseSignal)) {
|
if (is_array($abuseSignal)) {
|
||||||
$metadata['abuse'] = $abuseSignal;
|
$metadata['abuse'] = $abuseSignal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use App\Support\ApiError;
|
|||||||
use App\Support\TenantMemberPermissions;
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -314,6 +315,12 @@ class AiEditController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$metadata = (array) $request->input('metadata', []);
|
$metadata = (array) $request->input('metadata', []);
|
||||||
|
$styleMetadata = is_array($style->metadata) ? $style->metadata : [];
|
||||||
|
$styleRunwareMetadata = Arr::get($styleMetadata, 'runware');
|
||||||
|
if (is_array($styleRunwareMetadata)) {
|
||||||
|
$metadata['runware'] = $styleRunwareMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
if (is_array($abuseSignal)) {
|
if (is_array($abuseSignal)) {
|
||||||
$metadata['abuse'] = $abuseSignal;
|
$metadata['abuse'] = $abuseSignal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,23 @@
|
|||||||
namespace App\Services\AiEditing\Providers;
|
namespace App\Services\AiEditing\Providers;
|
||||||
|
|
||||||
use App\Models\AiEditRequest;
|
use App\Models\AiEditRequest;
|
||||||
|
use App\Models\Event;
|
||||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||||
use App\Services\AiEditing\AiProviderResult;
|
use App\Services\AiEditing\AiProviderResult;
|
||||||
use App\Services\AiEditing\Contracts\AiImageProvider;
|
use App\Services\AiEditing\Contracts\AiImageProvider;
|
||||||
|
use App\Services\Storage\EventStorageManager;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class RunwareAiImageProvider implements AiImageProvider
|
class RunwareAiImageProvider implements AiImageProvider
|
||||||
{
|
{
|
||||||
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig) {}
|
public function __construct(
|
||||||
|
private readonly AiEditingRuntimeConfig $runtimeConfig,
|
||||||
|
private readonly EventStorageManager $eventStorageManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function submit(AiEditRequest $request): AiProviderResult
|
public function submit(AiEditRequest $request): AiProviderResult
|
||||||
{
|
{
|
||||||
@@ -29,28 +35,13 @@ class RunwareAiImageProvider implements AiImageProvider
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = [
|
$task = $this->buildInferenceTask($request);
|
||||||
[
|
if ($task instanceof AiProviderResult) {
|
||||||
'taskType' => 'imageInference',
|
return $task;
|
||||||
'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 = [$task];
|
||||||
$payload[0]['seedImage'] = $request->input_image_path;
|
$taskUuid = (string) Arr::get($task, 'taskUUID', '');
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = Http::withToken($apiKey)
|
$response = Http::withToken($apiKey)
|
||||||
@@ -59,14 +50,24 @@ class RunwareAiImageProvider implements AiImageProvider
|
|||||||
->post($this->baseUrl(), $payload);
|
->post($this->baseUrl(), $payload);
|
||||||
|
|
||||||
$body = (array) $response->json();
|
$body = (array) $response->json();
|
||||||
$data = Arr::first((array) ($body['data'] ?? []), []);
|
if ($error = $this->extractProviderError($body)) {
|
||||||
$providerTaskId = (string) ($data['taskUUID'] ?? '');
|
return AiProviderResult::failed(
|
||||||
$status = strtolower((string) ($data['status'] ?? ''));
|
$error['code'],
|
||||||
$cost = is_numeric($data['cost'] ?? null) ? (float) $data['cost'] : null;
|
$error['message'],
|
||||||
$imageUrl = $data['imageURL'] ?? $data['outputUrl'] ?? $data['url'] ?? null;
|
requestPayload: ['tasks' => $payload],
|
||||||
$providerNsfw = $this->toBool($data['NSFWContent'] ?? null) || $this->toBool($data['nsfwContent'] ?? null);
|
responsePayload: $body,
|
||||||
|
httpStatus: $response->status(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (is_string($imageUrl) && $imageUrl !== '') {
|
$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) {
|
if ($providerNsfw) {
|
||||||
return AiProviderResult::blocked(
|
return AiProviderResult::blocked(
|
||||||
failureCode: 'provider_nsfw_content',
|
failureCode: 'provider_nsfw_content',
|
||||||
@@ -80,11 +81,7 @@ class RunwareAiImageProvider implements AiImageProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
return AiProviderResult::succeeded(
|
return AiProviderResult::succeeded(
|
||||||
outputs: [[
|
outputs: $outputs,
|
||||||
'provider_url' => $imageUrl,
|
|
||||||
'provider_asset_id' => $providerTaskId !== '' ? $providerTaskId : null,
|
|
||||||
'mime_type' => 'image/jpeg',
|
|
||||||
]],
|
|
||||||
costUsd: $cost,
|
costUsd: $cost,
|
||||||
safetyState: 'passed',
|
safetyState: 'passed',
|
||||||
requestPayload: ['tasks' => $payload],
|
requestPayload: ['tasks' => $payload],
|
||||||
@@ -93,7 +90,17 @@ class RunwareAiImageProvider implements AiImageProvider
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($providerTaskId !== '' || $status === 'processing') {
|
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(
|
return AiProviderResult::processing(
|
||||||
providerTaskId: $providerTaskId !== '' ? $providerTaskId : (string) Str::uuid(),
|
providerTaskId: $providerTaskId !== '' ? $providerTaskId : (string) Str::uuid(),
|
||||||
costUsd: $cost,
|
costUsd: $cost,
|
||||||
@@ -103,6 +110,16 @@ class RunwareAiImageProvider implements AiImageProvider
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
return AiProviderResult::failed(
|
||||||
'provider_unexpected_response',
|
'provider_unexpected_response',
|
||||||
'Runware returned an unexpected response format.',
|
'Runware returned an unexpected response format.',
|
||||||
@@ -146,13 +163,23 @@ class RunwareAiImageProvider implements AiImageProvider
|
|||||||
->post($this->baseUrl(), $payload);
|
->post($this->baseUrl(), $payload);
|
||||||
|
|
||||||
$body = (array) $response->json();
|
$body = (array) $response->json();
|
||||||
$data = Arr::first((array) ($body['data'] ?? []), []);
|
if ($error = $this->extractProviderError($body)) {
|
||||||
$status = strtolower((string) ($data['status'] ?? ''));
|
return AiProviderResult::failed(
|
||||||
$cost = is_numeric($data['cost'] ?? null) ? (float) $data['cost'] : null;
|
$error['code'],
|
||||||
$imageUrl = $data['imageURL'] ?? $data['outputUrl'] ?? $data['url'] ?? null;
|
$error['message'],
|
||||||
$providerNsfw = $this->toBool($data['NSFWContent'] ?? null) || $this->toBool($data['nsfwContent'] ?? null);
|
requestPayload: ['tasks' => $payload],
|
||||||
|
responsePayload: $body,
|
||||||
|
httpStatus: $response->status(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (is_string($imageUrl) && $imageUrl !== '') {
|
$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) {
|
if ($providerNsfw) {
|
||||||
return AiProviderResult::blocked(
|
return AiProviderResult::blocked(
|
||||||
failureCode: 'provider_nsfw_content',
|
failureCode: 'provider_nsfw_content',
|
||||||
@@ -166,11 +193,7 @@ class RunwareAiImageProvider implements AiImageProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
return AiProviderResult::succeeded(
|
return AiProviderResult::succeeded(
|
||||||
outputs: [[
|
outputs: $outputs,
|
||||||
'provider_url' => $imageUrl,
|
|
||||||
'provider_asset_id' => $providerTaskId,
|
|
||||||
'mime_type' => 'image/jpeg',
|
|
||||||
]],
|
|
||||||
costUsd: $cost,
|
costUsd: $cost,
|
||||||
safetyState: 'passed',
|
safetyState: 'passed',
|
||||||
requestPayload: ['tasks' => $payload],
|
requestPayload: ['tasks' => $payload],
|
||||||
@@ -179,7 +202,7 @@ class RunwareAiImageProvider implements AiImageProvider
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($status === 'processing' || $status === '') {
|
if (in_array($status, ['queued', 'pending', 'processing', 'running'], true)) {
|
||||||
return AiProviderResult::processing(
|
return AiProviderResult::processing(
|
||||||
providerTaskId: $providerTaskId,
|
providerTaskId: $providerTaskId,
|
||||||
costUsd: $cost,
|
costUsd: $cost,
|
||||||
@@ -192,7 +215,27 @@ class RunwareAiImageProvider implements AiImageProvider
|
|||||||
if (in_array($status, ['failed', 'error'], true)) {
|
if (in_array($status, ['failed', 'error'], true)) {
|
||||||
return AiProviderResult::failed(
|
return AiProviderResult::failed(
|
||||||
'provider_failed',
|
'provider_failed',
|
||||||
(string) ($data['errorMessage'] ?? 'Runware reported a failed job.'),
|
$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],
|
requestPayload: ['tasks' => $payload],
|
||||||
responsePayload: $body,
|
responsePayload: $body,
|
||||||
httpStatus: $response->status(),
|
httpStatus: $response->status(),
|
||||||
@@ -268,6 +311,462 @@ class RunwareAiImageProvider implements AiImageProvider
|
|||||||
return rtrim($base, '/');
|
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
|
private function toBool(mixed $value): bool
|
||||||
{
|
{
|
||||||
if (is_bool($value)) {
|
if (is_bool($value)) {
|
||||||
|
|||||||
261
app/Services/AiEditing/RunwareModelSearchService.php
Normal file
261
app/Services/AiEditing/RunwareModelSearchService.php
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\AiEditing;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class RunwareModelSearchService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, array{
|
||||||
|
* air: string,
|
||||||
|
* name: string,
|
||||||
|
* architecture: ?string,
|
||||||
|
* category: ?string,
|
||||||
|
* defaults: array<string, int|float|null>,
|
||||||
|
* constraints: array<string, int|float|null>
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function search(string $search, ?int $limit = null): array
|
||||||
|
{
|
||||||
|
$normalizedSearch = trim($search);
|
||||||
|
$minimumSearchLength = max(1, (int) config('ai-editing.providers.runware.model_search_min_chars', 2));
|
||||||
|
|
||||||
|
if (Str::length($normalizedSearch) < $minimumSearchLength) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKey = $this->apiKey();
|
||||||
|
if (! $apiKey) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedLimit = $this->resolveLimit($limit);
|
||||||
|
$cacheTtl = max(30, (int) config('ai-editing.providers.runware.model_search_cache_seconds', 300));
|
||||||
|
$cacheKey = sprintf(
|
||||||
|
'ai_editing.runware.model_search.%s',
|
||||||
|
sha1(Str::lower($normalizedSearch).'|'.$resolvedLimit)
|
||||||
|
);
|
||||||
|
|
||||||
|
return Cache::remember(
|
||||||
|
$cacheKey,
|
||||||
|
now()->addSeconds($cacheTtl),
|
||||||
|
fn (): array => $this->performSearch($normalizedSearch, $resolvedLimit, $apiKey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function searchOptions(string $search, ?int $limit = null): array
|
||||||
|
{
|
||||||
|
return collect($this->search($search, $limit))
|
||||||
|
->mapWithKeys(fn (array $model): array => [$model['air'] => $this->formatLabel($model)])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function labelForModel(mixed $air): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($air) || trim($air) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model = $this->findByAir($air);
|
||||||
|
|
||||||
|
return $model ? $this->formatLabel($model) : trim($air);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* air: string,
|
||||||
|
* name: string,
|
||||||
|
* architecture: ?string,
|
||||||
|
* category: ?string,
|
||||||
|
* defaults: array<string, int|float|null>,
|
||||||
|
* constraints: array<string, int|float|null>
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public function findByAir(string $air): ?array
|
||||||
|
{
|
||||||
|
$normalizedAir = trim($air);
|
||||||
|
if ($normalizedAir === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = $this->search($normalizedAir, 50);
|
||||||
|
|
||||||
|
foreach ($results as $model) {
|
||||||
|
if (Str::lower($model['air']) === Str::lower($normalizedAir)) {
|
||||||
|
return $model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{
|
||||||
|
* air: string,
|
||||||
|
* name: string,
|
||||||
|
* architecture: ?string,
|
||||||
|
* category: ?string,
|
||||||
|
* defaults: array<string, int|float|null>,
|
||||||
|
* constraints: array<string, int|float|null>
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private function performSearch(string $search, int $limit, string $apiKey): array
|
||||||
|
{
|
||||||
|
$payload = [[
|
||||||
|
'taskType' => 'modelSearch',
|
||||||
|
'taskUUID' => (string) Str::uuid(),
|
||||||
|
'search' => $search,
|
||||||
|
'limit' => $limit,
|
||||||
|
'offset' => 0,
|
||||||
|
]];
|
||||||
|
|
||||||
|
$response = Http::withToken($apiKey)
|
||||||
|
->acceptJson()
|
||||||
|
->timeout((int) config('services.runware.timeout', 90))
|
||||||
|
->post($this->baseUrl(), $payload);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = (array) $response->json();
|
||||||
|
$items = (array) ($body['data'] ?? []);
|
||||||
|
|
||||||
|
$models = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (! is_array($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = $this->normalizeModel($item);
|
||||||
|
if (! is_array($normalized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$models[$normalized['air']] = $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($models);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $item
|
||||||
|
* @return array{
|
||||||
|
* air: string,
|
||||||
|
* name: string,
|
||||||
|
* architecture: ?string,
|
||||||
|
* category: ?string,
|
||||||
|
* defaults: array<string, int|float|null>,
|
||||||
|
* constraints: array<string, int|float|null>
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
private function normalizeModel(array $item): ?array
|
||||||
|
{
|
||||||
|
$air = trim((string) (Arr::get($item, 'air') ?? Arr::get($item, 'model') ?? ''));
|
||||||
|
if ($air === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim((string) (Arr::get($item, 'name') ?? Arr::get($item, 'title') ?? $air));
|
||||||
|
$architecture = $this->normalizeOptionalString((string) Arr::get($item, 'architecture', ''));
|
||||||
|
$category = $this->normalizeOptionalString((string) Arr::get($item, 'category', ''));
|
||||||
|
|
||||||
|
$defaults = [
|
||||||
|
'width' => $this->normalizeInteger(Arr::get($item, 'defaultWidth')),
|
||||||
|
'height' => $this->normalizeInteger(Arr::get($item, 'defaultHeight')),
|
||||||
|
'steps' => $this->normalizeInteger(Arr::get($item, 'defaultSteps')),
|
||||||
|
'cfg_scale' => $this->normalizeFloat(Arr::get($item, 'defaultCFG')),
|
||||||
|
];
|
||||||
|
|
||||||
|
$constraints = [
|
||||||
|
'min_width' => $this->normalizeInteger(Arr::get($item, 'minWidth')),
|
||||||
|
'max_width' => $this->normalizeInteger(Arr::get($item, 'maxWidth')),
|
||||||
|
'width_step' => $this->normalizeInteger(Arr::get($item, 'widthStep')),
|
||||||
|
'min_height' => $this->normalizeInteger(Arr::get($item, 'minHeight')),
|
||||||
|
'max_height' => $this->normalizeInteger(Arr::get($item, 'maxHeight')),
|
||||||
|
'height_step' => $this->normalizeInteger(Arr::get($item, 'heightStep')),
|
||||||
|
'min_steps' => $this->normalizeInteger(Arr::get($item, 'minSteps')),
|
||||||
|
'max_steps' => $this->normalizeInteger(Arr::get($item, 'maxSteps')),
|
||||||
|
'min_cfg_scale' => $this->normalizeFloat(Arr::get($item, 'minCFG')),
|
||||||
|
'max_cfg_scale' => $this->normalizeFloat(Arr::get($item, 'maxCFG')),
|
||||||
|
'min_strength' => $this->normalizeFloat(Arr::get($item, 'minStrength')),
|
||||||
|
'max_strength' => $this->normalizeFloat(Arr::get($item, 'maxStrength')),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'air' => $air,
|
||||||
|
'name' => $name !== '' ? $name : $air,
|
||||||
|
'architecture' => $architecture,
|
||||||
|
'category' => $category,
|
||||||
|
'defaults' => $defaults,
|
||||||
|
'constraints' => $constraints,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* air: string,
|
||||||
|
* name: string,
|
||||||
|
* architecture: ?string,
|
||||||
|
* category: ?string,
|
||||||
|
* defaults: array<string, int|float|null>,
|
||||||
|
* constraints: array<string, int|float|null>
|
||||||
|
* } $model
|
||||||
|
*/
|
||||||
|
private function formatLabel(array $model): string
|
||||||
|
{
|
||||||
|
$parts = [$model['name']];
|
||||||
|
|
||||||
|
if ($model['architecture']) {
|
||||||
|
$parts[] = $model['architecture'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = $model['air'];
|
||||||
|
|
||||||
|
return implode(' | ', array_filter($parts, static fn (mixed $value): bool => is_string($value) && trim($value) !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveLimit(?int $limit): int
|
||||||
|
{
|
||||||
|
$defaultLimit = max(1, (int) config('ai-editing.providers.runware.model_search_limit', 25));
|
||||||
|
$resolved = $limit ?? $defaultLimit;
|
||||||
|
|
||||||
|
return max(1, min(100, $resolved));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function apiKey(): ?string
|
||||||
|
{
|
||||||
|
$apiKey = config('services.runware.api_key');
|
||||||
|
|
||||||
|
return is_string($apiKey) && trim($apiKey) !== '' ? trim($apiKey) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function baseUrl(): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('services.runware.base_url', 'https://api.runware.ai/v1'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeOptionalString(string $value): ?string
|
||||||
|
{
|
||||||
|
$trimmed = trim($value);
|
||||||
|
|
||||||
|
return $trimmed !== '' ? $trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeInteger(mixed $value): ?int
|
||||||
|
{
|
||||||
|
return is_numeric($value) ? (int) $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeFloat(mixed $value): ?float
|
||||||
|
{
|
||||||
|
return is_numeric($value) ? (float) $value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,18 @@ return [
|
|||||||
'providers' => [
|
'providers' => [
|
||||||
'runware' => [
|
'runware' => [
|
||||||
'mode' => env('AI_EDITING_RUNWARE_MODE', 'live'),
|
'mode' => env('AI_EDITING_RUNWARE_MODE', 'live'),
|
||||||
|
'model_search_limit' => (int) env('AI_EDITING_RUNWARE_MODEL_SEARCH_LIMIT', 25),
|
||||||
|
'model_search_min_chars' => (int) env('AI_EDITING_RUNWARE_MODEL_SEARCH_MIN_CHARS', 2),
|
||||||
|
'model_search_cache_seconds' => (int) env('AI_EDITING_RUNWARE_MODEL_SEARCH_CACHE_SECONDS', 300),
|
||||||
|
'defaults' => [
|
||||||
|
'width' => (int) env('AI_EDITING_RUNWARE_DEFAULT_WIDTH', 1024),
|
||||||
|
'height' => (int) env('AI_EDITING_RUNWARE_DEFAULT_HEIGHT', 1024),
|
||||||
|
'steps' => (int) env('AI_EDITING_RUNWARE_DEFAULT_STEPS', 28),
|
||||||
|
'cfg_scale' => (float) env('AI_EDITING_RUNWARE_DEFAULT_CFG_SCALE', 7),
|
||||||
|
'strength' => (float) env('AI_EDITING_RUNWARE_DEFAULT_STRENGTH', 0.75),
|
||||||
|
'output_format' => env('AI_EDITING_RUNWARE_OUTPUT_FORMAT', 'JPG'),
|
||||||
|
'delivery_method' => env('AI_EDITING_RUNWARE_DELIVERY_METHOD', 'async'),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
215
tests/Unit/Services/RunwareAiImageProviderTest.php
Normal file
215
tests/Unit/Services/RunwareAiImageProviderTest.php
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use App\Models\AiEditingSetting;
|
||||||
|
use App\Models\AiEditRequest;
|
||||||
|
use App\Models\AiStyle;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Services\AiEditing\Providers\RunwareAiImageProvider;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Client\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class RunwareAiImageProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
AiEditingSetting::flushCache();
|
||||||
|
AiEditingSetting::query()->create(array_merge(
|
||||||
|
AiEditingSetting::defaults(),
|
||||||
|
[
|
||||||
|
'runware_mode' => 'live',
|
||||||
|
'queue_auto_dispatch' => false,
|
||||||
|
]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_submit_builds_image_inference_payload_with_model_defaults_and_constraints(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'services.runware.api_key' => 'test-runware-key',
|
||||||
|
'services.runware.base_url' => 'https://api.runware.ai/v1',
|
||||||
|
'filesystems.default' => 'public',
|
||||||
|
'filesystems.disks.public.url' => 'https://cdn.example.test/storage',
|
||||||
|
'app.url' => 'https://app.example.test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$event = Event::factory()->create(['status' => 'published']);
|
||||||
|
$photo = Photo::factory()->for($event)->create([
|
||||||
|
'tenant_id' => $event->tenant_id,
|
||||||
|
'status' => 'approved',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sourcePath = 'events/'.$event->slug.'/photos/source-image.jpg';
|
||||||
|
Storage::disk('public')->put($sourcePath, 'source-image');
|
||||||
|
|
||||||
|
$style = AiStyle::query()->create([
|
||||||
|
'key' => 'provider-style',
|
||||||
|
'name' => 'Provider Style',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'provider_model' => 'runware:100@1',
|
||||||
|
'requires_source_image' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = AiEditRequest::query()->create([
|
||||||
|
'tenant_id' => $event->tenant_id,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'photo_id' => $photo->id,
|
||||||
|
'style_id' => $style->id,
|
||||||
|
'provider' => 'runware',
|
||||||
|
'provider_model' => 'runware:100@1',
|
||||||
|
'status' => AiEditRequest::STATUS_QUEUED,
|
||||||
|
'safety_state' => 'pending',
|
||||||
|
'prompt' => 'cinematic portrait',
|
||||||
|
'negative_prompt' => 'blurry',
|
||||||
|
'input_image_path' => $sourcePath,
|
||||||
|
'idempotency_key' => 'runware-provider-test-1',
|
||||||
|
'queued_at' => now(),
|
||||||
|
'metadata' => [
|
||||||
|
'runware' => [
|
||||||
|
'generation' => [
|
||||||
|
'width' => 1510,
|
||||||
|
'height' => 986,
|
||||||
|
'steps' => 32,
|
||||||
|
'cfg_scale' => 5.5,
|
||||||
|
'strength' => 0.62,
|
||||||
|
'output_format' => 'PNG',
|
||||||
|
'delivery_method' => 'async',
|
||||||
|
],
|
||||||
|
'constraints' => [
|
||||||
|
'min_width' => 768,
|
||||||
|
'max_width' => 2048,
|
||||||
|
'width_step' => 64,
|
||||||
|
'min_height' => 512,
|
||||||
|
'max_height' => 2048,
|
||||||
|
'height_step' => 64,
|
||||||
|
'min_steps' => 20,
|
||||||
|
'max_steps' => 60,
|
||||||
|
'min_cfg_scale' => 1.0,
|
||||||
|
'max_cfg_scale' => 8.0,
|
||||||
|
'min_strength' => 0.2,
|
||||||
|
'max_strength' => 0.9,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$capturedPayload = null;
|
||||||
|
|
||||||
|
Http::fake(function (Request $httpRequest) use (&$capturedPayload) {
|
||||||
|
$payload = $httpRequest->data();
|
||||||
|
$capturedPayload = is_array($payload) ? $payload : [];
|
||||||
|
$taskUuid = (string) (($capturedPayload[0]['taskUUID'] ?? null) ?: 'task-1');
|
||||||
|
|
||||||
|
return Http::response([
|
||||||
|
'data' => [
|
||||||
|
[
|
||||||
|
'taskUUID' => $taskUuid,
|
||||||
|
'status' => 'completed',
|
||||||
|
'imageURL' => 'https://cdn.runware.ai/outputs/image-1.png',
|
||||||
|
'imageUUID' => 'image-uuid-1',
|
||||||
|
'outputFormat' => 'PNG',
|
||||||
|
'width' => 1536,
|
||||||
|
'height' => 960,
|
||||||
|
'cost' => 0.0125,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'taskUUID' => $taskUuid,
|
||||||
|
'status' => 'completed',
|
||||||
|
'imageURL' => 'https://cdn.runware.ai/outputs/image-2.png',
|
||||||
|
'imageUUID' => 'image-uuid-2',
|
||||||
|
'outputFormat' => 'PNG',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
$provider = app(RunwareAiImageProvider::class);
|
||||||
|
$result = $provider->submit($request);
|
||||||
|
|
||||||
|
$this->assertSame('succeeded', $result->status);
|
||||||
|
$this->assertIsArray($capturedPayload);
|
||||||
|
$this->assertIsArray($capturedPayload[0] ?? null);
|
||||||
|
$this->assertSame('imageInference', $capturedPayload[0]['taskType'] ?? null);
|
||||||
|
$this->assertSame('runware:100@1', $capturedPayload[0]['model'] ?? null);
|
||||||
|
$this->assertSame('PNG', $capturedPayload[0]['outputFormat'] ?? null);
|
||||||
|
$this->assertSame('async', $capturedPayload[0]['deliveryMethod'] ?? null);
|
||||||
|
$this->assertSame(1536, $capturedPayload[0]['width'] ?? null);
|
||||||
|
$this->assertSame(960, $capturedPayload[0]['height'] ?? null);
|
||||||
|
$this->assertSame(32, $capturedPayload[0]['steps'] ?? null);
|
||||||
|
$this->assertEquals(5.5, $capturedPayload[0]['CFGScale'] ?? null);
|
||||||
|
$this->assertEquals(0.62, $capturedPayload[0]['strength'] ?? null);
|
||||||
|
$this->assertIsString($capturedPayload[0]['seedImage'] ?? null);
|
||||||
|
$this->assertMatchesRegularExpression('/^https?:\\/\\//', (string) ($capturedPayload[0]['seedImage'] ?? ''));
|
||||||
|
$this->assertSame(2, count($result->outputs));
|
||||||
|
$this->assertSame('image/png', $result->outputs[0]['mime_type'] ?? null);
|
||||||
|
$this->assertSame('image-uuid-1', $result->outputs[0]['provider_asset_id'] ?? null);
|
||||||
|
$this->assertSame(0.0125, $result->costUsd);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_poll_maps_top_level_runware_errors_to_failed_result(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'services.runware.api_key' => 'test-runware-key',
|
||||||
|
'services.runware.base_url' => 'https://api.runware.ai/v1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event = Event::factory()->create(['status' => 'published']);
|
||||||
|
$photo = Photo::factory()->for($event)->create([
|
||||||
|
'tenant_id' => $event->tenant_id,
|
||||||
|
'status' => 'approved',
|
||||||
|
]);
|
||||||
|
$style = AiStyle::query()->create([
|
||||||
|
'key' => 'provider-poll-style',
|
||||||
|
'name' => 'Provider Poll Style',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'provider_model' => 'runware:100@1',
|
||||||
|
'requires_source_image' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
$request = AiEditRequest::query()->create([
|
||||||
|
'tenant_id' => $event->tenant_id,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'photo_id' => $photo->id,
|
||||||
|
'style_id' => $style->id,
|
||||||
|
'provider' => 'runware',
|
||||||
|
'provider_model' => 'runware:100@1',
|
||||||
|
'status' => AiEditRequest::STATUS_PROCESSING,
|
||||||
|
'safety_state' => 'pending',
|
||||||
|
'prompt' => 'test prompt',
|
||||||
|
'idempotency_key' => 'runware-provider-test-2',
|
||||||
|
'queued_at' => now(),
|
||||||
|
'started_at' => now()->subSeconds(30),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.runware.ai/v1' => Http::response([
|
||||||
|
'errors' => [
|
||||||
|
[
|
||||||
|
'errorCode' => 'rate_limit_exceeded',
|
||||||
|
'message' => 'Rate limit exceeded',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 429),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$provider = app(RunwareAiImageProvider::class);
|
||||||
|
$result = $provider->poll($request, 'provider-task-123');
|
||||||
|
|
||||||
|
$this->assertSame('failed', $result->status);
|
||||||
|
$this->assertSame('rate_limit_exceeded', $result->failureCode);
|
||||||
|
$this->assertSame('Rate limit exceeded', $result->failureMessage);
|
||||||
|
$this->assertSame(429, $result->httpStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
tests/Unit/Services/RunwareModelSearchServiceTest.php
Normal file
79
tests/Unit/Services/RunwareModelSearchServiceTest.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use App\Services\AiEditing\RunwareModelSearchService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class RunwareModelSearchServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_searches_runware_models_and_formats_search_options(): void
|
||||||
|
{
|
||||||
|
Cache::flush();
|
||||||
|
config([
|
||||||
|
'services.runware.api_key' => 'test-runware-key',
|
||||||
|
'services.runware.base_url' => 'https://api.runware.ai/v1',
|
||||||
|
'ai-editing.providers.runware.model_search_min_chars' => 2,
|
||||||
|
'ai-editing.providers.runware.model_search_limit' => 25,
|
||||||
|
'ai-editing.providers.runware.model_search_cache_seconds' => 300,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.runware.ai/v1' => Http::response([
|
||||||
|
'data' => [
|
||||||
|
[
|
||||||
|
'taskType' => 'modelSearch',
|
||||||
|
'air' => 'runware:100@1',
|
||||||
|
'name' => 'Flux Portrait',
|
||||||
|
'architecture' => 'flux',
|
||||||
|
'category' => 'photo',
|
||||||
|
'defaultWidth' => 1216,
|
||||||
|
'defaultHeight' => 832,
|
||||||
|
'defaultSteps' => 30,
|
||||||
|
'defaultCFG' => 3.5,
|
||||||
|
'minWidth' => 768,
|
||||||
|
'maxWidth' => 2048,
|
||||||
|
'widthStep' => 64,
|
||||||
|
'minHeight' => 512,
|
||||||
|
'maxHeight' => 2048,
|
||||||
|
'heightStep' => 64,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(RunwareModelSearchService::class);
|
||||||
|
$options = $service->searchOptions('flux');
|
||||||
|
$model = $service->findByAir('runware:100@1');
|
||||||
|
$label = $service->labelForModel('runware:100@1');
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('runware:100@1', $options);
|
||||||
|
$this->assertStringContainsString('Flux Portrait', (string) $options['runware:100@1']);
|
||||||
|
$this->assertIsArray($model);
|
||||||
|
$this->assertSame(1216, $model['defaults']['width'] ?? null);
|
||||||
|
$this->assertSame(768, $model['constraints']['min_width'] ?? null);
|
||||||
|
$this->assertIsString($label);
|
||||||
|
$this->assertStringContainsString('runware:100@1', (string) $label);
|
||||||
|
|
||||||
|
$service->searchOptions('flux');
|
||||||
|
Http::assertSentCount(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_empty_results_when_runware_api_key_is_missing(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'services.runware.api_key' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(RunwareModelSearchService::class);
|
||||||
|
|
||||||
|
$this->assertSame([], $service->search('flux'));
|
||||||
|
$this->assertSame([], $service->searchOptions('flux'));
|
||||||
|
$this->assertNull($service->findByAir('runware:100@1'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user