390 lines
13 KiB
PHP
390 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\AcademyPromptTemplate;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
final class GenerateAcademyPromptThumbnailsCommand extends Command
|
|
{
|
|
private const PROMPT_PREVIEW_PREFIX = 'academy-prompts/previews';
|
|
|
|
/**
|
|
* @var array<string, int>
|
|
*/
|
|
private const VARIANT_WIDTHS = [
|
|
'thumb' => 480,
|
|
'md' => 960,
|
|
];
|
|
|
|
private const PREVIEW_WEBP_QUALITY = 84;
|
|
|
|
private const LESSON_MEDIA_WEBP_QUALITY = 85;
|
|
|
|
protected $signature = 'academy:prompts:generate-missing-thumbnails
|
|
{--id=* : Restrict to one or more prompt IDs}
|
|
{--slug=* : Restrict to one or more prompt slugs}
|
|
{--limit= : Stop after processing this many prompts}
|
|
{--force : Regenerate variants even when they already exist}
|
|
{--dry-run : Report planned thumbnail work without writing files or saving prompt JSON}';
|
|
|
|
protected $description = 'Generate missing prompt preview and comparison thumbnails for existing Academy prompts';
|
|
|
|
public function handle(): int
|
|
{
|
|
if (! function_exists('imagecreatefromstring') || ! function_exists('imagewebp')) {
|
|
$this->error('GD WebP support is required to generate prompt thumbnails.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$ids = collect((array) $this->option('id'))
|
|
->map(static fn (mixed $id): int => (int) $id)
|
|
->filter(static fn (int $id): bool => $id > 0)
|
|
->values()
|
|
->all();
|
|
|
|
$slugs = collect((array) $this->option('slug'))
|
|
->map(static fn (mixed $slug): string => trim((string) $slug))
|
|
->filter(static fn (string $slug): bool => $slug !== '')
|
|
->values()
|
|
->all();
|
|
|
|
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
|
$force = (bool) $this->option('force');
|
|
$dryRun = (bool) $this->option('dry-run');
|
|
|
|
$query = AcademyPromptTemplate::query()
|
|
->select(['id', 'slug', 'title', 'preview_image', 'tool_notes'])
|
|
->orderBy('id');
|
|
|
|
if ($ids !== []) {
|
|
$query->whereIn('id', $ids);
|
|
}
|
|
|
|
if ($slugs !== []) {
|
|
$query->whereIn('slug', $slugs);
|
|
}
|
|
|
|
$processed = 0;
|
|
$changed = 0;
|
|
$generatedVariants = 0;
|
|
$plannedVariants = 0;
|
|
$skipped = 0;
|
|
$failed = 0;
|
|
|
|
$query->chunkById(100, function ($prompts) use ($limit, $force, $dryRun, &$processed, &$changed, &$generatedVariants, &$plannedVariants, &$skipped, &$failed) {
|
|
foreach ($prompts as $prompt) {
|
|
if ($limit !== null && $processed >= $limit) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$result = $this->backfillPrompt($prompt, $force, $dryRun);
|
|
|
|
$generatedVariants += (int) ($result['generated_variants'] ?? 0);
|
|
$plannedVariants += (int) ($result['planned_variants'] ?? 0);
|
|
|
|
if (($result['changed'] ?? false) === true) {
|
|
$changed++;
|
|
} else {
|
|
$skipped++;
|
|
}
|
|
} catch (Throwable $e) {
|
|
$failed++;
|
|
$this->warn(sprintf('Prompt %d (%s) failed: %s', (int) $prompt->id, (string) $prompt->slug, $e->getMessage()));
|
|
}
|
|
|
|
$processed++;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
$this->info(sprintf(
|
|
'Prompt thumbnail backfill complete. processed=%d changed=%d generated_variants=%d planned_variants=%d skipped=%d failed=%d',
|
|
$processed,
|
|
$changed,
|
|
$generatedVariants,
|
|
$plannedVariants,
|
|
$skipped,
|
|
$failed,
|
|
));
|
|
|
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* @return array{changed:bool,generated_variants:int,planned_variants:int}
|
|
*/
|
|
private function backfillPrompt(AcademyPromptTemplate $prompt, bool $force, bool $dryRun): array
|
|
{
|
|
$generatedVariants = 0;
|
|
$plannedVariants = 0;
|
|
$changed = false;
|
|
|
|
$previewResult = $this->ensureManagedImageVariants((string) ($prompt->preview_image ?? ''), $force, $dryRun);
|
|
$generatedVariants += $previewResult['generated_variants'];
|
|
$plannedVariants += $previewResult['planned_variants'];
|
|
$changed = $changed || $previewResult['changed'];
|
|
|
|
$notes = is_array($prompt->tool_notes) ? $prompt->tool_notes : [];
|
|
$nextNotes = [];
|
|
|
|
foreach ($notes as $note) {
|
|
if (! is_array($note)) {
|
|
$nextNotes[] = $note;
|
|
continue;
|
|
}
|
|
|
|
$noteResult = $this->ensurePromptComparisonNoteVariants($note, $force, $dryRun);
|
|
$generatedVariants += $noteResult['generated_variants'];
|
|
$plannedVariants += $noteResult['planned_variants'];
|
|
$changed = $changed || $noteResult['changed'];
|
|
$nextNotes[] = $noteResult['note'];
|
|
}
|
|
|
|
if ($changed && ! $dryRun && $nextNotes !== $notes) {
|
|
$prompt->forceFill([
|
|
'tool_notes' => $nextNotes,
|
|
])->save();
|
|
}
|
|
|
|
return [
|
|
'changed' => $changed,
|
|
'generated_variants' => $generatedVariants,
|
|
'planned_variants' => $plannedVariants,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $note
|
|
* @return array{note:array<string, mixed>,changed:bool,generated_variants:int,planned_variants:int}
|
|
*/
|
|
private function ensurePromptComparisonNoteVariants(array $note, bool $force, bool $dryRun): array
|
|
{
|
|
$imagePath = trim((string) ($note['image_path'] ?? ''));
|
|
|
|
if (! $this->isManagedLessonMediaPath($imagePath)) {
|
|
return [
|
|
'note' => $note,
|
|
'changed' => false,
|
|
'generated_variants' => 0,
|
|
'planned_variants' => 0,
|
|
];
|
|
}
|
|
|
|
$variants = $this->ensureManagedImageVariants($imagePath, $force, $dryRun);
|
|
$thumbPath = $variants['thumb_path'] ?? '';
|
|
|
|
if ($thumbPath === '') {
|
|
$thumbPath = $imagePath;
|
|
}
|
|
|
|
$nextNote = $note;
|
|
$currentThumbPath = trim((string) ($note['thumb_path'] ?? ''));
|
|
|
|
if ($currentThumbPath !== $thumbPath) {
|
|
$nextNote['thumb_path'] = $thumbPath;
|
|
$variants['changed'] = true;
|
|
}
|
|
|
|
return [
|
|
'note' => $nextNote,
|
|
'changed' => (bool) $variants['changed'],
|
|
'generated_variants' => (int) $variants['generated_variants'],
|
|
'planned_variants' => (int) $variants['planned_variants'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{thumb_path:string,changed:bool,generated_variants:int,planned_variants:int}
|
|
*/
|
|
private function ensureManagedImageVariants(string $path, bool $force, bool $dryRun): array
|
|
{
|
|
$path = trim($path);
|
|
|
|
if (! $this->isManagedPromptPreviewPath($path) && ! $this->isManagedLessonMediaPath($path)) {
|
|
return [
|
|
'thumb_path' => '',
|
|
'changed' => false,
|
|
'generated_variants' => 0,
|
|
'planned_variants' => 0,
|
|
];
|
|
}
|
|
|
|
$source = $this->openManagedImage($path);
|
|
|
|
try {
|
|
$generatedVariants = 0;
|
|
$plannedVariants = 0;
|
|
$changed = false;
|
|
$thumbPath = $path;
|
|
|
|
foreach (self::VARIANT_WIDTHS as $variant => $targetWidth) {
|
|
$status = $this->ensureVariantForWidth(
|
|
$source['image'],
|
|
$source['width'],
|
|
$source['height'],
|
|
$path,
|
|
$variant,
|
|
$targetWidth,
|
|
$force,
|
|
$dryRun,
|
|
);
|
|
|
|
if ($variant === 'thumb' && $source['width'] > $targetWidth) {
|
|
$thumbPath = $this->variantPath($path, 'thumb');
|
|
}
|
|
|
|
if ($status === 'generated') {
|
|
$generatedVariants++;
|
|
$changed = true;
|
|
}
|
|
|
|
if ($status === 'planned') {
|
|
$plannedVariants++;
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'thumb_path' => $thumbPath,
|
|
'changed' => $changed,
|
|
'generated_variants' => $generatedVariants,
|
|
'planned_variants' => $plannedVariants,
|
|
];
|
|
} finally {
|
|
imagedestroy($source['image']);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{image:\GdImage,width:int,height:int}
|
|
*/
|
|
private function openManagedImage(string $path): array
|
|
{
|
|
$disk = Storage::disk($this->storageDisk());
|
|
|
|
if (! $disk->exists($path)) {
|
|
throw new RuntimeException(sprintf('Source image is missing: %s', $path));
|
|
}
|
|
|
|
$binary = $disk->get($path);
|
|
|
|
if (! is_string($binary) || $binary === '') {
|
|
throw new RuntimeException(sprintf('Source image could not be read: %s', $path));
|
|
}
|
|
|
|
$image = @imagecreatefromstring($binary);
|
|
|
|
if (! $image instanceof \GdImage) {
|
|
throw new RuntimeException(sprintf('Source image is not a supported raster image: %s', $path));
|
|
}
|
|
|
|
if (! imageistruecolor($image)) {
|
|
imagepalettetotruecolor($image);
|
|
}
|
|
|
|
imagealphablending($image, true);
|
|
imagesavealpha($image, true);
|
|
|
|
return [
|
|
'image' => $image,
|
|
'width' => imagesx($image),
|
|
'height' => imagesy($image),
|
|
];
|
|
}
|
|
|
|
private function ensureVariantForWidth(\GdImage $source, int $sourceWidth, int $sourceHeight, string $sourcePath, string $variant, int $targetWidth, bool $force, bool $dryRun): string
|
|
{
|
|
if ($sourceWidth <= $targetWidth || $sourceWidth < 1 || $sourceHeight < 1) {
|
|
return 'skipped';
|
|
}
|
|
|
|
$variantPath = $this->variantPath($sourcePath, $variant);
|
|
$disk = Storage::disk($this->storageDisk());
|
|
|
|
if (! $force && $disk->exists($variantPath)) {
|
|
return 'skipped';
|
|
}
|
|
|
|
if ($dryRun) {
|
|
return 'planned';
|
|
}
|
|
|
|
$targetHeight = max(1, (int) round(($sourceHeight / $sourceWidth) * $targetWidth));
|
|
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
|
|
|
|
if (! $canvas instanceof \GdImage) {
|
|
throw new RuntimeException(sprintf('Could not allocate variant canvas for %s', $sourcePath));
|
|
}
|
|
|
|
imagealphablending($canvas, false);
|
|
imagesavealpha($canvas, true);
|
|
$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
|
|
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $transparent);
|
|
imagecopyresampled($canvas, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight);
|
|
|
|
try {
|
|
ob_start();
|
|
$converted = imagewebp($canvas, null, $this->qualityForPath($sourcePath));
|
|
$webpBinary = ob_get_clean();
|
|
|
|
if (! $converted || ! is_string($webpBinary) || $webpBinary === '') {
|
|
throw new RuntimeException(sprintf('Could not encode %s variant for %s', $variant, $sourcePath));
|
|
}
|
|
|
|
$disk->put($variantPath, $webpBinary, ['visibility' => 'public']);
|
|
} finally {
|
|
imagedestroy($canvas);
|
|
}
|
|
|
|
return 'generated';
|
|
}
|
|
|
|
private function variantPath(string $path, string $variant): string
|
|
{
|
|
$directory = pathinfo($path, PATHINFO_DIRNAME);
|
|
$filename = pathinfo($path, PATHINFO_FILENAME);
|
|
$baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;
|
|
|
|
return sprintf('%s/%s-%s.webp', $directory, $baseFilename, $variant);
|
|
}
|
|
|
|
private function isManagedPromptPreviewPath(string $path): bool
|
|
{
|
|
return $this->isLocalPath($path) && str_starts_with($path, self::PROMPT_PREVIEW_PREFIX . '/');
|
|
}
|
|
|
|
private function isManagedLessonMediaPath(string $path): bool
|
|
{
|
|
return $this->isLocalPath($path)
|
|
&& (str_starts_with($path, 'academy/lessons/body/') || str_starts_with($path, 'academy/lessons/covers/'));
|
|
}
|
|
|
|
private function isLocalPath(string $path): bool
|
|
{
|
|
return $path !== ''
|
|
&& ! str_starts_with($path, 'http://')
|
|
&& ! str_starts_with($path, 'https://')
|
|
&& ! str_starts_with($path, '/');
|
|
}
|
|
|
|
private function storageDisk(): string
|
|
{
|
|
return (string) config('uploads.object_storage.disk', 's3');
|
|
}
|
|
|
|
private function qualityForPath(string $path): int
|
|
{
|
|
return $this->isManagedPromptPreviewPath($path)
|
|
? self::PREVIEW_WEBP_QUALITY
|
|
: self::LESSON_MEDIA_WEBP_QUALITY;
|
|
}
|
|
} |