From da52d35be0b195800e9b3340da797582e8fc1206 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sun, 22 Feb 2026 07:29:14 -0500 Subject: [PATCH 1/2] feat(game): add screenshot media library infrastructure --- app/Filament/Resources/GameResource.php | 82 +--- .../Resources/GameResource/Pages/Edit.php | 42 -- .../Resources/GameResource/Pages/Media.php | 273 +++++++++++ app/Models/Game.php | 74 ++- app/Models/GameScreenshot.php | 121 +++++ app/Models/System.php | 4 + .../Actions/AddGameScreenshotAction.php | 215 +++++++++ .../Actions/BuildGameShowPagePropsAction.php | 1 + app/Platform/Data/SystemData.php | 4 + app/Platform/Enums/GameScreenshotStatus.php | 17 + app/Platform/Enums/ScreenshotType.php | 12 + app/Platform/EventServiceProvider.php | 3 + .../Observers/GameScreenshotObserver.php | 70 +++ app/Policies/GamePolicy.php | 8 + .../Media/CreateLegacyScreenshotPngAction.php | 76 +++ config/media.php | 8 +- database/factories/GameScreenshotFactory.php | 79 +++ ...0_000000_create_game_screenshots_table.php | 42 ++ ...stems_table_add_screenshot_resolutions.php | 456 ++++++++++++++++++ .../PlayableMainMedia.test.tsx | 40 ++ .../PlayableMainMedia/PlayableMainMedia.tsx | 29 +- .../+show-mobile/GameShowMobileRoot.tsx | 2 + .../components/+show/GameShowMainRoot.tsx | 2 + resources/js/types/generated.d.ts | 5 +- .../Actions/AddGameScreenshotActionTest.php | 277 +++++++++++ .../Observers/GameScreenshotObserverTest.php | 121 +++++ 26 files changed, 1935 insertions(+), 128 deletions(-) create mode 100644 app/Filament/Resources/GameResource/Pages/Media.php create mode 100644 app/Models/GameScreenshot.php create mode 100644 app/Platform/Actions/AddGameScreenshotAction.php create mode 100644 app/Platform/Enums/GameScreenshotStatus.php create mode 100644 app/Platform/Enums/ScreenshotType.php create mode 100644 app/Platform/Observers/GameScreenshotObserver.php create mode 100644 app/Support/Media/CreateLegacyScreenshotPngAction.php create mode 100644 database/factories/GameScreenshotFactory.php create mode 100644 database/migrations/2026_02_20_000000_create_game_screenshots_table.php create mode 100644 database/migrations/2026_02_20_000001_update_systems_table_add_screenshot_resolutions.php create mode 100644 tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php create mode 100644 tests/Feature/Platform/Observers/GameScreenshotObserverTest.php diff --git a/app/Filament/Resources/GameResource.php b/app/Filament/Resources/GameResource.php index 55262471aa..f34ca816a8 100644 --- a/app/Filament/Resources/GameResource.php +++ b/app/Filament/Resources/GameResource.php @@ -17,8 +17,6 @@ use App\Models\Game; use App\Models\System; use App\Models\User; -use App\Rules\DisallowAnimatedImageRule; -use App\Rules\UploadedImageAspectRatioRule; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -292,84 +290,6 @@ public static function form(Schema $schema): Schema ->disabled(!$user->can('updateField', [$schema->model, 'legacy_guide_url'])), ]), - Schemas\Components\Section::make('Media') - ->icon('heroicon-s-photo') - ->hidden( - !$user->can('updateField', [$schema->model, 'image_icon_asset_path']) - && !$user->can('updateField', [$schema->model, 'image_box_art_asset_path']) - && !$user->can('updateField', [$schema->model, 'image_title_asset_path']) - && !$user->can('updateField', [$schema->model, 'image_ingame_asset_path']) - ) - ->schema([ - // Store a temporary file on disk until the user submits. - // When the user submits, put in storage. - Forms\Components\FileUpload::make('image_icon_asset_path') - ->label('Badge') - ->disk('livewire-tmp') // Use Livewire's self-cleaning temporary disk - ->image() - ->rules([ - 'dimensions:width=96,height=96', - ]) - ->acceptedFileTypes(['image/png', 'image/jpeg']) - ->maxSize(1024) - ->maxFiles(1) - ->previewable(true) - ->hidden(!$user->can('updateField', [$schema->model, 'image_icon_asset_path'])), - - Forms\Components\FileUpload::make('image_box_art_asset_path') - ->label('Box Art') - ->disk('livewire-tmp') // Use Livewire's self-cleaning temporary disk - ->image() - ->acceptedFileTypes(['image/png', 'image/jpeg']) - ->maxSize(1024) - ->maxFiles(1) - ->previewable(true) - ->hidden(!$user->can('updateField', [$schema->model, 'image_box_art_asset_path'])), - - Forms\Components\FileUpload::make('image_title_asset_path') - ->label('Title') - ->disk('livewire-tmp') // Use Livewire's self-cleaning temporary disk - ->image() - ->acceptedFileTypes(['image/png', 'image/jpeg']) - ->maxSize(1024) - ->maxFiles(1) - ->previewable(true) - ->hidden(!$user->can('updateField', [$schema->model, 'image_title_asset_path'])), - - Forms\Components\FileUpload::make('image_ingame_asset_path') - ->label('In Game') - ->disk('livewire-tmp') // Use Livewire's self-cleaning temporary disk - ->image() - ->acceptedFileTypes(['image/png', 'image/jpeg']) - ->maxSize(1024) - ->maxFiles(1) - ->previewable(true) - ->hidden(!$user->can('updateField', [$schema->model, 'image_ingame_asset_path'])), - - Forms\Components\SpatieMediaLibraryFileUpload::make('banner') - ->label('Banner Image') - ->collection('banner') - ->conversion('desktop-xl-webp') - ->disk('s3') - ->visibility('public') - ->image() - ->rules([ - 'dimensions:min_width=1920,min_height=540', - new UploadedImageAspectRatioRule(32 / 9, 0.15), // 32:9 aspect ratio with a ±15% tolerance. - new DisallowAnimatedImageRule(), - ]) - ->acceptedFileTypes(['image/png', 'image/jpeg', 'image/webp']) - ->maxSize(5120) - ->maxFiles(1) - ->customProperties(['is_current' => true]) - ->filterMediaUsing(fn ($media) => $media->where('custom_properties.is_current', true)) - ->helperText(new HtmlString('Read: banner rules and guidelines. Upload a high-quality 32:9 ultra-wide banner image (minimum: 1920x540, recommended: 3200x900). The image must be approximately 32:9 aspect ratio (±15% tolerance). The image will be processed to multiple sizes for mobile and desktop. Your image should not include text of any kind.')) - ->previewable(true) - ->downloadable(false) - ->hidden(!$user->can('updateField', [$schema->model, 'banner'])), - ]) - ->columns(2), - Schemas\Components\Section::make('Rich Presence') ->icon('heroicon-s-chat-bubble-left-right') ->schema([ @@ -704,6 +624,7 @@ public static function getRecordSubNavigation(Page $page): array { return $page->generateNavigationItems([ Pages\Details::class, + Pages\Media::class, Pages\Hubs::class, Pages\SimilarGames::class, Pages\Hashes::class, @@ -717,6 +638,7 @@ public static function getPages(): array 'index' => Pages\Index::route('/'), 'view' => Pages\Details::route('/{record}'), 'edit' => Pages\Edit::route('/{record}/edit'), + 'media' => Pages\Media::route('/{record}/media'), 'hubs' => Pages\Hubs::route('/{record}/hubs'), 'similar-games' => Pages\SimilarGames::route('/{record}/similar-games'), 'hashes' => Pages\Hashes::route('/{record}/hashes'), diff --git a/app/Filament/Resources/GameResource/Pages/Edit.php b/app/Filament/Resources/GameResource/Pages/Edit.php index 0279cf30fd..8c2b49f594 100644 --- a/app/Filament/Resources/GameResource/Pages/Edit.php +++ b/app/Filament/Resources/GameResource/Pages/Edit.php @@ -5,19 +5,14 @@ namespace App\Filament\Resources\GameResource\Pages; use App\Connect\Actions\SubmitRichPresenceAction; -use App\Filament\Actions\ApplyUploadedImageToDataAction; use App\Filament\Actions\ViewOnSiteAction; use App\Filament\Concerns\HasFieldLevelAuthorization; -use App\Filament\Enums\ImageUploadType; use App\Filament\Resources\GameResource; use App\Models\Game; use App\Models\User; -use App\Support\MediaLibrary\Actions\ExtractBannerEdgeColorsAction; -use Exception; use Filament\Resources\Pages\EditRecord; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Storage; class Edit extends EditRecord { @@ -57,13 +52,6 @@ protected function mutateFormDataBeforeSave(array $data): array } } - $action = new ApplyUploadedImageToDataAction(); - - $action->execute($data, 'image_icon_asset_path', ImageUploadType::GameBadge); - $action->execute($data, 'image_title_asset_path', ImageUploadType::GameTitle); - $action->execute($data, 'image_ingame_asset_path', ImageUploadType::GameInGame); - $action->execute($data, 'image_box_art_asset_path', ImageUploadType::GameBoxArt); - // Handle trigger_definition separately to ensure trigger versioning is captured. if (array_key_exists('trigger_definition', $data)) { /** @var User $user */ @@ -79,9 +67,6 @@ protected function mutateFormDataBeforeSave(array $data): array unset($data['trigger_definition']); } - // Remove banner from the data array - it's handled by MediaLibrary, not a database column. - unset($data['banner']); - return $data; } @@ -89,32 +74,5 @@ protected function afterSave(): void { $this->record->refresh(); $this->refreshFormData(['sort_title']); - - /** @var Game $game */ - $game = $this->record; - - $banner = $game->current_banner_media; - - // Extract and store edge colors. - if ($banner && !$banner->getCustomProperty('left_edge_color')) { - try { - $action = new ExtractBannerEdgeColorsAction(); - - $fileContents = Storage::disk('s3')->get($banner->getPath()); - $extension = $banner->extension; - $tempPath = tempnam(sys_get_temp_dir(), 'banner-') . '.' . $extension; - file_put_contents($tempPath, $fileContents); - - $colors = $action->execute($tempPath); - - unlink($tempPath); - - $banner->setCustomProperty('left_edge_color', $colors['left_edge_color']); - $banner->setCustomProperty('right_edge_color', $colors['right_edge_color']); - $banner->save(); - } catch (Exception $e) { - // Silently fail if color extraction fails - this isn't critical. - } - } } } diff --git a/app/Filament/Resources/GameResource/Pages/Media.php b/app/Filament/Resources/GameResource/Pages/Media.php new file mode 100644 index 0000000000..7e4fe13385 --- /dev/null +++ b/app/Filament/Resources/GameResource/Pages/Media.php @@ -0,0 +1,273 @@ +getRecord(); + + return "{$game->title} ({$game->system->name_short}) - Media"; + } + + public function getBreadcrumb(): string + { + return 'Media'; + } + + protected function getHeaderActions(): array + { + return [ + ViewOnSiteAction::make('view-on-site'), + ]; + } + + public function form(Schema $schema): Schema + { + /** @var User $user */ + $user = Auth::user(); + + return $schema + ->components([ + Schemas\Components\Section::make('Badge') + ->icon('heroicon-s-star') + ->schema([ + Forms\Components\FileUpload::make('image_icon_asset_path') + ->label('Badge') + ->disk('livewire-tmp') + ->image() + ->rules([ + 'dimensions:width=96,height=96', + ]) + ->acceptedFileTypes(['image/png', 'image/jpeg']) + ->maxSize(1024) + ->maxFiles(1) + ->previewable(true), + ]) + ->hidden(!$user->can('updateField', [$schema->model, 'image_icon_asset_path'])), + + Schemas\Components\Section::make('Banner Image') + ->icon('heroicon-s-photo') + ->schema([ + Forms\Components\SpatieMediaLibraryFileUpload::make('banner') + ->label('Banner Image') + ->collection('banner') + ->conversion('desktop-xl-webp') + ->disk('s3') + ->visibility('public') + ->image() + ->rules([ + 'dimensions:min_width=1920,min_height=540', + new UploadedImageAspectRatioRule(32 / 9, 0.15), + new DisallowAnimatedImageRule(), + ]) + ->acceptedFileTypes(['image/png', 'image/jpeg', 'image/webp']) + ->maxSize(5120) + ->maxFiles(1) + ->customProperties(['is_current' => true]) + ->filterMediaUsing(fn ($media) => $media->where('custom_properties.is_current', true)) + ->helperText(new HtmlString('Read: banner rules and guidelines. Upload a high-quality 32:9 ultra-wide banner image (minimum: 1920x540, recommended: 3200x900). The image must be approximately 32:9 aspect ratio (±15% tolerance). The image will be processed to multiple sizes for mobile and desktop. Your image should not include text of any kind.')) + ->previewable(true) + ->downloadable(false), + ]) + ->hidden(!$user->can('updateField', [$schema->model, 'banner'])), + + Schemas\Components\Section::make('Box Art') + ->icon('heroicon-s-rectangle-stack') + ->schema([ + Forms\Components\FileUpload::make('image_box_art_asset_path') + ->label('Box Art') + ->disk('livewire-tmp') + ->image() + ->acceptedFileTypes(['image/png', 'image/jpeg']) + ->maxSize(1024) + ->maxFiles(1) + ->previewable(true), + ]) + ->hidden(!$user->can('updateField', [$schema->model, 'image_box_art_asset_path'])), + + Schemas\Components\Section::make('Title Screenshot') + ->icon('heroicon-s-tv') + ->schema([ + Forms\Components\FileUpload::make('image_title_asset_path') + ->label('Title') + ->disk('livewire-tmp') + ->image() + ->acceptedFileTypes(['image/png', 'image/jpeg']) + ->maxSize(1024) + ->maxFiles(1) + ->previewable(true) + ->helperText($this->getScreenshotHelperText()), + ]) + ->hidden(!$user->can('updateField', [$schema->model, 'image_title_asset_path'])), + + Schemas\Components\Section::make('In-Game Screenshot') + ->icon('heroicon-s-camera') + ->schema([ + // We intentionally use a standard FileUpload instead of SpatieMediaLibraryFileUpload + // here. SpatieMediaLibraryFileUpload manages the media collection directly, which + // would bypass AddGameScreenshotAction. SHA1 dedup, cap enforcement, legacy PNG + // creation, and GameScreenshot record creation would only happen in a fragile + // afterSave() reconciliation. By using a standard FileUpload, the action is the + // single entry point for all validation and side effects. + Forms\Components\FileUpload::make('screenshot_uploads') + ->label('Upload New Screenshot') + ->disk('livewire-tmp') + ->image() + ->maxFiles(1) + ->maxSize(4096) + ->acceptedFileTypes(['image/png', 'image/jpeg', 'image/webp']) + ->rules([ + 'dimensions:min_width=64,min_height=64,max_width=1920,max_height=1080', + new DisallowAnimatedImageRule(), + ]) + ->previewable(true) + ->helperText($this->getScreenshotHelperText()), + ]) + ->hidden(!$user->can('updateField', [$schema->model, 'screenshots'])), + ]); + } + + protected function mutateFormDataBeforeSave(array $data): array + { + $this->authorizeFields($this->record, $data); + + $action = new ApplyUploadedImageToDataAction(); + $action->execute($data, 'image_icon_asset_path', ImageUploadType::GameBadge); + $action->execute($data, 'image_title_asset_path', ImageUploadType::GameTitle); + $action->execute($data, 'image_box_art_asset_path', ImageUploadType::GameBoxArt); + + // Banner is handled by MediaLibrary, not a database column. + unset($data['banner']); + + // Screenshots are processed in afterSave() via AddGameScreenshotAction. + unset($data['screenshot_uploads']); + + return $data; + } + + protected function afterSave(): void + { + /** @var Game $game */ + $game = $this->record; + + // Process new screenshot uploads through AddGameScreenshotAction. + $uploads = $this->data['screenshot_uploads'] ?? []; + if (!empty($uploads)) { + $addAction = new AddGameScreenshotAction(); + $failureMessages = []; + + foreach ($uploads as $upload) { + $filePath = storage_path('app/livewire-tmp/' . $upload); + if (!file_exists($filePath)) { + continue; + } + + $uploadedFile = new UploadedFile($filePath, basename($filePath), test: true); + + try { + // Always mark as primary so each new upload replaces the current + // ingame screenshot, matching pre-migration behavior where every + // upload became "the" screenshot. The action handles demoting the + // old primary automatically. + $addAction->execute($game, $uploadedFile, ScreenshotType::Ingame, isPrimary: true); + } catch (ValidationException $e) { + $failureMessages[] = collect($e->errors())->flatten()->first(); + } + } + + if (!empty($failureMessages)) { + Notification::make() + ->warning() + ->title('Some screenshots were not uploaded') + ->body(implode("\n", array_unique($failureMessages))) + ->send(); + } + } + + // Extract and store edge colors for newly uploaded banners. + $banner = $game->current_banner_media; + if ($banner && !$banner->getCustomProperty('left_edge_color')) { + try { + $action = new ExtractBannerEdgeColorsAction(); + + $fileContents = Storage::disk('s3')->get($banner->getPath()); + $extension = $banner->extension; + $tempPath = tempnam(sys_get_temp_dir(), 'banner-') . '.' . $extension; + file_put_contents($tempPath, $fileContents); + + $colors = $action->execute($tempPath); + + unlink($tempPath); + + $banner->setCustomProperty('left_edge_color', $colors['left_edge_color']); + $banner->setCustomProperty('right_edge_color', $colors['right_edge_color']); + $banner->save(); + } catch (Exception $e) { + // Silently fail if color extraction fails - this isn't critical. + } + } + } + + private function getScreenshotHelperText(): ?string + { + $system = $this->record?->system; + $resolutions = $system?->screenshot_resolutions; + if (empty($resolutions)) { + return null; + } + + $formatted = collect($resolutions) + ->map(fn (array $r) => "{$r['width']}x{$r['height']}") + ->join(', '); + + $label = count($resolutions) > 1 ? 'Accepted resolutions' : 'Expected resolution'; + + return "{$label} for {$system->name}: {$formatted}"; + } +} diff --git a/app/Models/Game.php b/app/Models/Game.php index fafb9b385e..1702576a16 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -16,8 +16,10 @@ use App\Platform\Contracts\HasVersionedTrigger; use App\Platform\Data\PageBannerData; use App\Platform\Enums\AchievementSetType; +use App\Platform\Enums\GameScreenshotStatus; use App\Platform\Enums\GameSetType; use App\Platform\Enums\ReleasedAtGranularity; +use App\Platform\Enums\ScreenshotType; use App\Support\Database\Eloquent\BaseModel; use Database\Factories\GameFactory; use Fico7489\Laravel\Pivot\Traits\PivotEventTrait; @@ -74,7 +76,6 @@ class Game extends BaseModel implements HasMedia, HasPermalink, HasVersionedTrig use SoftDeletes; // TODO migrate forum_topic_id to forumable morph - // TODO migrate image_*_asset_path columns to media library // TODO drop achievement_set_version_hash, migrate to achievement_sets protected $table = 'games'; @@ -360,6 +361,28 @@ public function registerMediaCollections(): void ->fit(Fit::Crop, 32, 9) ->performOnCollections('banner'); }); + + $this->addMediaCollection('screenshots') + ->useDisk('s3') + ->registerMediaConversions(function () { + $sizes = ['sm', 'md', 'lg']; + + foreach ($sizes as $size) { + $maxWidth = config("media.game.screenshot.{$size}.width"); + + $this->addMediaConversion("{$size}-webp") + ->format('webp') + ->fit(Fit::Max, $maxWidth, $maxWidth) + ->optimize() + ->performOnCollections('screenshots'); + + $this->addMediaConversion("{$size}-avif") + ->format('avif') + ->fit(Fit::Max, $maxWidth, $maxWidth) + ->optimize() + ->performOnCollections('screenshots'); + } + }); } // == search @@ -460,6 +483,25 @@ public function shouldBeSearchable(): bool // == actions + /** + * Syncs the legacy image_ingame_asset_path and image_title_asset_path columns + * from the primary GameScreenshot records. This keeps all existing API consumers + * working without changes. + */ + public function syncLegacyScreenshotFields(): void + { + $primaries = $this->gameScreenshots() + ->primary() + ->with('media') + ->get() + ->keyBy(fn (GameScreenshot $s) => $s->type->value); + + $this->updateQuietly([ + 'image_ingame_asset_path' => $primaries->get('ingame')?->media?->getCustomProperty('legacy_path') ?? '/Images/000002.png', + 'image_title_asset_path' => $primaries->get('title')?->media?->getCustomProperty('legacy_path') ?? '/Images/000002.png', + ]); + } + // == accessors public function getBadgeUrlAttribute(): string @@ -489,6 +531,28 @@ public function getImageIngameUrlAttribute(): string return media_asset($this->image_ingame_asset_path); } + /** + * Callers should ensure the relationship is loaded: Game::with('gameScreenshots.media'). + */ + public function getPrimaryScreenshot(ScreenshotType $type = ScreenshotType::Ingame): ?GameScreenshot + { + return $this->gameScreenshots + ->first(fn (GameScreenshot $s) => $s->type === $type && $s->is_primary); + } + + /** + * Callers should ensure the relationship is loaded: Game::with('gameScreenshots.media'). + * + * @return Collection + */ + public function getApprovedScreenshots(ScreenshotType $type = ScreenshotType::Ingame): Collection + { + return $this->gameScreenshots + ->filter(fn (GameScreenshot $s) => $s->type === $type && $s->status === GameScreenshotStatus::Approved) + ->sortBy('order_column') + ->values(); + } + public function getBannerAttribute(): PageBannerData { $currentBanner = $this->current_banner_media; @@ -856,6 +920,14 @@ public function gameAchievementSets(): HasMany return $this->hasMany(GameAchievementSet::class, 'game_id', 'id'); } + /** + * @return HasMany + */ + public function gameScreenshots(): HasMany + { + return $this->hasMany(GameScreenshot::class); + } + /** * @return HasMany */ diff --git a/app/Models/GameScreenshot.php b/app/Models/GameScreenshot.php new file mode 100644 index 0000000000..7a1644e316 --- /dev/null +++ b/app/Models/GameScreenshot.php @@ -0,0 +1,121 @@ + */ + use HasFactory; + use SortableTrait; + + protected $table = 'game_screenshots'; + + protected $fillable = [ + 'game_id', + 'media_id', + 'type', + 'is_primary', + 'status', + 'description', + 'captured_by_user_id', + 'reviewed_by_user_id', + 'reviewed_at', + ]; + + protected $casts = [ + 'type' => ScreenshotType::class, + 'status' => GameScreenshotStatus::class, + 'is_primary' => 'boolean', + 'reviewed_at' => 'datetime', + ]; + + /** @var array */ + public $sortable = [ + 'order_column_name' => 'order_column', + 'sort_when_creating' => true, + ]; + + protected static function newFactory(): GameScreenshotFactory + { + return GameScreenshotFactory::new(); + } + + // == accessors + + // == mutators + + // == relations + + /** + * @return BelongsTo + */ + public function game(): BelongsTo + { + return $this->belongsTo(Game::class); + } + + /** + * @return BelongsTo + */ + public function media(): BelongsTo + { + return $this->belongsTo(Media::class); + } + + /** + * @return BelongsTo + */ + public function capturedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'captured_by_user_id'); + } + + /** + * @return BelongsTo + */ + public function reviewedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by_user_id'); + } + + // == scopes + + /** + * @param Builder $query + * @return Builder + */ + public function scopeApproved(Builder $query): Builder + { + return $query->where('status', GameScreenshotStatus::Approved); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeOfType(Builder $query, ScreenshotType $type): Builder + { + return $query->where('type', $type); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopePrimary(Builder $query): Builder + { + return $query->where('is_primary', true); + } +} diff --git a/app/Models/System.php b/app/Models/System.php index e05241cb9a..bf3c1635bf 100644 --- a/app/Models/System.php +++ b/app/Models/System.php @@ -78,6 +78,8 @@ protected function getSlugSourceField(): string 'name', 'name_full', 'name_short', + 'screenshot_resolutions', + 'supports_resolution_scaling', 'manufacturer', 'order_column', 'active', @@ -94,6 +96,8 @@ protected function getSlugSourceField(): string protected $casts = [ 'active' => 'boolean', + 'screenshot_resolutions' => 'array', + 'supports_resolution_scaling' => 'boolean', ]; // == constants diff --git a/app/Platform/Actions/AddGameScreenshotAction.php b/app/Platform/Actions/AddGameScreenshotAction.php new file mode 100644 index 0000000000..8925e27b5b --- /dev/null +++ b/app/Platform/Actions/AddGameScreenshotAction.php @@ -0,0 +1,215 @@ +validateFile($file); + $this->validateResolution($file, $game); + $hash = $this->validateHash($file, $game); + $this->validateCap($game, $type); + + // Auto-promote to primary if explicitly requested or if no approved screenshots of this type exist yet. + $shouldBePrimary = $isPrimary || !$game->gameScreenshots() + ->ofType($type) + ->approved() + ->exists(); + + $legacyPath = null; + if ($shouldBePrimary) { + // Create the legacy /Images/NNNNNN.png file while we still have the local file on disk. + $legacyPath = (new CreateLegacyScreenshotPngAction())->execute( + file_get_contents($file->getRealPath()) + ); + + // Demote existing approved screenshots of this type to pending. This + // prevents the 20-screenshot cap from being hit by normal editor + // uploads and keeps demoted screenshots available for future gallery + // management (Set as Primary, Delete, etc). + $game->gameScreenshots() + ->ofType($type) + ->approved() + ->update(['is_primary' => false, 'status' => GameScreenshotStatus::Pending]); + } + + // Add the file to the screenshots MediaLibrary collection. + $customProperties = ['sha1' => $hash]; + if ($legacyPath !== null) { + $customProperties['legacy_path'] = $legacyPath; + } + + $media = $game + ->addMedia($file->getRealPath()) + ->preservingOriginal() + ->withCustomProperties($customProperties) + ->toMediaCollection('screenshots'); + + return GameScreenshot::create([ + 'game_id' => $game->id, + 'media_id' => $media->id, + 'type' => $type, + 'is_primary' => $shouldBePrimary, + 'status' => GameScreenshotStatus::Approved, + 'description' => $description, + ]); + } + + /** + * @throws ValidationException + */ + private function validateFile(UploadedFile $file): void + { + $validator = Validator::make( + ['screenshot' => $file], + ['screenshot' => [ + 'image', + 'mimes:png,jpg,jpeg,webp', + 'max:4096', + 'dimensions:min_width=64,min_height=64,max_width=1920,max_height=1080', + new DisallowAnimatedImageRule(), + ]], + ); + + $validator->validate(); + } + + /** + * @throws ValidationException + */ + private function validateHash(UploadedFile $file, Game $game): string + { + $hash = sha1_file($file->getRealPath()); + + if (in_array($hash, RejectedHashes::IMAGE_HASHES_GAMES)) { + throw ValidationException::withMessages([ + 'screenshot' => 'This image is a known placeholder and cannot be uploaded.', + ]); + } + + // Reject duplicates based on SHA1 within this game's screenshots collection. + $isDuplicate = $game->media() + ->where('collection_name', 'screenshots') + ->where('custom_properties->sha1', $hash) + ->exists(); + + if ($isDuplicate) { + throw ValidationException::withMessages([ + 'screenshot' => 'This image has already been uploaded for this game.', + ]); + } + + return $hash; + } + + /** + * @throws ValidationException + */ + private function validateResolution(UploadedFile $file, Game $game): void + { + $system = $game->system; + $resolutions = $system?->screenshot_resolutions; + + // Systems with null resolutions allow any dimensions. + if (empty($resolutions)) { + return; + } + + $imageInfo = getimagesize($file->getRealPath()); + if ($imageInfo === false) { + throw ValidationException::withMessages([ + 'screenshot' => 'Unable to read image dimensions. The file may be corrupt.', + ]); + } + + [$width, $height] = $imageInfo; + + // Check for an exact match against a known base resolution. + foreach ($resolutions as $resolution) { + if ($width === $resolution['width'] && $height === $resolution['height']) { + return; + } + } + + // If the system supports resolution scaling, check if the dimensions are + // an exact integer multiple of any base resolution (up to 3x). + if ($system->supports_resolution_scaling) { + foreach ($resolutions as $resolution) { + $baseW = $resolution['width']; + $baseH = $resolution['height']; + + if ($baseW === 0 || $baseH === 0) { + continue; + } + + // Both axes must scale by the same integer factor. + if ($width % $baseW === 0 && $height % $baseH === 0) { + $scaleX = (int) ($width / $baseW); + $scaleY = (int) ($height / $baseH); + + if ($scaleX === $scaleY && $scaleX >= 2 && $scaleX <= self::MAX_SCALE_FACTOR) { + return; + } + } + } + } + + $formatted = collect($resolutions) + ->map(fn (array $r) => "{$r['width']}x{$r['height']}") + ->join(', '); + + $scalingNote = $system->supports_resolution_scaling + ? " (or 2x/3x integer multiples)" + : ''; + + throw ValidationException::withMessages([ + 'screenshot' => "This screenshot's dimensions ({$width}x{$height}) don't match the expected resolutions for {$system->name}: {$formatted}{$scalingNote}.", + ]); + } + + /** + * @throws ValidationException + */ + private function validateCap(Game $game, ScreenshotType $type): void + { + $cap = match ($type) { + ScreenshotType::Ingame => 20, + ScreenshotType::Title, ScreenshotType::Completion => 1, + }; + + $approvedCount = $game->gameScreenshots() + ->ofType($type) + ->approved() + ->count(); + + if ($approvedCount >= $cap) { + throw ValidationException::withMessages([ + 'screenshot' => "This game has reached the maximum of {$cap} approved {$type->value} screenshot(s).", + ]); + } + } +} diff --git a/app/Platform/Actions/BuildGameShowPagePropsAction.php b/app/Platform/Actions/BuildGameShowPagePropsAction.php index bae7b36a21..29b64c3610 100644 --- a/app/Platform/Actions/BuildGameShowPagePropsAction.php +++ b/app/Platform/Actions/BuildGameShowPagePropsAction.php @@ -289,6 +289,7 @@ public function execute( 'system.active', 'system.iconUrl', 'system.nameShort', + 'system.screenshotResolutions', 'system', 'timesBeaten', 'timesBeatenHardcore', diff --git a/app/Platform/Data/SystemData.php b/app/Platform/Data/SystemData.php index 1ad3facb76..29a477986d 100644 --- a/app/Platform/Data/SystemData.php +++ b/app/Platform/Data/SystemData.php @@ -7,6 +7,7 @@ use App\Models\System; use Spatie\LaravelData\Data; use Spatie\LaravelData\Lazy; +use Spatie\TypeScriptTransformer\Attributes\LiteralTypeScriptType; use Spatie\TypeScriptTransformer\Attributes\TypeScript; #[TypeScript('System')] @@ -20,6 +21,8 @@ public function __construct( public Lazy|string $nameFull, public Lazy|string $nameShort, public Lazy|string $iconUrl, + #[LiteralTypeScriptType('Array<{ width: number; height: number }> | null')] + public Lazy|array|null $screenshotResolutions, ) { } @@ -33,6 +36,7 @@ public static function fromSystem(System $system): self nameFull: Lazy::create(fn () => $system->name_full), nameShort: Lazy::create(fn () => $system->name_short), iconUrl: Lazy::create(fn () => $system->icon_url), + screenshotResolutions: Lazy::create(fn () => $system->screenshot_resolutions), ); } } diff --git a/app/Platform/Enums/GameScreenshotStatus.php b/app/Platform/Enums/GameScreenshotStatus.php new file mode 100644 index 0000000000..94892252c4 --- /dev/null +++ b/app/Platform/Enums/GameScreenshotStatus.php @@ -0,0 +1,17 @@ +is_primary) { + return; + } + + // For updates, only sync when is_primary actually changed (not on description edits etc). + // For creates, wasChanged() is unreliable (syncChanges() only runs in performUpdate), + // so we check wasRecentlyCreated instead. + if (!$screenshot->wasRecentlyCreated && !$screenshot->wasChanged('is_primary')) { + return; + } + + // When a non-primary screenshot is promoted to primary, its media record + // may not have a legacy_path value in custom_properties (only auto-primary + // screenshots get it at upload time). Create the legacy PNG now by downloading + // the original from S3. + $media = $screenshot->media; + if ($media && !$media->getCustomProperty('legacy_path')) { + $fileContents = Storage::disk('s3')->get($media->getPath()); + if ($fileContents) { + $legacyPath = (new CreateLegacyScreenshotPngAction())->execute($fileContents); + if ($legacyPath) { + $media->setCustomProperty('legacy_path', $legacyPath); + $media->save(); + } + } + } + + $screenshot->game->syncLegacyScreenshotFields(); + } + + public function deleted(GameScreenshot $screenshot): void + { + if (!$screenshot->is_primary) { + return; + } + + $game = $screenshot->game; + + // Promote the next approved screenshot of the same type. + $next = $game->gameScreenshots() + ->ofType($screenshot->type) + ->approved() + ->orderBy('order_column') + ->first(); + + if ($next) { + // Promoting the next screenshot triggers saved(), which handles the sync. + $next->update(['is_primary' => true]); + + return; + } + + // No remaining screenshots of this type. Reset to placeholder. + $game->syncLegacyScreenshotFields(); + } +} diff --git a/app/Policies/GamePolicy.php b/app/Policies/GamePolicy.php index 5d9dc33eab..d83d1d5eda 100644 --- a/app/Policies/GamePolicy.php +++ b/app/Policies/GamePolicy.php @@ -17,6 +17,7 @@ public function manage(User $user): bool { return $user->hasAnyRole([ Role::GAME_HASH_MANAGER, + Role::GAME_EDITOR, Role::DEVELOPER, Role::DEVELOPER_JUNIOR, @@ -52,6 +53,7 @@ public function update(User $user, Game $game): bool { $canAlwaysUpdate = $user->hasAnyRole([ Role::GAME_HASH_MANAGER, + Role::GAME_EDITOR, Role::DEVELOPER, Role::ARTIST, ]); @@ -104,6 +106,7 @@ public function updateField(User $user, Game $game, string $fieldName): bool 'image_box_art_asset_path', 'image_title_asset_path', 'image_ingame_asset_path', + 'screenshots', 'released_at', 'released_at_granularity', 'trigger_definition', @@ -120,11 +123,16 @@ public function updateField(User $user, Game $game, string $fieldName): bool 'image_box_art_asset_path', 'image_title_asset_path', 'image_ingame_asset_path', + 'screenshots', 'released_at', 'released_at_granularity', 'trigger_definition', ], + Role::GAME_EDITOR => [ + 'screenshots', + ], + Role::ARTIST => [ 'banner', 'image_icon_asset_path', diff --git a/app/Support/Media/CreateLegacyScreenshotPngAction.php b/app/Support/Media/CreateLegacyScreenshotPngAction.php new file mode 100644 index 0000000000..a3388aec75 --- /dev/null +++ b/app/Support/Media/CreateLegacyScreenshotPngAction.php @@ -0,0 +1,76 @@ +calculateTargetDimensions($sourceWidth, $sourceHeight); + + $resized = imagecreatetruecolor($targetWidth, $targetHeight); + imagecopyresampled($resized, $sourceImage, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight); + + $imagePath = '/Images/' . FilenameIterator::getImageIterator() . '.png'; + $localPath = storage_path('app/media' . $imagePath); + + // Ensure the directory exists. + $dir = dirname($localPath); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + imagepng($resized, $localPath); + FilenameIterator::incrementImageIterator(); + + imagedestroy($sourceImage); + imagedestroy($resized); + + UploadToS3($localPath, $imagePath); + + return $imagePath; + } + + /** + * @return array{int, int} + */ + private function calculateTargetDimensions(int $sourceWidth, int $sourceHeight): array + { + $targetWidth = $sourceWidth; + $targetHeight = $sourceHeight; + + if ($targetWidth > self::MAX_WIDTH) { + $scale = self::MAX_WIDTH / $targetWidth; + $targetWidth = (int) ($targetWidth * $scale); + $targetHeight = (int) ($targetHeight * $scale); + } + + if ($targetHeight > self::MAX_HEIGHT) { + $scale = self::MAX_HEIGHT / $targetHeight; + $targetWidth = (int) ($targetWidth * $scale); + $targetHeight = (int) ($targetHeight * $scale); + } + + return [$targetWidth, $targetHeight]; + } +} diff --git a/config/media.php b/config/media.php index 219046de48..062d5869f6 100644 --- a/config/media.php +++ b/config/media.php @@ -70,8 +70,12 @@ ], ], - // Future game media types can go here: - // 'screenshot' => [...], + 'screenshot' => [ + 'sm' => ['width' => 320], + 'md' => ['width' => 640], + 'lg' => ['width' => 1280], + ], + // 'boxart' => [...], ], diff --git a/database/factories/GameScreenshotFactory.php b/database/factories/GameScreenshotFactory.php new file mode 100644 index 0000000000..7c7df86bc7 --- /dev/null +++ b/database/factories/GameScreenshotFactory.php @@ -0,0 +1,79 @@ + + */ +class GameScreenshotFactory extends Factory +{ + protected $model = GameScreenshot::class; + + public function definition(): array + { + return [ + 'game_id' => null, + 'media_id' => fn () => Media::create([ + 'model_type' => Game::class, + 'model_id' => 0, + 'uuid' => $this->faker->uuid(), + 'collection_name' => 'screenshots', + 'name' => $this->faker->word(), + 'file_name' => $this->faker->word() . '.png', + 'mime_type' => 'image/png', + 'disk' => 's3', + 'size' => 1024, + 'manipulations' => [], + 'custom_properties' => ['sha1' => sha1($this->faker->unique()->word())], + 'generated_conversions' => [], + 'responsive_images' => [], + ])->id, + 'type' => ScreenshotType::Ingame, + 'is_primary' => false, + 'status' => GameScreenshotStatus::Approved, + 'description' => null, + 'captured_by_user_id' => null, + 'reviewed_by_user_id' => null, + 'reviewed_at' => null, + ]; + } + + public function primary(): static + { + return $this->state(fn () => ['is_primary' => true]); + } + + public function title(): static + { + return $this->state(fn () => ['type' => ScreenshotType::Title]); + } + + public function ingame(): static + { + return $this->state(fn () => ['type' => ScreenshotType::Ingame]); + } + + public function completion(): static + { + return $this->state(fn () => ['type' => ScreenshotType::Completion]); + } + + public function pending(): static + { + return $this->state(fn () => ['status' => GameScreenshotStatus::Pending]); + } + + public function rejected(): static + { + return $this->state(fn () => ['status' => GameScreenshotStatus::Rejected]); + } +} diff --git a/database/migrations/2026_02_20_000000_create_game_screenshots_table.php b/database/migrations/2026_02_20_000000_create_game_screenshots_table.php new file mode 100644 index 0000000000..b4ec94c7f6 --- /dev/null +++ b/database/migrations/2026_02_20_000000_create_game_screenshots_table.php @@ -0,0 +1,42 @@ +id(); + + $table->foreignId('game_id')->constrained('games')->cascadeOnDelete(); + $table->foreignId('media_id')->unique()->constrained('media')->cascadeOnDelete(); + + $table->string('type', 20); // ScreenShotType enum + $table->boolean('is_primary')->default(false); // Is this the primary in-game screenshot? + $table->string('status', 20)->default('approved'); // GameScreenshotStatus enum + $table->text('description')->nullable(); + + $table->foreignId('captured_by_user_id')->nullable()->constrained('users')->nullOnDelete(); // The user who uploaded this (nullable due to an upcoming backfill) + $table->foreignId('reviewed_by_user_id')->nullable()->constrained('users')->nullOnDelete(); // The user who reviewed this upload (if any) + + $table->unsignedInteger('order_column')->default(0); + $table->timestamp('reviewed_at')->nullable(); + $table->timestamps(); + + // Find a game's primary screenshot(s) by type. + $table->index(['game_id', 'type', 'is_primary']); + + // Show a game's approved and ordered screenshots by type. + $table->index(['game_id', 'type', 'status', 'order_column']); + }); + } + + public function down(): void + { + Schema::dropIfExists('game_screenshots'); + } +}; diff --git a/database/migrations/2026_02_20_000001_update_systems_table_add_screenshot_resolutions.php b/database/migrations/2026_02_20_000001_update_systems_table_add_screenshot_resolutions.php new file mode 100644 index 0000000000..da403110dc --- /dev/null +++ b/database/migrations/2026_02_20_000001_update_systems_table_add_screenshot_resolutions.php @@ -0,0 +1,456 @@ +json('screenshot_resolutions')->nullable()->after('name_short'); + $table->boolean('supports_resolution_scaling')->default(false)->after('screenshot_resolutions'); + }); + + /** + * Seed known system screenshot resolutions. + * + * Each entry is a JSON array of {width, height} objects representing + * the valid emulator output resolutions for screenshots on that system. + * + * IMPORTANT: These are what the libretro core (or RA-verified standalone + * emulator) actually writes to the framebuffer. These are not raw hardware + * pixel counts or display aspect ratios. + * + * The first resolution in each array is the "most common", and it's used + * as the default for layout shift prevention in the front-end. + * + * Unlisted systems stay null, meaning the resolution varies per game and + * no dimension validation is applied at upload time. + * + * Citations point to core source code (retro_get_system_av_info or + * equivalent geometry structs) where possible. + */ + $resolutions = [ + + // ================================================================= + // HANDHELDS - Fixed LCD resolutions, no PAL/NTSC variance. + // ================================================================= + + // Game Boy - Fixed 160x144 dot-matrix LCD. + 4 => [[160, 144]], + + // Game Boy Color - Same 160x144 LCD as Game Boy. + 6 => [[160, 144]], + + // Game Boy Advance - Fixed 240x160 TFT LCD. + 5 => [[240, 160]], + + // Game Gear - Fixed 160x144 LCD viewport. + 15 => [[160, 144]], + + // Atari Lynx - Fixed 160x102 backlit LCD. + 13 => [[160, 102]], + + // Neo Geo Pocket / Color - Fixed 160x152 TFT LCD. + 14 => [[160, 152]], + + // Pokemon Mini - Fixed 96x64 monochrome LCD. + // https://docs.libretro.com/library/pokemini/ + 24 => [[96, 64]], + + // WonderSwan / Color - 224x144 LCD with physical rotation. + // Games run horizontal (224x144) or vertical (144x224). + 53 => [[224, 144], [144, 224]], + + // Watara Supervision - Fixed 160x160 monochrome LCD (4 shades). + // https://en.wikipedia.org/wiki/Watara_Supervision + 63 => [[160, 160]], + + // Mega Duck - Fixed 160x144 LCD (Game Boy clone hardware). + 69 => [[160, 144]], + + // PlayStation Portable - Fixed 480x272 TFT LCD. + // https://docs.libretro.com/library/ppsspp/ + 41 => [[480, 272]], + + // Nintendo DS - Two 256x192 screens stacked into 256x384. + // https://docs.libretro.com/library/melonds/ + 18 => [[256, 384]], + + // Nintendo DSi - Same dual-screen layout as DS, 256x384. + 78 => [[256, 384]], + + // Nintendo 3DS - Top screen is 400x240. Bottom screen is 320x240. + 62 => [[400, 240]], + + // Nokia N-Gage - Fixed 176x208 TFT LCD (portrait). + // https://en.wikipedia.org/wiki/N-Gage_(device) + 61 => [[176, 208]], + + // ================================================================= + // NINTENDO HOME CONSOLES + // ================================================================= + + // NES/Famicom - PPU outputs 256x240. NTSC cores crop overscan to + // 256x224 by default. PAL uses the full 256x240. + 7 => [[256, 224], [256, 240]], + + // Famicom Disk System - Same PPU as NES. + 81 => [[256, 224], [256, 240]], + + // SNES/Super Famicom - 256x224 standard. Hi-res Mode 5/6 doubles + // horizontal to 512. Interlaced doubles vertical. Max 512x478. + // https://docs.libretro.com/library/snes9x/ + 3 => [[256, 224], [256, 239], [512, 224], [512, 239], [512, 448], [512, 478]], + + // Nintendo 64 - 320x240 standard. Expansion Pak games support 640x480 interlaced. + // https://github.com/libretro/mupen64plus-libretro-nx/blob/develop/libretro/libretro.c#L1441 + 2 => [[320, 240], [640, 480]], + + // Virtual Boy - Fixed 384x224 LED display per eye. + // https://en.wikipedia.org/wiki/Virtual_Boy + 28 => [[384, 224]], + + // GameCube - Dolphin core outputs 640x480 at 1x native. + // https://docs.libretro.com/library/dolphin/ + 16 => [[640, 480]], + + // Wii - Same Dolphin core as GameCube, 640x480 at 1x native. + // https://docs.libretro.com/library/dolphin/ + 19 => [[640, 480]], + + // Wii U - Most games render at 1280x720. + 20 => [[1280, 720]], + + // ================================================================= + // SEGA + // ================================================================= + + // SG-1000 - TMS9918A VDP. 256x192 only, no extended modes. + // https://docs.libretro.com/library/genesis_plus_gx/ + 33 => [[256, 192]], + + // Master System - TMS9918-derived VDP outputs 256x192. SMS2 VDP + // adds 256x224 and 256x240, used by Codemasters titles and others. + // https://docs.libretro.com/library/gearsystem/ + // https://www.smspower.org/Development/Modes + 11 => [[256, 192], [256, 224], [256, 240]], + + // Genesis/Mega Drive - H40 (320px, most games) and H32 (256px). + // NTSC: 224 lines, PAL: 240 lines. + // https://docs.libretro.com/library/genesis_plus_gx/ + // https://docs.libretro.com/library/picodrive/ + 1 => [[320, 224], [256, 224], [320, 240], [256, 240]], + + // Sega CD - Same VDP as Genesis, same resolution modes. + 9 => [[320, 224], [256, 224], [320, 240], [256, 240]], + + // 32X - Inherits Genesis VDP. Most 32X rendering uses 320-wide. + // PAL outputs 240 lines. + 10 => [[320, 224], [320, 240]], + + // Sega Pico - Same VDP as Genesis. + 68 => [[320, 224], [256, 224], [320, 240], [256, 240]], + + // Saturn - VDP2 supports widths 320/352 (lo-res) and 640/704 + // (hi-res), heights 224/240 (NTSC) and 256 (PAL). Interlaced + // doubles vertical. Most games use 320x224 or 352x224. + // https://docs.libretro.com/library/beetle_saturn/ + 39 => [ + [320, 224], [352, 224], [320, 240], [352, 240], // lo-res NTSC/PAL + [320, 256], [352, 256], // lo-res PAL + [640, 224], [704, 224], [640, 240], [704, 240], // hi-res NTSC/PAL + [640, 448], [704, 448], [640, 480], [704, 480], // interlaced + ], + + // Dreamcast - Flycast always outputs 640x480. Games rendering at + // 320x240 are pixel-doubled by hardware. + // https://github.com/libretro/flycast/blob/b897744e27c730c7519784b2aef12ba7f658de31/core/libretro/libretro.cpp#L318 + 40 => [[640, 480]], + + // ================================================================= + // SONY + // ================================================================= + + // PlayStation - GPU supports widths 256/320/368/512/640 at 240 + // lines (progressive) or 480 (interlaced). No 224-line mode exists + // on PS1 unlike NES/SNES. Most games use 320x240. + // https://docs.libretro.com/library/beetle_psx/ + // https://psx-spx.consoledev.net/graphicsprocessingunitgpu/ + 12 => [ + [320, 240], [256, 240], [368, 240], [512, 240], [640, 240], // progressive + [320, 480], [256, 480], [368, 480], [512, 480], [640, 480], // interlaced + ], + + // PlayStation 2 - PS2 always outputs 640x448 (NTSC) or 640x512 + // (PAL). Games rendering at 512-wide get stretched to fill 640. + // 512-wide variants appear when screen offsets are disabled. + // https://github.com/PCSX2/pcsx2/issues/10922#issuecomment-1997653184 + 21 => [[640, 448], [512, 448], [640, 512], [512, 512]], + + // ================================================================= + // MICROSOFT + // ================================================================= + + // Xbox - 640x480 baseline output. + // https://www.copetti.org/writings/consoles/xbox/ + 22 => [[640, 480]], + + // ================================================================= + // ATARI + // ================================================================= + + // Atari 5200 - a5200 core outputs 336x240 (includes overscan + // borders around the ANTIC chip's 320x192 active area). + // https://docs.libretro.com/library/atari800/ + 50 => [[336, 240]], + + // Atari 7800 - ProSystem core: 320x223 (NTSC), 320x272 (PAL). + // https://docs.libretro.com/library/prosystem/ + 51 => [[320, 223], [320, 272]], + + // Atari Jaguar - Most games use 320x240. + // https://github.com/libretro/virtualjaguar-libretro/blob/48096c1f6f8b98cfff048a5cb4e6a86686631072/libretro.c#L860 + 17 => [[320, 240]], + + // Atari Jaguar CD - Same hardware as Jaguar. + // https://github.com/libretro/virtualjaguar-libretro/blob/48096c1f6f8b98cfff048a5cb4e6a86686631072/libretro.c#L860 + 77 => [[320, 240]], + + // ================================================================= + // NEC + // ================================================================= + + // PC Engine/TurboGrafx-16 - HuC6270 VDC supports widths 256, 336, + // and 512 with ~239 visible lines. Beetle PCE FAST outputs 512x243 + // to handle mid-frame width switching. + // https://docs.libretro.com/library/beetle_pce_fast/ + 8 => [[256, 239], [336, 239], [512, 243]], + + // PC Engine CD - Same hardware as PC Engine. + // https://docs.libretro.com/library/beetle_pce_fast/ + 76 => [[256, 239], [336, 239], [512, 243]], + + // PC-FX - Most games use 256x240 or 341x240. + // https://github.com/libretro/beetle-pcfx-libretro/blob/dd04cef9355286488a1d78ff18c4c848a1575540/libretro.cpp#L441 + 49 => [[256, 240], [341, 240]], + + // ================================================================= + // SNK + // ================================================================= + + // Neo Geo CD - LSPC2 outputs 320x224. Many games use the center + // 304 pixels only (16px black borders). + // https://www.chibiakumas.com/68000/neogeo.php + 56 => [[320, 224], [304, 224]], + + // ================================================================= + // OTHER CONSOLES + // ================================================================= + + // 3DO - Opera core: 320x240 default, 640x480 with High Resolution + // option enabled. + // https://docs.libretro.com/library/opera/ + 43 => [[320, 240], [640, 480]], + + // Philips CD-i + // http://www.icdia.co.uk/docs_sw/vcd_on_cdi_311.pdf + 42 => [[384, 240], [384, 280]], + + // ColecoVision - TMS9928A VDP, 256x192 only. + // https://www.msx.org/forum/msx-talk/development/setting-graphics-mode-7-and-sprites-also-bluemsx-vram-debugging + 44 => [[256, 192]], + + // Intellivision - FreeIntv core outputs 352x224 by default. + // https://github.com/libretro/FreeIntv/blob/df5a5312985b66b1ec71b496868641e40b7ad1c9/src/libretro.c#L53 + 45 => [[352, 224]], + + // Magnavox Odyssey 2 + // https://docs.libretro.com/library/o2em/ + 23 => [[340, 250]], + + // Fairchild Channel F - FreeChaF core outputs 306x192. + // https://github.com/libretro/FreeChaF/blob/cdb8ad6fcecb276761b193650f5ce9ae8b878067/src/libretro.c#L40 + 57 => [[306, 192]], + + // Arcadia 2001 + // https://docs.retroachievements.org/guidelines/content/badge-and-icon-guidelines.html + 73 => [[146, 240]], + + // Interton VC 4000 + // https://docs.retroachievements.org/guidelines/content/badge-and-icon-guidelines.html + 74 => [[146, 240]], + + // Elektor TV Games Computer + // https://docs.retroachievements.org/guidelines/content/badge-and-icon-guidelines.html + 75 => [[146, 240]], + + // Cassette Vision - Hardware spec. + // https://en.wikipedia.org/wiki/Cassette_Vision + 54 => [[128, 192]], + + // Super Cassette Vision - Hardware spec. + // https://en.wikipedia.org/wiki/Super_Cassette_Vision + 55 => [[256, 192]], + + // Vectrex - Vector display with no fixed pixel resolution. The vecx + // core rasterizes to 330x410 (portrait matches original CRT). + // https://docs.libretro.com/library/vecx/ + 46 => [[330, 410]], + + // Zeebo - ARM-based console with 800x480 display. + // https://en.wikipedia.org/wiki/Zeebo + 70 => [[800, 480]], + + // ================================================================= + // FANTASY CONSOLES & CALCULATORS + // ================================================================= + + // Arduboy - Fixed 128x64 1-bit OLED. + // https://docs.libretro.com/library/ardens/ + 71 => [[128, 64]], + + // WASM-4 - Spec defines 160x160 at 4 colors. + // https://github.com/aduros/wasm4/blob/main/runtimes/native/src/backend/main_libretro.c#L266 + 72 => [[160, 160]], + + // TIC-80 - Spec defines 240x136. + // https://github.com/nesbox/TIC-80/blob/main/src/system/libretro/tic80_libretro.c#L433 + 65 => [[240, 136]], + + // TI-83 - 96x64 monochrome LCD. + 79 => [[96, 64]], + + // ================================================================= + // COMPUTERS - Fixed or well-defined core output. + // ================================================================= + + // MSX + 29 => [[272, 240]], + + // VIC-20 - VICE xvic core: 448x284 (PAL), 400x234 (NTSC). + // https://docs.libretro.com/library/vice/ + // https://github.com/libretro/vice-libretro/blob/master/libretro/libretro-core.h + 34 => [[448, 284], [400, 234]], + + // Atari ST - Hatari core scales all ST display modes into its + // "Internal Resolution" buffer. Default is 640x480. + // https://github.com/libretro/hatari/blob/7008194d3f951a157997f67a820578f56f7feee0/libretro/libretro.c#L976 + 36 => [[640, 480], [320, 200]], + + // Amstrad CPC + 37 => [[320, 226]], + + // Apple II - RAppleWin outputs a fixed 560x384 framebuffer. All + // video modes (HGR, LoRes, DHGR) render into this buffer. + // https://github.com/AppleWin/AppleWin + 38 => [[560, 384], [320, 219]], + + // PC-8000/8800 - QUASI88 core always outputs 640x400 regardless + // of PC-88 video mode. + // https://docs.libretro.com/library/quasi88/ + 47 => [[640, 400]], + + // PC-9800 - NP2kai core: 640x400 (nearly all PC-98 games). + // https://forums.libretro.com/t/neko-project-ii-kai-pc-9801-core-different-nekop2-meowpc98/11086/41 + 48 => [[640, 400]], + + // Sharp X68000 - PX68k core outputs a fixed 800x600 framebuffer. + // All native modes are internally scaled by the core. + // https://docs.libretro.com/library/px68k/ + // https://github.com/libretro/px68k-libretro/blob/9dfa6abc25ddd6e597790f7a535cd0a1d7f9c385/libretro.c#L129 + 52 => [[800, 600]], + + // ZX Spectrum - Fuse core: 320x240 (standard models). Timex + // models (TC2048, TC2068) output 640x480. + // https://github.com/libretro/fuse-libretro/blob/cad85b7b1b864c65734f71aa4a510b6f6536881c/src/libretro.c#L499 + 59 => [[320, 240], [640, 480]], + + // Sharp X1 + // https://github.com/libretro/xmil-libretro/blob/master/libretro/xmil.h + 64 => [[320, 200], [640, 400]], + + // Thomson TO8 + // https://docs.libretro.com/library/theodore/ + 66 => [[672, 432]], + + // ================================================================= + // GAME-DEPENDENT - Left null. No dimension validation at upload. + // ================================================================= + + // Atari 2600 (ID 25) - TIA is 160px wide but vertical scanline + // count varies per game (~160 to ~230+). Stella core is dynamic. + // https://github.com/libretro/stella2014-libretro/blob/3cc89f0d316d6c924a5e3f4011d17421df58e615/libretro.cxx#L1020 + + // Arcade (ID 27) - Every board has different hardware. + // FBNeo outputs per-game native resolution. + + // DOS (ID 26) - DOSBox Pure dynamically resizes per video mode. + + // Amiga (ID 35) - PUAE core varies by game mode and PAL/NTSC. + + // Oric (ID 32) + + // ZX81 (ID 31) + + // Commodore 64 (ID 30) + + // Uzebox (ID 80) - Resolution varies per game mode. + + // Game & Watch (ID 60) - gw-libretro uses per-game dimensions + // (each .mgw file defines its own resolution). No fixed output. + + // FM Towns (ID 58) + + // PC-6000 (ID 67) + + // ================================================================= + // NON-GAME - Not applicable. + // ================================================================= + + // Hubs (ID 100), Events (ID 101), Standalone (ID 102) + ]; + + foreach ($resolutions as $systemId => $modes) { + $json = json_encode(array_map( + fn (array $pair) => ['width' => $pair[0], 'height' => $pair[1]], + $modes, + )); + + DB::table('systems')->where('id', $systemId)->update([ + 'screenshot_resolutions' => $json, + ]); + } + + // Systems where the emulator supports internal resolution scaling + // (2x, 3x, etc). Validation accepts screenshots that are exact + // integer multiples of any base resolution, capped at 3x. + $scalingSystems = [ + 41, // PSP + 12, // PlayStation + 21, // PlayStation 2 + 2, // Nintendo 64 + 16, // GameCube + 19, // Wii + 40, // Dreamcast + 18, // Nintendo DS + 78, // Nintendo DSi + 62, // Nintendo 3DS + ]; + + DB::table('systems')->whereIn('id', $scalingSystems)->update([ + 'supports_resolution_scaling' => true, + ]); + } + + public function down(): void + { + Schema::table('systems', function (Blueprint $table) { + $table->dropColumn(['screenshot_resolutions', 'supports_resolution_scaling']); + }); + } +}; diff --git a/resources/js/common/components/PlayableMainMedia/PlayableMainMedia.test.tsx b/resources/js/common/components/PlayableMainMedia/PlayableMainMedia.test.tsx index f9291d142c..8fef20ec28 100644 --- a/resources/js/common/components/PlayableMainMedia/PlayableMainMedia.test.tsx +++ b/resources/js/common/components/PlayableMainMedia/PlayableMainMedia.test.tsx @@ -45,4 +45,44 @@ describe('Component: PlayableMainMedia', () => { // ASSERT expect(screen.queryByRole('img')).not.toBeInTheDocument(); }); + + it('given expected dimensions, sets width and height on both images', () => { + // ARRANGE + render( + , + ); + + // ASSERT + const titleImage = screen.getByRole('img', { name: /title screenshot/i }); + const ingameImage = screen.getByRole('img', { name: /ingame screenshot/i }); + + expect(titleImage).toHaveAttribute('width', '256'); + expect(titleImage).toHaveAttribute('height', '224'); + expect(ingameImage).toHaveAttribute('width', '256'); + expect(ingameImage).toHaveAttribute('height', '224'); + }); + + it('given no expected dimensions, does not set width and height on images', () => { + // ARRANGE + render( + , + ); + + // ASSERT + const titleImage = screen.getByRole('img', { name: /title screenshot/i }); + const ingameImage = screen.getByRole('img', { name: /ingame screenshot/i }); + + expect(titleImage).not.toHaveAttribute('width'); + expect(titleImage).not.toHaveAttribute('height'); + expect(ingameImage).not.toHaveAttribute('width'); + expect(ingameImage).not.toHaveAttribute('height'); + }); }); diff --git a/resources/js/common/components/PlayableMainMedia/PlayableMainMedia.tsx b/resources/js/common/components/PlayableMainMedia/PlayableMainMedia.tsx index bd60035f93..b6f951ba38 100644 --- a/resources/js/common/components/PlayableMainMedia/PlayableMainMedia.tsx +++ b/resources/js/common/components/PlayableMainMedia/PlayableMainMedia.tsx @@ -5,13 +5,25 @@ import { cn } from '@/common/utils/cn'; import { ZoomableImage } from '../ZoomableImage'; +/** + * TODO replace legacy PNG sources with sourcesets + * using the sm/md WebP/AVIF conversions from the screenshots + * MediaLibrary collection. + */ + interface PlayableMainMediaProps { imageTitleUrl: string; imageIngameUrl: string; isPixelated?: boolean; + + /** Set width/height on img tags to reserve space and prevent layout shift. */ + expectedWidth?: number | null; + expectedHeight?: number | null; } export const PlayableMainMedia: FC = ({ + expectedHeight, + expectedWidth, imageIngameUrl, imageTitleUrl, isPixelated, @@ -23,6 +35,9 @@ export const PlayableMainMedia: FC = ({ return null; } + const dimensionProps = + expectedWidth && expectedHeight ? { width: expectedWidth, height: expectedHeight } : {}; + return (
= ({ >
- {t('title + {t('title
- {t('ingame + {t('ingame
diff --git a/resources/js/features/games/components/+show-mobile/GameShowMobileRoot.tsx b/resources/js/features/games/components/+show-mobile/GameShowMobileRoot.tsx index d415f2bb08..24e33f9fb8 100644 --- a/resources/js/features/games/components/+show-mobile/GameShowMobileRoot.tsx +++ b/resources/js/features/games/components/+show-mobile/GameShowMobileRoot.tsx @@ -142,6 +142,8 @@ export const GameShowMobileRoot: FC = () => { imageIngameUrl={game.imageIngameUrl!} imageTitleUrl={game.imageTitleUrl!} isPixelated={getIsSystemPixelated(game.system!.id)} + expectedWidth={game.system?.screenshotResolutions?.[0]?.width} + expectedHeight={game.system?.screenshotResolutions?.[0]?.height} /> diff --git a/resources/js/features/games/components/+show/GameShowMainRoot.tsx b/resources/js/features/games/components/+show/GameShowMainRoot.tsx index def454a049..8af890c1c5 100644 --- a/resources/js/features/games/components/+show/GameShowMainRoot.tsx +++ b/resources/js/features/games/components/+show/GameShowMainRoot.tsx @@ -38,6 +38,8 @@ export const GameShowMainRoot: FC = () => { imageIngameUrl={game.imageIngameUrl!} imageTitleUrl={game.imageTitleUrl!} isPixelated={getIsSystemPixelated(game.system!.id)} + expectedWidth={game.system?.screenshotResolutions?.[0]?.width} + expectedHeight={game.system?.screenshotResolutions?.[0]?.height} />
diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index 09e81992c4..7404ca6492 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -1173,6 +1173,7 @@ declare namespace App.Platform.Data { nameFull?: string; nameShort?: string; iconUrl?: string; + screenshotResolutions?: Array<{ width: number; height: number }> | null; }; export type SystemGameListPageProps = { system: App.Platform.Data.System; @@ -1210,9 +1211,9 @@ declare namespace App.Platform.Data { }; } declare namespace App.Platform.Enums { - export type UnlockMode = 0 | 1; export type AchievementAuthorTask = 'artwork' | 'design' | 'logic' | 'testing' | 'writing'; export type AchievementSetAuthorTask = 'artwork' | 'banner'; + export type UnlockMode = 0 | 1; export type AchievementSetType = | 'core' | 'bonus' @@ -1278,6 +1279,7 @@ declare namespace App.Platform.Enums { | 'na' | 'worldwide' | 'other'; + export type GameScreenshotStatus = 'approved' | 'pending' | 'rejected'; export type GameSetRolePermission = 'view' | 'update'; export type GameSetType = 'hub' | 'similar-games'; export type GameSuggestionReason = @@ -1305,6 +1307,7 @@ declare namespace App.Platform.Enums { | 'hacks_beaten' | 'all_beaten'; export type ReleasedAtGranularity = 'day' | 'month' | 'year'; + export type ScreenshotType = 'title' | 'ingame' | 'completion'; export type TicketableType = 'achievement' | 'leaderboard' | 'game.rich-presence'; export type TriggerableType = 'achievement' | 'leaderboard' | 'game'; } diff --git a/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php b/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php new file mode 100644 index 0000000000..f9123d826e --- /dev/null +++ b/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php @@ -0,0 +1,277 @@ +create(['system_id' => System::factory()]); + $file = UploadedFile::fake()->image('screenshot.png', 256, 224); + + // ACT + $screenshot = (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame); + + // ASSERT + expect($screenshot)->toBeInstanceOf(GameScreenshot::class); + expect($screenshot->game_id)->toEqual($game->id); + expect($screenshot->type)->toEqual(ScreenshotType::Ingame); + expect($screenshot->status)->toEqual(GameScreenshotStatus::Approved); + expect($screenshot->is_primary)->toBeTrue(); + expect($screenshot->media_id)->not->toBeNull(); + + $media = $game->fresh()->getMedia('screenshots')->first(); + expect($media->getCustomProperty('sha1'))->not->toBeNull(); +}); + +it('does not set subsequent screenshots as primary', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $action = new AddGameScreenshotAction(); + $action->execute($game, UploadedFile::fake()->image('first.png', 256, 224), ScreenshotType::Ingame); + + // ACT + $second = $action->execute($game, UploadedFile::fake()->image('second.png', 320, 240), ScreenshotType::Ingame); + + // ASSERT + expect($second->is_primary)->toBeFalse(); +}); + +it('demotes existing primary image when a new screenshot is forced as primary', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $action = new AddGameScreenshotAction(); + $first = $action->execute($game, UploadedFile::fake()->image('first.png', 256, 224), ScreenshotType::Ingame); + + // ACT + $second = $action->execute($game, UploadedFile::fake()->image('second.png', 320, 240), ScreenshotType::Ingame, isPrimary: true); + + // ASSERT + expect($second->is_primary)->toBeTrue(); + expect($second->status)->toEqual(GameScreenshotStatus::Approved); + expect($first->fresh()->is_primary)->toBeFalse(); + expect($first->fresh()->status)->toEqual(GameScreenshotStatus::Pending); +}); + +it('rejects duplicate images for the same game', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $action = new AddGameScreenshotAction(); + + // ... create the source image content, then make two UploadedFile instances from it ... + $source = UploadedFile::fake()->image('screenshot.png', 256, 224); + $sourceContent = file_get_contents($source->getRealPath()); + + $action->execute($game, $source, ScreenshotType::Ingame); + + $duplicate = UploadedFile::fake()->image('duplicate.png', 256, 224); + file_put_contents($duplicate->getRealPath(), $sourceContent); + + // ASSERT + $action->execute($game->fresh(), $duplicate, ScreenshotType::Ingame); +})->throws(ValidationException::class); + +it('enforces a cap of 20 approved ingame screenshots', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + GameScreenshot::factory()->count(20)->for($game)->ingame()->create(); + $file = UploadedFile::fake()->image('screenshot.png', 256, 224); + + // ASSERT + (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame); +})->throws(ValidationException::class); + +it('does not enforce the ingame cap for title screenshots', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + GameScreenshot::factory()->count(20)->for($game)->ingame()->create(); + $file = UploadedFile::fake()->image('title.png', 256, 224); + + // ACT + $screenshot = (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Title); + + // ASSERT + expect($screenshot->type)->toEqual(ScreenshotType::Title); +}); + +it('enforces a cap of 1 approved screenshot for title and completion types', function (ScreenshotType $type) { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + GameScreenshot::factory()->for($game)->state(['type' => $type])->create(); + $file = UploadedFile::fake()->image('screenshot.png', 256, 224); + + // ASSERT + (new AddGameScreenshotAction())->execute($game, $file, $type); +})->throws(ValidationException::class)->with([ + 'title' => [ScreenshotType::Title], + 'completion' => [ScreenshotType::Completion], +]); + +it('rejects a file smaller than 64x64', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $file = UploadedFile::fake()->image('tiny.png', 32, 32); + + // ASSERT + (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame); +})->throws(ValidationException::class); + +it('rejects a file larger than 1920x1080', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $file = UploadedFile::fake()->image('huge.png', 2560, 1440); + + // ASSERT + (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame); +})->throws(ValidationException::class); + +it('stores the description', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $file = UploadedFile::fake()->image('screenshot.png', 256, 224); + + // ACT + $screenshot = (new AddGameScreenshotAction())->execute( + $game, + $file, + ScreenshotType::Ingame, + description: 'Boss fight in stage 3', + ); + + // ASSERT + expect($screenshot->description)->toEqual('Boss fight in stage 3'); +}); + +it('accepts a screenshot matching an exact base resolution', function () { + // ARRANGE + $system = System::factory()->create([ + 'screenshot_resolutions' => [['width' => 256, 'height' => 224]], + 'supports_resolution_scaling' => false, + ]); + $game = Game::factory()->create(['system_id' => $system->id]); + $file = UploadedFile::fake()->image('screenshot.png', 256, 224); + + // ACT + $screenshot = (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame); + + // ASSERT + expect($screenshot)->toBeInstanceOf(GameScreenshot::class); +}); + +it('rejects a screenshot with wrong dimensions for the system', function () { + // ARRANGE + $system = System::factory()->create([ + 'screenshot_resolutions' => [['width' => 256, 'height' => 224]], + 'supports_resolution_scaling' => false, + ]); + $game = Game::factory()->create(['system_id' => $system->id]); + $file = UploadedFile::fake()->image('screenshot.png', 320, 240); + + // ASSERT + (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame); +})->throws(ValidationException::class); + +it('accepts a 2x scaled screenshot when the system supports resolution scaling', function () { + // ARRANGE + $system = System::factory()->create([ + 'screenshot_resolutions' => [['width' => 320, 'height' => 240]], + 'supports_resolution_scaling' => true, + ]); + $game = Game::factory()->create(['system_id' => $system->id]); + $file = UploadedFile::fake()->image('screenshot.png', 640, 480); + + // ACT + $screenshot = (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame); + + // ASSERT + expect($screenshot)->toBeInstanceOf(GameScreenshot::class); +}); + +it('accepts a 3x scaled screenshot when the system supports resolution scaling', function () { + // ARRANGE + $system = System::factory()->create([ + 'screenshot_resolutions' => [['width' => 320, 'height' => 240]], + 'supports_resolution_scaling' => true, + ]); + $game = Game::factory()->create(['system_id' => $system->id]); + $file = UploadedFile::fake()->image('screenshot.png', 960, 720); + + // ACT + $screenshot = (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame); + + // ASSERT + expect($screenshot)->toBeInstanceOf(GameScreenshot::class); +}); + +it('rejects a 4x scaled screenshot even when the system supports resolution scaling', function () { + // ARRANGE + $system = System::factory()->create([ + 'screenshot_resolutions' => [['width' => 160, 'height' => 144]], + 'supports_resolution_scaling' => true, + ]); + $game = Game::factory()->create(['system_id' => $system->id]); + $file = UploadedFile::fake()->image('screenshot.png', 640, 576); + + // ASSERT + (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame); +})->throws(ValidationException::class); + +it('rejects a scaled screenshot when the system does not support resolution scaling', function () { + // ARRANGE + $system = System::factory()->create([ + 'screenshot_resolutions' => [['width' => 256, 'height' => 224]], + 'supports_resolution_scaling' => false, + ]); + $game = Game::factory()->create(['system_id' => $system->id]); + $file = UploadedFile::fake()->image('screenshot.png', 512, 448); + + // ASSERT + (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame); +})->throws(ValidationException::class); + +it('allows any dimensions when the system has null resolutions', function () { + // ARRANGE + $system = System::factory()->create([ + 'screenshot_resolutions' => null, + 'supports_resolution_scaling' => false, + ]); + $game = Game::factory()->create(['system_id' => $system->id]); + $file = UploadedFile::fake()->image('screenshot.png', 800, 600); + + // ACT + $screenshot = (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame); + + // ASSERT + expect($screenshot)->toBeInstanceOf(GameScreenshot::class); +}); + +it('treats different screenshot types independently for primary', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $action = new AddGameScreenshotAction(); + + // ACT + $ingame = $action->execute($game, UploadedFile::fake()->image('ingame.png', 256, 224), ScreenshotType::Ingame); + $title = $action->execute($game, UploadedFile::fake()->image('title.png', 320, 240), ScreenshotType::Title); + + // ASSERT + // ... both should be primary since they're different types ... + expect($ingame->is_primary)->toBeTrue(); + expect($title->is_primary)->toBeTrue(); +}); diff --git a/tests/Feature/Platform/Observers/GameScreenshotObserverTest.php b/tests/Feature/Platform/Observers/GameScreenshotObserverTest.php new file mode 100644 index 0000000000..96440faa75 --- /dev/null +++ b/tests/Feature/Platform/Observers/GameScreenshotObserverTest.php @@ -0,0 +1,121 @@ + Game::class, + 'model_id' => $game->id, + 'uuid' => (string) Str::uuid(), + 'collection_name' => 'screenshots', + 'name' => 'screenshot', + 'file_name' => 'screenshot.png', + 'mime_type' => 'image/png', + 'disk' => 's3', + 'size' => 1024, + 'manipulations' => [], + 'custom_properties' => ['sha1' => sha1(uniqid()), 'legacy_path' => $legacyPath], + 'generated_conversions' => [], + 'responsive_images' => [], + ]); +} + +it('syncs legacy screenshot fields when a screenshot becomes primary', function () { + // ARRANGE + $game = Game::factory()->create([ + 'system_id' => System::factory(), + 'image_ingame_asset_path' => '/Images/000002.png', + ]); + + $media = createMediaForGame($game, '/Images/099999.png'); + + $screenshot = GameScreenshot::factory()->for($game)->ingame()->create([ + 'media_id' => $media->id, + 'is_primary' => false, + ]); + + // ACT + $screenshot->update(['is_primary' => true]); + + // ASSERT + expect($game->fresh()->image_ingame_asset_path)->toEqual('/Images/099999.png'); +}); + +it('does not sync legacy fields when a non-primary screenshot is saved', function () { + // ARRANGE + $game = Game::factory()->create([ + 'system_id' => System::factory(), + 'image_ingame_asset_path' => '/Images/000002.png', + ]); + + $media = createMediaForGame($game, '/Images/099999.png'); + + $screenshot = GameScreenshot::factory()->for($game)->ingame()->create([ + 'media_id' => $media->id, + 'is_primary' => false, + ]); + + // ACT + $screenshot->update(['description' => 'Updated description']); + + // ASSERT + expect($game->fresh()->image_ingame_asset_path)->toEqual('/Images/000002.png'); +}); + +it('promotes the next approved screenshot when the primary is deleted', function () { + // ARRANGE + $game = Game::factory()->create([ + 'system_id' => System::factory(), + 'image_ingame_asset_path' => '/Images/000002.png', + ]); + + $media1 = createMediaForGame($game, '/Images/099998.png'); + $media2 = createMediaForGame($game, '/Images/099999.png'); + + $primary = GameScreenshot::factory()->for($game)->ingame()->primary()->create([ + 'media_id' => $media1->id, + 'order_column' => 1, + ]); + + $next = GameScreenshot::factory()->for($game)->ingame()->create([ + 'media_id' => $media2->id, + 'is_primary' => false, + 'order_column' => 2, + ]); + + // ACT + $primary->delete(); + + // ASSERT + expect($next->fresh()->is_primary)->toBeTrue(); +}); + +it('resets to placeholder when the last screenshot of a type is deleted', function () { + // ARRANGE + $game = Game::factory()->create([ + 'system_id' => System::factory(), + 'image_ingame_asset_path' => '/Images/099999.png', + ]); + + $media = createMediaForGame($game, '/Images/099999.png'); + + $screenshot = GameScreenshot::factory()->for($game)->ingame()->primary()->create([ + 'media_id' => $media->id, + ]); + + // ACT + $screenshot->delete(); + + // ASSERT + expect($game->fresh()->image_ingame_asset_path)->toEqual('/Images/000002.png'); +}); From 2d9bf11d1b216742bb82d1828721170a84afa08d Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sun, 22 Feb 2026 13:53:06 -0500 Subject: [PATCH 2/2] fix: support 3ds bottom screen res --- ...0_000001_update_systems_table_add_screenshot_resolutions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/2026_02_20_000001_update_systems_table_add_screenshot_resolutions.php b/database/migrations/2026_02_20_000001_update_systems_table_add_screenshot_resolutions.php index da403110dc..245c8e82ed 100644 --- a/database/migrations/2026_02_20_000001_update_systems_table_add_screenshot_resolutions.php +++ b/database/migrations/2026_02_20_000001_update_systems_table_add_screenshot_resolutions.php @@ -85,7 +85,7 @@ public function up(): void 78 => [[256, 384]], // Nintendo 3DS - Top screen is 400x240. Bottom screen is 320x240. - 62 => [[400, 240]], + 62 => [[400, 240], [320, 240]], // Nokia N-Gage - Fixed 176x208 TFT LCD (portrait). // https://en.wikipedia.org/wiki/N-Gage_(device)