Implement academy analytics, billing, and web stories updates

This commit is contained in:
2026-05-26 07:27:29 +02:00
parent 456c3d6bb0
commit 0b33a1b074
177 changed files with 27360 additions and 2685 deletions

View File

@@ -0,0 +1,390 @@
<?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;
}
}