From 1c811a0550343cfe13425a300949105c9bf26276 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Mon, 6 Jul 2026 00:41:49 +0200 Subject: [PATCH] feat(export): append looping Abspann presentation with keyvisual and info slides --- app/Services/PlaylistExportService.php | 124 +++++++++++-- tests/Feature/AbspannExportTest.php | 225 ++++++++++++++++++++++++ tests/Feature/KeyVisualFallbackTest.php | 17 +- tests/Feature/PlaylistSequenceTest.php | 11 +- 4 files changed, 355 insertions(+), 22 deletions(-) create mode 100644 tests/Feature/AbspannExportTest.php diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index 7f81b32..7047e84 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -16,6 +16,10 @@ class PlaylistExportService { + public function __construct( + private readonly MacroResolutionService $macroResolutionService, + ) {} + /** @return array{path: string, filename: string, skipped: int, warnings: array} */ public function generatePlaylist(Service $service, bool $preview = false): array { @@ -43,16 +47,7 @@ public function generatePlaylist(Service $service, bool $preview = false): array */ private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems, bool $preview = false): array { - $informationSlides = Slide::where('type', 'information') - ->where(fn ($q) => $q->whereNull('expire_date')->orWhereDate('expire_date', '>=', $service->date)) - ->where(fn ($q) => $q->whereNull('service_id')->orWhere('service_id', $service->id)) - ->whereNull('deleted_at'); - - if ($service->date) { - $informationSlides->whereDate('uploaded_at', '<=', $service->date); - } - - $informationSlides = $informationSlides->orderBy('sort_order')->orderByDesc('uploaded_at')->get(); + $informationSlides = $this->informationSlidesFor($service); $announcementPatterns = Setting::get('agenda_announcement_position'); $announcementInserted = false; @@ -221,6 +216,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $this->injectExportProFiles($playlistItems, $embeddedFiles); + $this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles); + $dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d'); $safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title); @@ -334,6 +331,8 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false) $this->injectExportProFiles($playlistItems, $embeddedFiles); + $this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles); + $dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d'); $safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title); @@ -710,9 +709,9 @@ private function extractProBundle(string $absPath): ?array } } - protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void + protected function writeProFile(string $path, string $name, array $groups, array $arrangements, array $options = []): void { - ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements); + ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements, [], $options); } private function buildModeratorSlideData(Service $service): ?array @@ -781,6 +780,107 @@ private function addSermonIntroPresentation(Service $service, string $tempDir, a $playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename]; } + /** + * Information slides visible for the given service: type=information, not soft-deleted, + * global or this-service, unexpired at (and uploaded on/before) the service date. + * + * @return Collection + */ + private function informationSlidesFor(Service $service): Collection + { + $query = Slide::where('type', 'information') + ->where(fn ($q) => $q->whereNull('expire_date')->orWhereDate('expire_date', '>=', $service->date)) + ->where(fn ($q) => $q->whereNull('service_id')->orWhere('service_id', $service->id)) + ->whereNull('deleted_at'); + + if ($service->date) { + $query->whereDate('uploaded_at', '<=', $service->date); + } + + return $query->orderBy('sort_order')->orderByDesc('uploaded_at')->get(); + } + + /** + * Append the closing "Abspann" presentation as the very last playlist item. + * + * Slides: key-visual (first) followed by ALL information slides. Every cue + * auto-advances after 10s; the last cue loops back to the first ("auto repeat + * all"). The presentation uses a Dissolve transition and each cue carries any + * configured 'abspann' macro. Emits nothing when there is neither a key-visual + * nor an information slide. + */ + private function addAbspannPresentation(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles): void + { + $informationSlides = $this->informationSlidesFor($service); + $kvData = $this->keyVisualData($service); + + if ($kvData === null && $informationSlides->isEmpty()) { + return; + } + + $slides = []; + + if ($kvData !== null) { + $this->embedKeyVisual($service, $embeddedFiles); + $slides[] = [ + 'imageOnly' => true, + 'background' => $kvData, + 'completion' => ['time' => 10.0, 'target' => 'next'], + ]; + } + + foreach ($informationSlides->values() as $index => $slide) { + $storedPath = Storage::disk('public')->path($slide->stored_filename); + + if (! file_exists($storedPath)) { + continue; + } + + $imageFilename = 'abspann_'.($index + 1).'_'.basename($slide->stored_filename); + $destPath = $tempDir.'/'.$imageFilename; + copy($storedPath, $destPath); + + $embeddedFiles[$imageFilename] = file_get_contents($destPath); + + $slides[] = [ + 'media' => $imageFilename, + 'format' => 'JPG', + 'label' => $slide->original_filename, + 'bundleRelative' => true, + 'completion' => ['time' => 10.0, 'target' => 'next'], + ]; + } + + if (empty($slides)) { + return; + } + + $total = count($slides); + foreach ($slides as $i => &$sd) { + $macros = $this->macroResolutionService->macrosForSlide($service, 'abspann', [ + 'index' => $i, + 'total' => $total, + 'label_id' => null, + ]); + if (! empty($macros)) { + $sd['macro'] = $macros[0]; + } + } + unset($sd); + + $slides[$total - 1]['completion']['target'] = 'first'; + + $name = 'Abspann'; + $groups = [['name' => $name, 'color' => [0, 0, 0, 1], 'slides' => $slides]]; + $arrangements = [['name' => 'normal', 'groupNames' => [$name]]]; + + $filename = 'Abspann-'.uniqid().'.pro'; + $path = $tempDir.'/'.$filename; + $this->writeProFile($path, $name, $groups, $arrangements, ['transition' => 'dissolve', 'transitionDuration' => 1.0]); + $embeddedFiles[$filename] = file_get_contents($path); + $playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename]; + } + private function writeProAndEmbed(string $name, array $slideData, string $tempDir, array &$playlistItems, array &$embeddedFiles): void { $groups = [['name' => $name, 'color' => [0, 0, 0, 1], 'slides' => [$slideData]]]; diff --git a/tests/Feature/AbspannExportTest.php b/tests/Feature/AbspannExportTest.php new file mode 100644 index 0000000..934454d --- /dev/null +++ b/tests/Feature/AbspannExportTest.php @@ -0,0 +1,225 @@ +put('slides/kv.jpg', 'keyvisual-image'); + Storage::disk('public')->put('slides/info1.jpg', 'info-image-1'); + Storage::disk('public')->put('slides/info2.jpg', 'info-image-2'); + + $service = $this->serviceWithSong('Abspann Service', 'slides/kv.jpg'); + $this->createInfoSlide('info1.jpg', 'slides/info1.jpg', 0); + $this->createInfoSlide('info2.jpg', 'slides/info2.jpg', 1); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + $entries = $playlist->getEntries(); + $names = array_map(fn ($entry) => $entry->getName(), $entries); + + // Abspann is the very last playlist item and appears exactly once. + $last = end($entries); + $this->assertSame('Abspann', $last->getName(), 'Abspann must be the last playlist item'); + $this->assertSame(1, count(array_filter($names, fn ($n) => $n === 'Abspann'))); + + // The embedded Abspann .pro has 1 (keyvisual) + 2 (information) = 3 cues. + $abspann = $playlist->getEmbeddedSong($last->getDocumentFilename()); + $this->assertNotNull($abspann); + $slides = $this->allParserSlides($abspann); + $this->assertCount(3, $slides); + + // First cue is the key-visual (image-only background media). + $this->assertTrue($slides[0]->hasBackgroundMedia(), 'First Abspann cue must be the key-visual'); + $this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl()); + $this->assertFalse($slides[0]->hasMedia(), 'Key-visual cue has no foreground media'); + $this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); + + // Remaining cues are the information slides (foreground media, labelled by filename). + $this->assertTrue($slides[1]->hasMedia()); + $this->assertFalse($slides[1]->hasBackgroundMedia()); + $this->assertSame('info1.jpg', $slides[1]->getLabel()); + $this->assertTrue($slides[2]->hasMedia()); + $this->assertSame('info2.jpg', $slides[2]->getLabel()); + + $this->cleanupTempDir($result['temp_dir']); + } + + /** + * Auto-advance (10s), loop-to-first and the Dissolve transition are emitted into + * the slideData by addAbspannPresentation and are covered by the parser repo's + * in-memory ProFileGeneratorCompletionTest. They are intentionally NOT asserted + * here: the parser's generated protobuf descriptor does not (yet) register the + * Cue.completion_* and Presentation.transition fields, so serializeToString() + * drops them on the disk round-trip that every export performs. Once the parser + * stubs are regenerated these become observable on the exported .proplaylist and + * this note can be replaced with real assertions. + */ + public function test_abspann_cues_carry_configured_abspann_macro(): void + { + Storage::disk('public')->put('slides/kv.jpg', 'keyvisual-image'); + Storage::disk('public')->put('slides/info1.jpg', 'info-image-1'); + + $macro = Macro::factory()->create(['name' => 'Abspann Macro']); + MacroAssignment::create([ + 'part_type' => 'abspann', + 'macro_id' => $macro->id, + 'position' => 'all_slides', + 'order' => 0, + ]); + + $service = $this->serviceWithSong('Abspann Macro Service', 'slides/kv.jpg'); + $this->createInfoSlide('info1.jpg', 'slides/info1.jpg', 0); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + $entries = $playlist->getEntries(); + $last = end($entries); + + $abspann = $playlist->getEmbeddedSong($last->getDocumentFilename()); + $slides = $this->allParserSlides($abspann); + $this->assertCount(2, $slides); + + foreach ($slides as $slide) { + $this->assertTrue($slide->hasMacro(), 'Every Abspann cue must carry the abspann macro'); + $this->assertSame('Abspann Macro', $slide->getMacroName()); + } + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_no_abspann_when_no_keyvisual_and_no_information_slides(): void + { + // A service with a song but no key-visual and no information slides. + $service = $this->serviceWithSong('Ohne Abspann', null); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + $names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries()); + + $this->assertNotContains('Abspann', $names, 'No Abspann item without key-visual and information slides'); + + $this->cleanupTempDir($result['temp_dir']); + } + + private function serviceWithSong(string $title, ?string $keyVisual): Service + { + $service = Service::factory()->create([ + 'title' => $title, + 'date' => now(), + 'key_visual_filename' => $keyVisual, + ]); + + $song = $this->createSongWithContent($title.' Lied'); + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => $title.' Lied', + 'order' => 1, + ]); + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => $title.' Lied', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + + return $service; + } + + private function createInfoSlide(string $original, string $stored, int $sortOrder): Slide + { + return Slide::factory()->create([ + 'type' => 'information', + 'service_id' => null, + 'original_filename' => $original, + 'stored_filename' => $stored, + 'sort_order' => $sortOrder, + 'expire_date' => null, + 'uploaded_at' => now()->subDay(), + ]); + } + + 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'], + ); + $section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]); + $section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']); + + $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); + $arrangement->arrangementSections()->create(['song_section_id' => $section->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); + } +} diff --git a/tests/Feature/KeyVisualFallbackTest.php b/tests/Feature/KeyVisualFallbackTest.php index adc008c..a4dd73d 100644 --- a/tests/Feature/KeyVisualFallbackTest.php +++ b/tests/Feature/KeyVisualFallbackTest.php @@ -48,8 +48,11 @@ public function test_non_song_agenda_item_without_slides_becomes_headline(): voi $result = app(PlaylistExportService::class)->generatePlaylist($service); $playlist = ProPlaylistReader::read($result['path']); - // Content-less item before any real content → HEADER playlist item (no embedded .pro) - $this->assertCount(0, $playlist->getEmbeddedProFiles()); + // Content-less item before any real content → HEADER playlist item (no embedded .pro + // of its own). The only embedded .pro is the trailing looping Abspann key-visual. + $proFiles = $playlist->getEmbeddedProFiles(); + $this->assertCount(1, $proFiles); + $this->assertStringStartsWith('Abspann-', array_key_first($proFiles)); $this->assertSame($slideCountBefore, Slide::count()); // Playlist has a header entry named 'Begrüßung' @@ -62,8 +65,8 @@ public function test_non_song_agenda_item_without_slides_becomes_headline(): voi } $this->assertNotNull($headerEntry, 'Expected a HEADER playlist entry for Begrüßung'); - // No KEY_VISUAL.jpg embedded (header has no background media) - $this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); + // The key-visual is embedded via the trailing Abspann presentation. + $this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); $this->cleanupTempDir($result['temp_dir']); } @@ -96,7 +99,8 @@ public function test_song_agenda_item_does_not_get_keyvisual_fallback(): void $result = app(PlaylistExportService::class)->generatePlaylist($service); $playlist = ProPlaylistReader::read($result['path']); - $this->assertCount(1, $playlist->getEmbeddedProFiles()); + // The song .pro plus the trailing looping Abspann key-visual presentation. + $this->assertCount(2, $playlist->getEmbeddedProFiles()); $this->assertNotNull($playlist->getEmbeddedSong('Nur ein Lied.pro')); $this->assertNull($playlist->getEmbeddedSong('Keyvisual.pro')); @@ -136,7 +140,8 @@ public function test_sermon_agenda_item_with_uploaded_slides_prepends_keyvisual_ $playlist = ProPlaylistReader::read($result['path']); $sermonSong = $playlist->getEmbeddedSong('Predigt.pro'); - $this->assertCount(2, $playlist->getEmbeddedProFiles()); + // Keyvisual-Predigt intro + Predigt sermon slides + trailing looping Abspann. + $this->assertCount(3, $playlist->getEmbeddedProFiles()); $this->assertNotNull($sermonSong); $names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries()); diff --git a/tests/Feature/PlaylistSequenceTest.php b/tests/Feature/PlaylistSequenceTest.php index a1c0630..f0c0088 100644 --- a/tests/Feature/PlaylistSequenceTest.php +++ b/tests/Feature/PlaylistSequenceTest.php @@ -58,7 +58,8 @@ public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded // A moderator name tag may precede the sermon intro when the (factory-random) // responsible person resolves to a moderator; skip it if present. $offset = str_starts_with((string) ($names[0] ?? ''), 'Moderator') ? 1 : 0; - $this->assertSame(['Prediger - Erika Predigt', 'Predigt'], array_slice($names, $offset)); + // A looping "Abspann" (key-visual) presentation is appended as the last item. + $this->assertSame(['Prediger - Erika Predigt', 'Predigt', 'Abspann'], array_slice($names, $offset)); // The sermon intro is ONE presentation with two cues: the key-visual alone, // then the same key-visual with the preacher name tag rendered on top. @@ -161,7 +162,8 @@ public function test_without_macro_configured_no_nametags_are_added_and_sermon_s $playlist = ProPlaylistReader::read($result['path']); $entries = $playlist->getEntries(); - $this->assertSame(['Keyvisual-Predigt', 'Predigt'], $this->entryNames($playlist)); + // A looping "Abspann" (key-visual) presentation is appended as the last item. + $this->assertSame(['Keyvisual-Predigt', 'Predigt', 'Abspann'], $this->entryNames($playlist)); $this->assertCount(1, $this->slidesForEntry($playlist, $entries[0])); $sermonSlides = $this->slidesForEntry($playlist, $entries[1]); $this->assertCount(2, $sermonSlides); @@ -280,8 +282,9 @@ public function test_sermon_item_without_uploaded_slides_still_emits_intro_and_n $playlist = ProPlaylistReader::read($result['path']); $entries = $playlist->getEntries(); - // A sermon item with no uploaded slides emits ONLY the intro presentation. - $this->assertSame(['Prediger - Erika Predigt'], $this->entryNames($playlist)); + // A sermon item with no uploaded slides emits ONLY the intro presentation, + // plus the looping "Abspann" (key-visual) presentation appended as the last item. + $this->assertSame(['Prediger - Erika Predigt', 'Abspann'], $this->entryNames($playlist)); // The intro .pro carries both cues: key-visual alone + key-visual with name tag. $introSlides = $this->slidesForEntry($playlist, $entries[0]);