Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
58 changes: 58 additions & 0 deletions app/Factory/VramOccupancy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

namespace App\Factory;

class VramOccupancy
{
/**
* Probe occupancy. Stub with config kit.gpu.fake_status in tests.
* Never stops Forge. No beforeCut.
*
* @return array{gpu: 'blender'|'forge'|'ollama'|'free'}
*/
public function status(): array
{
$fake = config('kit.gpu.fake_status');
if (is_string($fake) && $fake !== '') {
return ['gpu' => $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;
}
}
136 changes: 136 additions & 0 deletions app/Imaging/ForgeClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

namespace App\Imaging;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;

class ForgeClient
{
public const CHECKPOINT = 'juggernautXL_v9';

public const DENOISE_FACE = 0.2;

public const DENOISE_STYLE = 0.35;

public const DOWN = 'forge down (7860). Use backend=imagine.';

public function txt2img(string $prompt, string $aspect, string $negative = ''): string
{
return $this->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<array<string, mixed>>
*/
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<string, mixed> $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<string, mixed>
*/
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;
}
}
59 changes: 45 additions & 14 deletions app/Tools/ImagineStill.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.';
}
Expand All @@ -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.'),
];
Expand Down Expand Up @@ -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')));
Expand All @@ -86,22 +91,33 @@ 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'] ?? []);
$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);
$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),
Expand All @@ -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<string> $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. "
Expand Down
10 changes: 10 additions & 0 deletions config/kit.php
Original file line number Diff line number Diff line change
Expand Up @@ -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', ''),
Expand Down
2 changes: 2 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,7 @@
<env name="NIGHTWATCH_ENABLED" value="false"/>
<env name="APP_KEY" value="base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="/>
<env name="KIT_CATALOG_PATH" value="tests/fixtures/catalog.json"/>
<env name="KIT_GPU_POLICY" value="ignore"/>
<env name="KIT_GPU_FAKE_STATUS" value="free"/>
</php>
</phpunit>
Loading
Loading