*/ 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 $note * @return array{note:array,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; } }