diff --git a/.env.example b/.env.example index f7e8c8d..e8cceae 100644 --- a/.env.example +++ b/.env.example @@ -91,3 +91,6 @@ KIT_MATTERMOST_TEAM=dhoh8n1xkjfgzy7s63ufckb9ko KIT_MATTERMOST_CHANNEL=3r1tnxhe5fgcmjxgrspm7316oc KIT_MATTERMOST_DM=3r1tnxhe5fgcmjxgrspm7316oc KIT_MATTERMOST_HALLWAY=9sp18hgiaiybtj9p5nm8uw3sna +KIT_IMAGINE_BASE=https://api.x.ai/v1 +KIT_IMAGINE_MODEL=grok-imagine-image-2.0 +KIT_IMAGINE_TIMEOUT=60 diff --git a/app/Agent/KitAgent.php b/app/Agent/KitAgent.php index 979b574..959db53 100644 --- a/app/Agent/KitAgent.php +++ b/app/Agent/KitAgent.php @@ -7,6 +7,7 @@ use App\Tools\CatalogRead; use App\Tools\ChannelCreate; use App\Tools\HeartbeatRead; +use App\Tools\ImagineStill; use App\Tools\LookReport; use App\Tools\MemorySearch; use App\Tools\MemoryStore; @@ -36,6 +37,7 @@ public function tools(): iterable new MemorySearch, new MemoryStore, new AskLexi, + new ImagineStill, ]; } } diff --git a/app/Imaging/ImagineClient.php b/app/Imaging/ImagineClient.php new file mode 100644 index 0000000..d22860d --- /dev/null +++ b/app/Imaging/ImagineClient.php @@ -0,0 +1,90 @@ + $paths + */ + public function edit(string $prompt, array $paths, string $aspect, string $resolution, string $model): string + { + if ($paths === []) { + throw new RuntimeException('edit needs at least one image'); + } + + $images = []; + foreach (array_slice($paths, 0, 3) as $path) { + $bin = (string) file_get_contents($path); + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $mime = $ext === 'png' ? 'image/png' : 'image/jpeg'; + $images[] = ['url' => 'data:'.$mime.';base64,'.base64_encode($bin)]; + } + + return $this->post('/images/edits', [ + 'model' => $model, + 'prompt' => $prompt, + 'images' => $images, + 'aspect_ratio' => $aspect, + 'resolution' => $this->resolution($resolution), + 'response_format' => 'b64_json', + ]); + } + + public function generate(string $prompt, string $aspect, string $resolution, string $model): string + { + return $this->post('/images/generations', [ + 'model' => $model, + 'prompt' => $prompt, + 'n' => 1, + 'aspect_ratio' => $aspect, + 'resolution' => $this->resolution($resolution), + 'response_format' => 'b64_json', + ]); + } + + /** + * @param array $body + */ + private function post(string $path, array $body): string + { + $key = (string) config('ai.providers.xai.key'); + if ($key === '') { + throw new RuntimeException('XAI_API_KEY empty'); + } + + $base = rtrim((string) config('kit.imagine.base', 'https://api.x.ai/v1'), '/'); + $timeout = (int) config('kit.imagine.timeout', 60); + $response = Http::withToken($key) + ->timeout($timeout) + ->acceptJson() + ->asJson() + ->post($base.$path, $body); + + if (! $response->successful()) { + throw new RuntimeException('imagine '.$path.' '.$response->status().' '.$response->body()); + } + + $b64 = $response->json('data.0.b64_json'); + if (! is_string($b64) || $b64 === '') { + throw new RuntimeException('imagine missing b64_json'); + } + + $bytes = base64_decode($b64, true); + if ($bytes === false || $bytes === '') { + throw new RuntimeException('imagine b64 decode failed'); + } + + return $bytes; + } + + public function resolution(string $raw): string + { + $n = strtolower(trim($raw)); + + return in_array($n, ['1k', '2k'], true) ? $n : '1k'; + } +} diff --git a/app/Imaging/RefStore.php b/app/Imaging/RefStore.php new file mode 100644 index 0000000..de3601b --- /dev/null +++ b/app/Imaging/RefStore.php @@ -0,0 +1,159 @@ + */ + public const VIEWS = ['front', 'side', 'back', 'ride']; + + /** @var list */ + public const PROTECTED = ['jordan-sheet.png', 'jordan-hero.jpg']; + + /** + * @return array{canonical: string, extras: list, aspect: string}|null + */ + public function map(string $view): ?array + { + return match ($view) { + 'front' => ['canonical' => 'jordan-front-flops.jpg', 'extras' => ['jordan-sheet.png', 'jordan-hero.jpg'], 'aspect' => '3:4'], + 'side' => ['canonical' => 'jordan-side-flops.jpg', 'extras' => ['jordan-sheet.png', 'jordan-hero.jpg'], 'aspect' => '3:4'], + 'back' => ['canonical' => 'jordan-back-flops.jpg', 'extras' => ['jordan-sheet.png', 'jordan-hero.jpg'], 'aspect' => '3:4'], + 'ride' => ['canonical' => 'jordan-ride-flops.jpg', 'extras' => ['jordan-sheet.png', 'jordan-hero.jpg'], 'aspect' => '16:9'], + default => null, + }; + } + + public function refsRoot(): string + { + return rtrim((string) config('kit.bikes_v2'), '/').'/tools/models/rider/refs'; + } + + public function wipDir(): string + { + return $this->refsRoot().'/_wip'; + } + + /** + * @param list $extraNames + * @return list + */ + public function resolve(string $view, array $extraNames = []): array + { + $map = $this->map($view); + if ($map === null) { + throw new RuntimeException('view must be front|side|back|ride'); + } + + $root = $this->refsRoot(); + $names = [$map['canonical']]; + $extras = $extraNames !== [] ? $extraNames : $map['extras']; + foreach ($extras as $name) { + $name = basename((string) $name); + if ($name === '' || $name === $map['canonical']) { + continue; + } + if (! $this->allowlisted($name)) { + throw new RuntimeException('ref not allowlisted: '.$name); + } + $names[] = $name; + if (count($names) >= 3) { + break; + } + } + + $paths = []; + foreach ($names as $name) { + $path = $root.'/'.$name; + if (! is_file($path)) { + throw new RuntimeException('missing ref '.$name); + } + $paths[] = $path; + } + + return $paths; + } + + public function allowlisted(string $name): bool + { + $name = basename($name); + + return (bool) preg_match('/^jordan-[a-z0-9-]+\.(jpg|jpeg|png)$/', $name); + } + + /** + * @return array + */ + public function protectHashes(): array + { + $hashes = []; + $roots = [ + $this->refsRoot(), + rtrim((string) config('kit.bikes_v2'), '/').'/public/models/rider/refs', + ]; + foreach ($roots as $root) { + foreach (self::PROTECTED as $name) { + $path = $root.'/'.$name; + if (is_file($path)) { + $hashes[$path] = hash_file('sha256', $path) ?: ''; + } + } + } + + return $hashes; + } + + /** + * @param array $before + */ + public function hashesUnchanged(array $before): bool + { + $after = $this->protectHashes(); + foreach ($before as $path => $hash) { + if (($after[$path] ?? '') !== $hash) { + return false; + } + } + + return true; + } + + /** + * @param array $meta + * @return array{path: string, sha256: string, sidecar: string, bytes: int} + */ + public function writeWip(string $view, string $bytes, array $meta): array + { + if ($bytes === '') { + throw new RuntimeException('empty still'); + } + $dir = $this->wipDir(); + if (! is_dir($dir) && ! mkdir($dir, 0755, true) && ! is_dir($dir)) { + throw new RuntimeException('cannot create '.$dir); + } + + $sha = hash('sha256', $bytes); + $name = now('America/Phoenix')->format('Ymd').'-'.$view.'-'.substr($sha, 0, 8).'.jpg'; + $path = $dir.'/'.$name; + $realDir = realpath($dir); + if ($realDir === false || ! str_starts_with($path, $realDir)) { + throw new RuntimeException('wip path escaped'); + } + if (file_put_contents($path, $bytes) === false) { + throw new RuntimeException('wip write failed'); + } + + $sidecar = substr($path, 0, -4).'.kit.json'; + $payload = array_merge($meta, [ + 'sha256' => $sha, + 'view' => $view, + 'created' => now('America/Phoenix')->toIso8601String(), + 'bytes' => strlen($bytes), + ]); + file_put_contents($sidecar, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n"); + + return ['path' => $path, 'sha256' => $sha, 'sidecar' => $sidecar, 'bytes' => strlen($bytes)]; + } +} diff --git a/app/Tools/ImagineStill.php b/app/Tools/ImagineStill.php new file mode 100644 index 0000000..d3b8338 --- /dev/null +++ b/app/Tools/ImagineStill.php @@ -0,0 +1,169 @@ + $schema->string()->description('Must be rider in v1.')->required(), + '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.'), + '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.'), + '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.'), + ]; + } + + public function handle(Request $request): Stringable|string + { + try { + return $this->run($request); + } catch (Throwable $e) { + return 'ImagineStill: '.$e->getMessage(); + } + } + + private function run(Request $request): string + { + $id = strtolower(trim((string) ($request['catalog_id'] ?? ''))); + if ($id !== 'rider') { + if (str_starts_with($id, 'logo')) { + return 'logos are SVG/code, not ImagineStill'; + } + + return 'v1 catalog_id must be rider (hero-ebike refs live under tools/models/bike/refs/; ranch-7620 has no tools refs tree)'; + } + + $view = strtolower(trim((string) ($request['view'] ?? ''))); + $store = app(RefStore::class); + $map = $store->map($view); + if ($map === null) { + return 'view must be front|side|back|ride'; + } + + $issue = (int) ($request['issue'] ?? 0); + if ($issue <= 0) { + return 'issue required (bikes-v2 #12 for rider)'; + } + + $backend = strtolower(trim((string) ($request['backend'] ?? 'imagine'))); + if ($backend !== '' && $backend !== 'imagine') { + return 'I2 backend=imagine only (forge is I4)'; + } + + $mode = strtolower(trim((string) ($request['mode'] ?? 'edit'))); + $force = (bool) ($request['force_fresh'] ?? false); + if ($mode === 'gen' && ! $force) { + return 'rider gen refused without force_fresh'; + } + if ($mode !== 'gen' || $force === false) { + $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')); + $aspect = trim((string) ($request['aspect_ratio'] ?? '')) ?: $map['aspect']; + $camera = trim((string) ($request['prompt'] ?? '')); + $extras = $this->names($request['ref_paths'] ?? []); + $paths = $store->resolve($view, $extras); + $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); + + $written = $store->writeWip($view, $bytes, [ + 'backend' => 'imagine', + 'model' => $model, + 'issue' => $issue, + 'refs_used' => array_map('basename', $paths), + ]); + + if (! $store->hashesUnchanged($before)) { + @unlink($written['path']); + @unlink($written['sidecar']); + + return 'aborted: sheet/hero hash changed'; + } + + app(Board::class)->upsert('rider-stills', [ + 'state' => $view, + 'issue' => $issue, + 'note' => 'stills #'.$issue.' '.$view.' wip='.basename($written['path']), + ]); + + return json_encode([ + 'path' => $written['path'], + 'sha256' => $written['sha256'], + 'bytes' => $written['bytes'], + 'backend' => 'imagine', + 'model' => $model, + 'issue' => $issue, + 'refs_used' => array_map('basename', $paths), + ], JSON_UNESCAPED_SLASHES) ?: 'wrote still'; + } + + 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. " + .ucfirst($view).', even studio light.'; + if ($n >= 2) { + $lock .= ' is the '.$view.' still — keep that pose, clothes, and camera.' + .' is the face sheet — keep that face (glasses, beard).'; + } + if ($n >= 3) { + $lock .= ' is the hero taste lock — tall Pixar-cartoon, modest gut, flops.'; + } + if ($camera !== '') { + $lock .= ' '.$camera; + } + + return $lock; + } + + /** + * @return list + */ + private function names(mixed $raw): array + { + if (! is_array($raw)) { + return []; + } + $out = []; + foreach ($raw as $name) { + if (is_string($name) && $name !== '') { + $out[] = $name; + } + } + + return $out; + } +} diff --git a/config/kit.php b/config/kit.php index 43417ee..43e42bf 100644 --- a/config/kit.php +++ b/config/kit.php @@ -18,6 +18,12 @@ 'peer_token' => env('KIT_PEER_TOKEN', ''), 'webhook_token' => env('KIT_WEBHOOK_TOKEN', ''), + 'imagine' => [ + 'base' => env('KIT_IMAGINE_BASE', 'https://api.x.ai/v1'), + 'default_model' => env('KIT_IMAGINE_MODEL', 'grok-imagine-image-2.0'), + 'timeout' => (int) env('KIT_IMAGINE_TIMEOUT', 60), + ], + 'mattermost' => [ 'url' => env('KIT_MATTERMOST_URL', 'http://100.68.122.24:8065'), 'token' => env('KIT_MATTERMOST_TOKEN', ''), diff --git a/prompts/hard-rules.md b/prompts/hard-rules.md index b519783..97fe0da 100644 --- a/prompts/hard-rules.md +++ b/prompts/hard-rules.md @@ -6,3 +6,4 @@ - Rider is M1-locked unless Jordan reopens clothes. Next mesh is hero-ebike. - You cannot set your own Mattermost avatar (bot 403). Ask Lexi `SetProfilePhoto`. - Sibling on Loki. Not a citizen inside Lexi's process. lexi#699 stays closed. +- One of ImagineStill / BlenderRun / LookCompare per turn. Do not pair ImagineStill with AskLexi. v1 ImagineStill is rider only. Never HTTP Lexi GenerateImage. diff --git a/tests/Feature/ImagineStillTest.php b/tests/Feature/ImagineStillTest.php new file mode 100644 index 0000000..9103e1d --- /dev/null +++ b/tests/Feature/ImagineStillTest.php @@ -0,0 +1,133 @@ +bikes = kitRiderTree(); + config([ + 'kit.bikes_v2' => $this->bikes, + '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', + ]); + app(Board::class)->upsert('rider', [ + 'state' => 'm1', + 'note' => 'look-compare locked. do not reopen clothes.', + ]); +}); + +afterEach(function () { + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->bikes, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($it as $file) { + $file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname()); + } + @rmdir($this->bikes); + @unlink((string) config('kit.board_path')); +}); + +test('kit carries ImagineStill and AskLexi', function () { + $classes = array_map(fn ($tool) => $tool::class, iterator_to_array(app(KitAgent::class)->tools(), false)); + + expect($classes)->toContain(ImagineStill::class)->toContain(AskLexi::class); +}); + +test('unknown catalog id errors', function () { + $out = (string) (new ImagineStill)->handle(new Request([ + 'catalog_id' => 'hero-ebike', + 'view' => 'front', + 'issue' => 12, + ])); + + expect($out)->toContain('v1 catalog_id must be rider'); +}); + +test('rider without force_fresh does not call generations', function () { + Http::fake([ + 'https://api.x.ai/v1/images/edits' => Http::response([ + 'data' => [['b64_json' => base64_encode(kitTinyJpeg())]], + ], 200), + 'https://api.x.ai/v1/images/generations' => Http::response(['nope' => true], 500), + ]); + + $out = (string) (new ImagineStill)->handle(new Request([ + 'catalog_id' => 'rider', + 'view' => 'front', + 'issue' => 12, + 'mode' => 'gen', + ])); + + expect($out)->toContain('gen refused'); + Http::assertNothingSent(); +}); + +test('three-ref edit writes wip and cites IMAGE_1', function () { + Http::fake(function (Illuminate\Http\Client\Request $request) { + expect($request->url())->toEndWith('/images/edits'); + $body = $request->data(); + expect($body['prompt'])->toContain('') + ->and($body['images'])->toHaveCount(3) + ->and($body['images'][0])->toHaveKey('url') + ->and($body['resolution'])->toBe('1k') + ->and($body)->not->toHaveKey('image'); + + return Http::response([ + 'data' => [['b64_json' => base64_encode(kitTinyJpeg())]], + ], 200); + }); + + $out = (string) (new ImagineStill)->handle(new Request([ + 'catalog_id' => 'rider', + 'view' => 'front', + 'issue' => 12, + 'prompt' => 'soft window light', + ])); + + expect($out)->toContain('_wip/')->toContain('"issue":12'); + $wip = $this->bikes.'/tools/models/rider/refs/_wip'; + expect(glob($wip.'/*.jpg'))->not->toBeEmpty(); + + $board = app(Board::class)->read(); + $ids = array_column($board['items'], 'id'); + expect($ids)->toContain('rider-stills')->toContain('rider'); + $rider = collect($board['items'])->firstWhere('id', 'rider'); + expect($rider['note'])->toContain('do not reopen clothes'); +});