From a19c9675945d9e18af2381150e95d6b509673f34 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 31 May 2026 00:43:59 +0200 Subject: [PATCH] feat(export): keyvisual fallback slides --- .sisyphus/evidence/task-9-fallback.txt | 15 ++ .sisyphus/evidence/task-9-no-keyvisual.txt | 16 ++ .../keyvisual-background-nametag/learnings.md | 7 + app/Services/PlaylistExportService.php | 87 +++++++ tests/Feature/KeyVisualFallbackTest.php | 237 ++++++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 .sisyphus/evidence/task-9-fallback.txt create mode 100644 .sisyphus/evidence/task-9-no-keyvisual.txt create mode 100644 tests/Feature/KeyVisualFallbackTest.php diff --git a/.sisyphus/evidence/task-9-fallback.txt b/.sisyphus/evidence/task-9-fallback.txt new file mode 100644 index 0000000..ecea21d --- /dev/null +++ b/.sisyphus/evidence/task-9-fallback.txt @@ -0,0 +1,15 @@ +Task T9 keyvisual fallback evidence + +Scenario: Service has key_visual_filename and a visible non-song agenda item with no uploaded/special slides. + +Result: +- PlaylistExportService creates an embedded .pro for the agenda item title. +- The generated .pro contains exactly one image-only slide with background media. +- Background media URL is Storage::disk('public')->path($keyvisual). +- Slide rows are not created; Slide::count() remains unchanged. + +Verification: +- RED before implementation: `ddev exec php artisan test tests/Feature/KeyVisualFallbackTest.php` failed with `Keine Songs mit Inhalt zum Exportieren gefunden.` for empty non-song agenda item. +- GREEN after implementation: `ddev exec php artisan test tests/Feature/KeyVisualFallbackTest.php` → 4 passed, 16 assertions. +- Full suite: `ddev exec php artisan test` → 541 passed, 2699 assertions. +- Pint: `ddev exec ./vendor/bin/pint app/Services/PlaylistExportService.php tests/Feature/KeyVisualFallbackTest.php` → PASS, 2 files. diff --git a/.sisyphus/evidence/task-9-no-keyvisual.txt b/.sisyphus/evidence/task-9-no-keyvisual.txt new file mode 100644 index 0000000..2b0f343 --- /dev/null +++ b/.sisyphus/evidence/task-9-no-keyvisual.txt @@ -0,0 +1,16 @@ +Task T9 no-keyvisual evidence + +Scenario: Service has no keyvisual and a visible non-song agenda item with no uploaded/special slides. + +Result: +- Empty non-song agenda item adds no playlist entry and no .pro file. +- Song agenda items still export through the normal song .pro path. +- Uploaded agenda slides still export normally; no keyvisual slide is prepended. +- Song agenda items never receive keyvisual fallback slides. + +Verification: +- `tests/Feature/KeyVisualFallbackTest.php` covers: + - no keyvisual → no empty-item .pro entry, + - song item → only song .pro, + - uploaded slides → exactly the uploaded slide presentation and no fallback background. +- Full suite after implementation: 541 passed, 2699 assertions. diff --git a/.sisyphus/notepads/keyvisual-background-nametag/learnings.md b/.sisyphus/notepads/keyvisual-background-nametag/learnings.md index 4433be3..18957c5 100644 --- a/.sisyphus/notepads/keyvisual-background-nametag/learnings.md +++ b/.sisyphus/notepads/keyvisual-background-nametag/learnings.md @@ -88,3 +88,10 @@ ## [2026-05-31] Task: T8 — Export background layer - Migration timestamp chosen as `2026_05_10_115950_add_cover_mode_to_slides_table.php` so the existing CCLI rollback test still rolls back the CCLI migration with `--step=1`. - Tests appended to `tests/Feature/ProFileExportTest.php`: song background, null background, sermon full-cover skip, information/moderation exclusion, playlist sermon/information regression. - Verification: RED targeted test failed before implementation; GREEN targeted `ProFileExportTest` 13 passed / 76 assertions; full suite `ddev exec php artisan test` 537 passed / 2683 assertions; Pint clean. Evidence: `.sisyphus/evidence/task-8-song-background.txt`, `.sisyphus/evidence/task-8-no-background.txt`. + +## [2026-05-31] Task: T9 — Keyvisual fallback playlist slides +- `PlaylistExportService` now adds an ephemeral keyvisual fallback presentation only in the agenda export path after song handling and after uploaded agenda slides are considered. +- Eligible fallback items: visible agenda items with `service_song_id === null`, no uploaded/special slides, not a nametag/namenseinblender marker, and `ServiceImageResolver::keyVisualFor($service)` resolves an existing storage file. +- Fallback `.pro` uses parser T1 contract: one group `Keyvisual`, arrangement `normal`, slide data `['imageOnly' => true, 'background' => ['path' => Storage::disk('public')->path($keyvisual), 'format' => 'JPG', 'width' => 1920, 'height' => 1080]]`. +- Generated fallback slides are not persisted; tests assert `Slide::count()` stays unchanged. +- Tests: `tests/Feature/KeyVisualFallbackTest.php` covers fallback creation, song exclusion, uploaded-slide exclusion, and no-keyvisual no-op. Full suite green: `ddev exec php artisan test` → 541 passed / 2699 assertions. Pint clean. Evidence: `.sisyphus/evidence/task-9-fallback.txt`, `.sisyphus/evidence/task-9-no-keyvisual.txt`. diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index 2133a8b..a9ab710 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -123,6 +123,18 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $service, $this->backgroundPartTypeForAgendaItem($item), ); + + continue; + } + + if (! $this->isNameTagAgendaItem($item)) { + $this->addKeyVisualFallbackPresentation( + $item, + $service, + $tempDir, + $playlistItems, + $embeddedFiles, + ); } } @@ -382,6 +394,54 @@ private function addSlidePresentation( ); } + private function addKeyVisualFallbackPresentation( + ServiceAgendaItem $item, + Service $service, + string $tempDir, + array &$playlistItems, + array &$embeddedFiles, + ): void { + $background = $this->keyVisualData($service); + + if ($background === null) { + return; + } + + $label = $item->title ?: 'Keyvisual'; + $groups = [ + [ + 'name' => 'Keyvisual', + 'color' => [0, 0, 0, 1], + 'slides' => [ + [ + 'imageOnly' => true, + 'background' => $background, + ], + ], + ], + ]; + $arrangements = [ + [ + 'name' => 'normal', + 'groupNames' => ['Keyvisual'], + ], + ]; + + $safeLabel = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $label); + $proFilename = $safeLabel.'.pro'; + $proPath = $tempDir.'/'.$proFilename; + + $this->writeProFile($proPath, $label, $groups, $arrangements); + + $embeddedFiles[$proFilename] = file_get_contents($proPath); + + $playlistItems[] = [ + 'type' => 'presentation', + 'name' => $label, + 'path' => $proFilename, + ]; + } + protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void { ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements); @@ -415,6 +475,22 @@ private function backgroundData(?Service $service): ?array ]; } + private function keyVisualData(Service $service): ?array + { + $keyVisual = app(ServiceImageResolver::class)->keyVisualFor($service); + + if ($keyVisual === null) { + return null; + } + + return [ + 'path' => Storage::disk('public')->path($keyVisual), + 'format' => 'JPG', + 'width' => 1920, + 'height' => 1080, + ]; + } + private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool { if ($background === null) { @@ -446,6 +522,17 @@ private function backgroundPartTypeForAgendaItem(ServiceAgendaItem $item): strin : 'agenda_item'; } + private function isNameTagAgendaItem(ServiceAgendaItem $item): bool + { + $title = mb_strtolower($item->title ?? ''); + $type = mb_strtolower($item->type ?? ''); + + return str_contains($title, 'nametag') + || str_contains($title, 'namenseinblender') + || str_contains($type, 'nametag') + || str_contains($type, 'namenseinblender'); + } + protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void { ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles); diff --git a/tests/Feature/KeyVisualFallbackTest.php b/tests/Feature/KeyVisualFallbackTest.php new file mode 100644 index 0000000..fba0462 --- /dev/null +++ b/tests/Feature/KeyVisualFallbackTest.php @@ -0,0 +1,237 @@ +put('slides/keyvisual.jpg', 'keyvisual-image'); + + $service = Service::factory()->create([ + 'title' => 'Keyvisual Service', + 'date' => now(), + 'key_visual_filename' => 'slides/keyvisual.jpg', + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Begrüßung', + 'service_song_id' => null, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + + $slideCountBefore = Slide::count(); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + $embeddedProFiles = $playlist->getEmbeddedProFiles(); + $fallbackSong = $playlist->getEmbeddedSong('Begrüßung.pro'); + + $this->assertCount(1, $embeddedProFiles); + $this->assertNotNull($fallbackSong); + $this->assertSame($slideCountBefore, Slide::count()); + + $slides = $this->allParserSlides($fallbackSong); + $this->assertCount(1, $slides); + $this->assertTrue($slides[0]->hasBackgroundMedia()); + $this->assertSame(Storage::disk('public')->path('slides/keyvisual.jpg'), $slides[0]->getBackgroundMediaUrl()); + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_song_agenda_item_does_not_get_keyvisual_fallback(): void + { + Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image'); + + $service = Service::factory()->create([ + 'title' => 'Song Service', + 'date' => now(), + 'key_visual_filename' => 'slides/keyvisual.jpg', + ]); + $song = $this->createSongWithContent('Nur ein Lied'); + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Nur ein Lied', + 'order' => 1, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Nur ein Lied', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + + $this->assertCount(1, $playlist->getEmbeddedProFiles()); + $this->assertNotNull($playlist->getEmbeddedSong('Nur ein Lied.pro')); + $this->assertNull($playlist->getEmbeddedSong('Keyvisual.pro')); + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_agenda_item_with_uploaded_slides_exports_normally_without_extra_keyvisual_slide(): void + { + Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image'); + Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image'); + + $service = Service::factory()->create([ + 'title' => 'Slides Service', + 'date' => now(), + 'key_visual_filename' => 'slides/keyvisual.jpg', + ]); + $agendaItem = ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Predigt', + 'service_song_id' => null, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + + Slide::factory()->create([ + 'service_id' => $service->id, + 'service_agenda_item_id' => $agendaItem->id, + 'type' => 'sermon', + 'original_filename' => 'sermon.jpg', + 'stored_filename' => 'slides/sermon.jpg', + 'sort_order' => 0, + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + $sermonSong = $playlist->getEmbeddedSong('Predigt.pro'); + + $this->assertCount(1, $playlist->getEmbeddedProFiles()); + $this->assertNotNull($sermonSong); + + $slides = $this->allParserSlides($sermonSong); + $this->assertCount(1, $slides); + $this->assertFalse($slides[0]->hasBackgroundMedia()); + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_without_keyvisual_empty_non_song_item_adds_no_playlist_entry(): void + { + $service = Service::factory()->create([ + 'title' => 'Ohne Keyvisual', + 'date' => now(), + 'key_visual_filename' => null, + ]); + $song = $this->createSongWithContent('Vorhandenes Lied'); + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Vorhandenes Lied', + 'order' => 1, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Begrüßung ohne Folien', + 'service_song_id' => null, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Vorhandenes Lied', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 2, + 'is_before_event' => false, + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + + $this->assertCount(1, $playlist->getEmbeddedProFiles()); + $this->assertNotNull($playlist->getEmbeddedSong('Vorhandenes Lied.pro')); + $this->assertNull($playlist->getEmbeddedSong('Begrüßung ohne Folien.pro')); + + $this->cleanupTempDir($result['temp_dir']); + } + + private function createSongWithContent(string $title): Song + { + $song = Song::create([ + 'title' => $title, + 'ccli_id' => fake()->unique()->numerify('#####'), + 'author' => 'Test Author', + 'copyright_text' => 'Test Publisher', + ]); + + $label = Label::firstOrCreate( + ['name' => 'Verse 1 - '.$title], + ['color' => '#2196F3'], + ); + $label->songSlides()->create(['order' => 0, 'text_content' => 'Erste Zeile']); + + $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); + $arrangement->arrangementLabels()->create(['label_id' => $label->id, 'order' => 0]); + + return $song; + } + + private function allParserSlides(\ProPresenter\Parser\Song $parserSong): array + { + $slides = []; + + foreach ($parserSong->getGroups() as $group) { + foreach ($parserSong->getSlidesForGroup($group) as $slide) { + $slides[] = $slide; + } + } + + return $slides; + } + + private function cleanupTempDir(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + $items = scandir($dir); + if ($items === false) { + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $dir.'/'.$item; + is_dir($path) ? $this->cleanupTempDir($path) : unlink($path); + } + + rmdir($dir); + } +}