feat(ai): add runware model search and model-constrained img2img
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
tests / ui (push) Waiting to run

This commit is contained in:
Codex Agent
2026-02-07 21:16:28 +01:00
parent c0c082975e
commit 6cc463fc70
8 changed files with 1271 additions and 49 deletions

View 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);
}
}

View 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'));
}
}