diff --git a/.env.example b/.env.example index e8cceae..70b5559 100644 --- a/.env.example +++ b/.env.example @@ -94,3 +94,7 @@ KIT_MATTERMOST_HALLWAY=9sp18hgiaiybtj9p5nm8uw3sna KIT_IMAGINE_BASE=https://api.x.ai/v1 KIT_IMAGINE_MODEL=grok-imagine-image-2.0 KIT_IMAGINE_TIMEOUT=60 +SD_FORGE_URL=http://127.0.0.1:7860 +KIT_FORGE_URL=http://127.0.0.1:7860 +SD_FORGE_TIMEOUT=180 +KIT_FORGE_CHECKPOINT=juggernautXL_v9 diff --git a/app/Factory/VramOccupancy.php b/app/Factory/VramOccupancy.php new file mode 100644 index 0000000..fce14a7 --- /dev/null +++ b/app/Factory/VramOccupancy.php @@ -0,0 +1,58 @@ + $this->normalize($fake)]; + } + + if ($this->blenderRunning()) { + return ['gpu' => 'blender']; + } + + if ($this->forgeListening()) { + return ['gpu' => 'forge']; + } + + return ['gpu' => 'free']; + } + + private function normalize(string $raw): string + { + $gpu = strtolower(trim($raw)); + + return in_array($gpu, ['blender', 'forge', 'ollama', 'free'], true) ? $gpu : 'free'; + } + + private function blenderRunning(): bool + { + exec('pgrep -x blender', $out, $code); + + return $code === 0; + } + + private function forgeListening(): bool + { + $url = (string) config('kit.forge.url', 'http://127.0.0.1:7860'); + $host = (string) (parse_url($url, PHP_URL_HOST) ?: '127.0.0.1'); + $port = (int) (parse_url($url, PHP_URL_PORT) ?: 7860); + $fp = @fsockopen($host, $port, $errno, $errstr, 0.15); + if (! is_resource($fp)) { + return false; + } + fclose($fp); + + return true; + } +} diff --git a/app/Imaging/ForgeClient.php b/app/Imaging/ForgeClient.php new file mode 100644 index 0000000..f408e3e --- /dev/null +++ b/app/Imaging/ForgeClient.php @@ -0,0 +1,136 @@ +post('/sdapi/v1/txt2img', $this->payload($prompt, $aspect, $negative)); + } + + public function img2img(string $prompt, string $initBytes, string $aspect, float $denoise, string $negative = ''): string + { + if ($initBytes === '') { + throw new RuntimeException('img2img needs an init image'); + } + + return $this->post('/sdapi/v1/img2img', [ + ...$this->payload($prompt, $aspect, $negative), + 'init_images' => [base64_encode($initBytes)], + 'denoising_strength' => $denoise, + ]); + } + + /** + * @return list> + */ + public function listModels(): array + { + try { + $response = $this->http()->get($this->url('/sdapi/v1/sd-models')); + $json = $response->json(); + + return is_array($json) ? $json : []; + } catch (Throwable) { + return []; + } + } + + /** + * @return array{width: int, height: int} + */ + public function size(string $aspect): array + { + return match ($aspect) { + '16:9' => ['width' => 1344, 'height' => 768], + default => ['width' => 768, 'height' => 1024], + }; + } + + public function denoiseFor(string $path): float + { + return str_contains(strtolower(basename($path)), 'sheet') + ? self::DENOISE_FACE + : self::DENOISE_STYLE; + } + + /** + * @param array $body + */ + private function post(string $path, array $body): string + { + try { + $response = $this->http()->post($this->url($path), $body); + } catch (ConnectionException $e) { + throw new RuntimeException(self::DOWN, 0, $e); + } + + if (! $response->successful()) { + throw new RuntimeException('forge '.$path.' '.$response->status()); + } + + $b64 = $response->json('images.0'); + if (! is_string($b64) || $b64 === '') { + throw new RuntimeException('forge returned no images'); + } + + $bytes = base64_decode($b64, true); + if ($bytes === false || $bytes === '') { + throw new RuntimeException('forge b64 decode failed'); + } + + return $bytes; + } + + /** + * @return array + */ + private function payload(string $prompt, string $aspect, string $negative): array + { + $size = $this->size($aspect); + + return [ + 'prompt' => $prompt, + 'negative_prompt' => $negative, + 'width' => $size['width'], + 'height' => $size['height'], + 'steps' => 28, + 'cfg_scale' => 5.5, + 'sampler_name' => 'DPM++ 2M Karras', + 'seed' => -1, + 'do_not_save_samples' => true, + 'do_not_save_grid' => true, + 'override_settings_restore_afterwards' => false, + 'override_settings' => [ + 'sd_model_checkpoint' => self::CHECKPOINT, + ], + ]; + } + + private function http(): PendingRequest + { + $timeout = (int) config('kit.forge.timeout', 180); + + return Http::timeout($timeout)->connectTimeout(5)->acceptJson()->asJson(); + } + + private function url(string $path): string + { + return rtrim((string) config('kit.forge.url', 'http://127.0.0.1:7860'), '/').$path; + } +} diff --git a/app/Tools/ImagineStill.php b/app/Tools/ImagineStill.php index d3b8338..018d1ff 100644 --- a/app/Tools/ImagineStill.php +++ b/app/Tools/ImagineStill.php @@ -3,6 +3,8 @@ namespace App\Tools; use App\Factory\Board; +use App\Factory\VramOccupancy; +use App\Imaging\ForgeClient; use App\Imaging\ImagineClient; use App\Imaging\RefStore; use Illuminate\Contracts\JsonSchema\JsonSchema; @@ -17,7 +19,7 @@ public function description(): Stringable|string { return 'Edit a rider factory still onto bikes-v2 disk (_wip only). ' .'v1: catalog_id=rider, view=front|side|back|ride. ' - .'Reference-first Imagine edit (Jordan photos). No VRAM. ' + .'Default backend=imagine (Jordan photos, no VRAM). backend=forge is optional I4 (JuggernautXL_v9). ' .'Does not cut a GLB, HTTP Lexi, touch board id=rider, or overwrite sheet/hero. ' .'One stills call per turn. Do not pair with BlenderRun, LookCompare, or AskLexi.'; } @@ -29,12 +31,12 @@ public function schema(JsonSchema $schema): array 'view' => $schema->string()->description('front|side|back|ride')->required(), 'issue' => $schema->integer()->description('bikes-v2 SKU issue (12 for rider). Tool ticket is kit#5.')->required(), 'prompt' => $schema->string()->description('Camera/lighting only. Body lock is injected.'), - 'backend' => $schema->string()->description('imagine only in I2. forge is I4.'), + 'backend' => $schema->string()->description('imagine (default) | forge. forge uses localhost:7860 JuggernautXL_v9.'), 'mode' => $schema->string()->description('edit (default) | gen. gen on rider requires force_fresh.'), 'ref_paths' => $schema->array()->description('Optional extra filenames under tools/models/rider/refs/. Max 3 with the canonical.'), 'replace_canonical' => $schema->boolean()->description('v1 ignored / refuse. Always write _wip/.'), - 'model' => $schema->string()->description('grok-imagine-image-2.0 (default) | grok-imagine-image | grok-imagine-image-quality.'), - 'resolution' => $schema->string()->description('1k (default) | 2k. Lowercase tokens only.'), + 'model' => $schema->string()->description('imagine: grok-imagine-image-2.0 (default). forge: always juggernautXL_v9.'), + 'resolution' => $schema->string()->description('1k (default) | 2k. Lowercase tokens only. imagine only.'), 'aspect_ratio' => $schema->string()->description('Override. Default 3:4 portraits / 16:9 ride.'), 'force_fresh' => $schema->boolean()->description('Allow /generations on rider. Default false. Refuse unless true.'), ]; @@ -73,8 +75,11 @@ private function run(Request $request): string } $backend = strtolower(trim((string) ($request['backend'] ?? 'imagine'))); - if ($backend !== '' && $backend !== 'imagine') { - return 'I2 backend=imagine only (forge is I4)'; + if ($backend === '') { + $backend = 'imagine'; + } + if (! in_array($backend, ['imagine', 'forge'], true)) { + return 'backend must be imagine or forge'; } $mode = strtolower(trim((string) ($request['mode'] ?? 'edit'))); @@ -86,9 +91,18 @@ private function run(Request $request): string $mode = 'edit'; } - $client = app(ImagineClient::class); - $model = trim((string) ($request['model'] ?? '')) ?: (string) config('kit.imagine.default_model', 'grok-imagine-image-2.0'); - $resolution = $client->resolution((string) ($request['resolution'] ?? '1k')); + if ($backend === 'forge') { + $gpu = app(VramOccupancy::class)->status()['gpu'] ?? 'free'; + if ($gpu === 'blender') { + return 'blender in flight — retry forge stills after the cut (no HTTP)'; + } + } + + $imagine = app(ImagineClient::class); + $model = $backend === 'forge' + ? ForgeClient::CHECKPOINT + : (trim((string) ($request['model'] ?? '')) ?: (string) config('kit.imagine.default_model', 'grok-imagine-image-2.0')); + $resolution = $imagine->resolution((string) ($request['resolution'] ?? '1k')); $aspect = trim((string) ($request['aspect_ratio'] ?? '')) ?: $map['aspect']; $camera = trim((string) ($request['prompt'] ?? '')); $extras = $this->names($request['ref_paths'] ?? []); @@ -96,12 +110,14 @@ private function run(Request $request): string $prompt = $this->lockPrompt($view, count($paths), $camera); $before = $store->protectHashes(); - $bytes = $mode === 'gen' - ? $client->generate($prompt, $aspect, $resolution, $model) - : $client->edit($prompt, $paths, $aspect, $resolution, $model); + $bytes = $backend === 'forge' + ? $this->forgeBytes($mode, $prompt, $aspect, $paths) + : ($mode === 'gen' + ? $imagine->generate($prompt, $aspect, $resolution, $model) + : $imagine->edit($prompt, $paths, $aspect, $resolution, $model)); $written = $store->writeWip($view, $bytes, [ - 'backend' => 'imagine', + 'backend' => $backend, 'model' => $model, 'issue' => $issue, 'refs_used' => array_map('basename', $paths), @@ -124,13 +140,28 @@ private function run(Request $request): string 'path' => $written['path'], 'sha256' => $written['sha256'], 'bytes' => $written['bytes'], - 'backend' => 'imagine', + 'backend' => $backend, 'model' => $model, 'issue' => $issue, 'refs_used' => array_map('basename', $paths), ], JSON_UNESCAPED_SLASHES) ?: 'wrote still'; } + /** + * @param list $paths + */ + private function forgeBytes(string $mode, string $prompt, string $aspect, array $paths): string + { + $client = app(ForgeClient::class); + if ($mode === 'gen') { + return $client->txt2img($prompt, $aspect); + } + + $init = (string) file_get_contents($paths[0]); + + return $client->img2img($prompt, $init, $aspect, $client->denoiseFor($paths[0])); + } + private function lockPrompt(string $view, int $n, string $camera): string { $lock = "Same man, 6'4 320, flip-flops, bare feet, shorts, tee, no socks, no helmet. " diff --git a/config/kit.php b/config/kit.php index 43e42bf..5f3e73a 100644 --- a/config/kit.php +++ b/config/kit.php @@ -24,6 +24,16 @@ 'timeout' => (int) env('KIT_IMAGINE_TIMEOUT', 60), ], + 'forge' => [ + 'url' => env('SD_FORGE_URL', env('KIT_FORGE_URL', 'http://127.0.0.1:7860')), + 'timeout' => (int) env('SD_FORGE_TIMEOUT', 180), + 'checkpoint' => env('KIT_FORGE_CHECKPOINT', 'juggernautXL_v9'), + ], + + 'gpu' => [ + 'fake_status' => env('KIT_GPU_FAKE_STATUS'), + ], + 'mattermost' => [ 'url' => env('KIT_MATTERMOST_URL', 'http://100.68.122.24:8065'), 'token' => env('KIT_MATTERMOST_TOKEN', ''), diff --git a/phpunit.xml b/phpunit.xml index 58cd924..a195870 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -34,5 +34,7 @@ + + diff --git a/tests/Feature/ForgeClientTest.php b/tests/Feature/ForgeClientTest.php new file mode 100644 index 0000000..071df8c --- /dev/null +++ b/tests/Feature/ForgeClientTest.php @@ -0,0 +1,75 @@ + 'http://127.0.0.1:7860', + 'kit.forge.timeout' => 180, + 'kit.gpu.fake_status' => 'free', + ]); +}); + +test('txt2img decodes images0 and never sends another checkpoint', function () { + Http::fake([ + 'http://127.0.0.1:7860/sdapi/v1/txt2img' => function (Request $request) { + $body = $request->data(); + expect($body['do_not_save_samples'])->toBeTrue() + ->and($body['do_not_save_grid'])->toBeTrue() + ->and($body['override_settings_restore_afterwards'])->toBeFalse() + ->and($body['override_settings']['sd_model_checkpoint'])->toBe('juggernautXL_v9') + ->and($body['override_settings'])->toHaveCount(1) + ->and($body['width'])->toBe(1344) + ->and($body['height'])->toBe(768) + ->and($body['steps'])->toBe(28) + ->and($body['cfg_scale'])->toBe(5.5) + ->and($body['sampler_name'])->toBe('DPM++ 2M Karras'); + + return Http::response([ + 'images' => [base64_encode('PNGBYTES')], + ], 200); + }, + ]); + + $bytes = app(ForgeClient::class)->txt2img('a rider', '16:9'); + + expect($bytes)->toBe('PNGBYTES'); +}); + +test('img2img face sheet uses denoise 0.2', function () { + Http::fake([ + 'http://127.0.0.1:7860/sdapi/v1/img2img' => function (Request $request) { + expect($request->data()['denoising_strength'])->toBe(0.2); + + return Http::response([ + 'images' => [base64_encode('FACE')], + ], 200); + }, + ]); + + $client = app(ForgeClient::class); + $denoise = $client->denoiseFor('/tmp/jordan-sheet.png'); + $bytes = $client->img2img('same face', 'init', '3:4', $denoise); + + expect($denoise)->toBe(0.2)->and($bytes)->toBe('FACE'); +}); + +test('connection refused is the forge-down string', function () { + Http::fake(function () { + throw new ConnectionException('Connection refused'); + }); + + expect(fn () => app(ForgeClient::class)->txt2img('x', '3:4')) + ->toThrow(RuntimeException::class, ForgeClient::DOWN); +}); + +test('listModels returns empty on failure', function () { + Http::fake(function () { + throw new ConnectionException('down'); + }); + + expect(app(ForgeClient::class)->listModels())->toBe([]); +}); diff --git a/tests/Feature/ImagineStillTest.php b/tests/Feature/ImagineStillTest.php index 9103e1d..f39fdf1 100644 --- a/tests/Feature/ImagineStillTest.php +++ b/tests/Feature/ImagineStillTest.php @@ -4,6 +4,7 @@ use App\Factory\Board; use App\Tools\AskLexi; use App\Tools\ImagineStill; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; use Laravel\Ai\Tools\Request; @@ -45,6 +46,8 @@ function kitRiderTree(): string 'kit.board_path' => storage_path('framework/testing/board-'.uniqid('', true).'.json'), 'ai.providers.xai.key' => 'test-xai', 'kit.imagine.base' => 'https://api.x.ai/v1', + 'kit.forge.url' => 'http://127.0.0.1:7860', + 'kit.gpu.fake_status' => 'free', ]); app(Board::class)->upsert('rider', [ 'state' => 'm1', @@ -131,3 +134,80 @@ function kitRiderTree(): string $rider = collect($board['items'])->firstWhere('id', 'rider'); expect($rider['note'])->toContain('do not reopen clothes'); }); + +test('forge up img2img writes wip with juggernaut payload', function () { + config(['kit.gpu.fake_status' => 'forge']); + + Http::fake([ + 'http://127.0.0.1:7860/*' => function (Illuminate\Http\Client\Request $request) { + expect($request->url())->toEndWith('/sdapi/v1/img2img'); + $body = $request->data(); + expect($body['do_not_save_samples'])->toBeTrue() + ->and($body['do_not_save_grid'])->toBeTrue() + ->and($body['override_settings_restore_afterwards'])->toBeFalse() + ->and($body['override_settings']['sd_model_checkpoint'])->toBe('juggernautXL_v9') + ->and($body['override_settings'])->toHaveCount(1) + ->and($body['denoising_strength'])->toBe(0.35) + ->and($body['width'])->toBe(768) + ->and($body['height'])->toBe(1024) + ->and($body['steps'])->toBe(28) + ->and($body['cfg_scale'])->toBe(5.5) + ->and($body['sampler_name'])->toBe('DPM++ 2M Karras'); + + return Http::response([ + 'images' => [base64_encode(kitTinyJpeg())], + ], 200); + }, + 'https://api.x.ai/v1/*' => Http::response(['nope' => true], 500), + ]); + + $out = (string) (new ImagineStill)->handle(new Request([ + 'catalog_id' => 'rider', + 'view' => 'front', + 'issue' => 12, + 'backend' => 'forge', + ])); + + expect($out)->toContain('_wip/') + ->and($out)->toContain('"backend":"forge"') + ->and($out)->toContain('juggernautXL_v9'); + + $wip = $this->bikes.'/tools/models/rider/refs/_wip'; + expect(glob($wip.'/*.jpg'))->not->toBeEmpty(); + $sidecars = glob($wip.'/*.kit.json'); + expect($sidecars)->not->toBeEmpty(); + $meta = json_decode((string) file_get_contents($sidecars[0]), true); + expect($meta['backend'])->toBe('forge') + ->and($meta['model'])->toBe('juggernautXL_v9'); +}); + +test('blender busy refuses forge without HTTP', function () { + config(['kit.gpu.fake_status' => 'blender']); + Http::fake(); + + $out = (string) (new ImagineStill)->handle(new Request([ + 'catalog_id' => 'rider', + 'view' => 'front', + 'issue' => 12, + 'backend' => 'forge', + ])); + + expect($out)->toBe('blender in flight — retry forge stills after the cut (no HTTP)'); + Http::assertNothingSent(); +}); + +test('forge down surfaces 7860 string', function () { + config(['kit.gpu.fake_status' => 'free']); + Http::fake(function () { + throw new ConnectionException('Connection refused'); + }); + + $out = (string) (new ImagineStill)->handle(new Request([ + 'catalog_id' => 'rider', + 'view' => 'front', + 'issue' => 12, + 'backend' => 'forge', + ])); + + expect($out)->toContain('forge down (7860)'); +}); diff --git a/tests/Feature/VramOccupancyTest.php b/tests/Feature/VramOccupancyTest.php new file mode 100644 index 0000000..e78ec88 --- /dev/null +++ b/tests/Feature/VramOccupancyTest.php @@ -0,0 +1,22 @@ + $gpu]); + + expect(app(VramOccupancy::class)->status())->toBe(['gpu' => $gpu]); +})->with(['blender', 'forge', 'ollama', 'free']); + +test('unknown fake_status falls back to free', function () { + config(['kit.gpu.fake_status' => 'trellis']); + + expect(app(VramOccupancy::class)->status())->toBe(['gpu' => 'free']); +}); + +test('class has status and no beforeCut', function () { + $methods = get_class_methods(VramOccupancy::class); + + expect($methods)->toContain('status'); + expect($methods)->not->toContain('beforeCut'); +});