feat(ai): add runware model search and model-constrained img2img
This commit is contained in:
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