feat(products): generate thumbnails with OpenAI
This commit is contained in:
parent
ad7d40b308
commit
7d96e8f0f9
2 changed files with 117 additions and 0 deletions
|
|
@ -35,4 +35,8 @@ return [
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'openai' => [
|
||||||
|
'api_key' => env('OPENAI_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,116 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
Artisan::command('products:generate-thumbnails {--force : Regenerate even if thumbnail_path already exists}', function (): int {
|
||||||
|
$disk = Storage::disk('images');
|
||||||
|
$directory = 'products/thumbnails';
|
||||||
|
$openAiApiKey = (string) config('services.openai.api_key', '');
|
||||||
|
|
||||||
|
if ($openAiApiKey === '') {
|
||||||
|
$this->error('Missing OpenAI API key. Set OPENAI_API_KEY in your environment.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$categoryLabels = [
|
||||||
|
'computers-laptop' => 'laptop computer',
|
||||||
|
'computers-server' => 'rack server hardware',
|
||||||
|
'components-motherboard' => 'computer motherboard',
|
||||||
|
'components-graphicscard' => 'graphics card',
|
||||||
|
'components-memory' => 'RAM memory sticks',
|
||||||
|
'components-harddrives-ssd' => 'NVMe SSD drive',
|
||||||
|
'components-harddrives-mechanical' => 'mechanical hard disk drive',
|
||||||
|
'accessories-keyboard' => 'computer keyboard',
|
||||||
|
'accessories-mouse' => 'computer mouse',
|
||||||
|
'accessories-streaming' => 'streaming equipment',
|
||||||
|
'accessories-bags' => 'laptop backpack',
|
||||||
|
'monitor-gaming-monitors' => 'gaming monitor',
|
||||||
|
'monitor-mounting' => 'monitor arm mount',
|
||||||
|
'monitor-accessories' => 'monitor desk accessory',
|
||||||
|
'components-soundcard' => 'USB audio interface',
|
||||||
|
'components-tools' => 'PC repair tools',
|
||||||
|
];
|
||||||
|
|
||||||
|
$products = Product::query()
|
||||||
|
->with('category:id,slug')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$updated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$categorySlug = $product->category?->slug;
|
||||||
|
|
||||||
|
if (! is_string($categorySlug) || ! isset($categoryLabels[$categorySlug])) {
|
||||||
|
$failed++;
|
||||||
|
$this->warn("No category image mapping for product #{$product->id} ({$product->sku})");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = Str::slug($product->sku).'.jpg';
|
||||||
|
$path = $directory.'/'.$filename;
|
||||||
|
|
||||||
|
if (! $this->option('force') && is_string($product->thumbnail_path) && $product->thumbnail_path !== '' && $disk->exists($product->thumbnail_path)) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prompt = sprintf(
|
||||||
|
'Photorealistic product photo of %s for an ecommerce thumbnail. Product: %s. Clean studio lighting, centered subject, neutral background, no text, no logos, no watermark.',
|
||||||
|
$categoryLabels[$categorySlug],
|
||||||
|
$product->name,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var \Illuminate\Http\Client\Response $response */
|
||||||
|
$response = Http::timeout(120)
|
||||||
|
->withToken($openAiApiKey)
|
||||||
|
->post('https://api.openai.com/v1/images/generations', [
|
||||||
|
'model' => 'gpt-image-1',
|
||||||
|
'prompt' => $prompt,
|
||||||
|
'size' => '1024x1024',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
$failed++;
|
||||||
|
$error = $response->json('error.message');
|
||||||
|
$status = $response->status();
|
||||||
|
$message = is_string($error) && $error !== '' ? $error : $response->body();
|
||||||
|
$this->error("Image generation failed for {$product->sku} ({$categorySlug}) [HTTP {$status}]: {$message}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $response->json();
|
||||||
|
$encodedImage = is_array($payload)
|
||||||
|
? data_get($payload, 'data.0.b64_json')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (! is_string($encodedImage) || $encodedImage === '') {
|
||||||
|
$failed++;
|
||||||
|
$this->error("Image payload missing for {$product->sku}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$binaryImage = base64_decode($encodedImage, true);
|
||||||
|
|
||||||
|
if (! is_string($binaryImage) || $binaryImage === '') {
|
||||||
|
$failed++;
|
||||||
|
$this->error("Image decode failed for {$product->sku}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$disk->put($path, $binaryImage);
|
||||||
|
$product->thumbnail_path = $path;
|
||||||
|
$product->save();
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Completed: updated={$updated}, skipped={$skipped}, failed={$failed}");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
})->purpose('Generate category-based product thumbnails into image storage');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue