From c3c8f7f50df5474047be6aad8e816331cb2dd7ec Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Mon, 3 Aug 2026 08:59:54 +0200 Subject: [PATCH] fix(export): restructure keyvisual/nametag sequence and macro placement - MacroResolutionService: rank by position specificity (by_label > first/last > all_slides) so a broad all_slides macro no longer masks first_slide/last_slide assignments - ProBundleExportService: resolve macros in a second pass over emitted slides, so last_slide still matches when files were skipped - PlaylistExportService: keyvisual is now a transition between content items instead of a one-shot fallback; nametags use a unified 3-cue sequence (kv -> kv+name 10s auto-next -> kv) for moderator and preacher - PlaylistExportService: prepend countdown standby slide (clock + kv) when the add_countdown_slide setting is enabled --- app/Services/MacroResolutionService.php | 26 +- app/Services/PlaylistExportService.php | 287 ++++++++++++++++----- app/Services/ProBundleExportService.php | 21 +- tests/Feature/CountdownSlideExportTest.php | 180 +++++++++++++ tests/Feature/PlaylistExportTest.php | 21 +- tests/Feature/PlaylistSequenceTest.php | 145 ++++++++++- tests/Feature/PlaylistSermonMacroTest.php | 16 +- tests/Feature/ProFileExportTest.php | 75 ++++++ 8 files changed, 672 insertions(+), 99 deletions(-) create mode 100644 tests/Feature/CountdownSlideExportTest.php diff --git a/app/Services/MacroResolutionService.php b/app/Services/MacroResolutionService.php index 3e8b56e..f5d18ed 100644 --- a/app/Services/MacroResolutionService.php +++ b/app/Services/MacroResolutionService.php @@ -60,7 +60,31 @@ public function macrosForSlide(Service $service, string $partType, array $slideC }; }); - return $matched->map(fn ($a) => $this->toExportArray($a->macro))->values()->all(); + // The ProPresenter parser stores only ONE macro per slide (consumers keep + // $macros[0]). Order the matches so the MOST SPECIFIC position wins, + // independent of the configured `order`: by_label > first_slide/last_slide + // > all_slides. Ties break by `order`. This stops a broad all_slides + // assignment from masking a first_slide/last_slide macro on the very + // first/last slide of a part. + $sorted = $matched + ->sortBy(fn ($a) => $this->positionSpecificity($a->position) * 100000 + (int) $a->order) + ->values(); + + return $sorted->map(fn ($a) => $this->toExportArray($a->macro))->values()->all(); + } + + /** + * Precedence rank for a macro position when several assignments match a single + * slide and only one macro can be emitted. Lower rank = more specific = wins. + */ + private function positionSpecificity(string $position): int + { + return match ($position) { + 'by_label' => 0, + 'first_slide', 'last_slide' => 1, + 'all_slides' => 2, + default => 3, + }; } /** diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index 21684cc..084792b 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -65,9 +65,13 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $moderatorSlideData = $this->buildModeratorSlideData($service); $firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id; + $kvData = $this->keyVisualData($service); $realContentEmitted = false; - $keyvisualFallbackEmitted = false; + // Whether the previous emission ended on a key-visual slide. Used so a + // key-visual transition between items is not duplicated at a position a + // name-tag sequence already frames with the key-visual. + $lastEndedWithKv = false; $keywordFiles = ExportProFile::keyword()->orderBy('order')->get(); @@ -75,9 +79,14 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) { $moderatorName = trim((string) ($moderatorSlideData['text'] ?? '')); $moderatorPresentationName = $moderatorName !== '' ? 'Moderator - '.$moderatorName : 'Moderator'; - $this->writeProAndEmbed( + // The moderator name tag renders as the unified sequence: with a + // key-visual it is a 3-cue presentation (key-visual → name tag → + // key-visual); without one, a single name-tag slide. + $lastEndedWithKv = $this->addNameTagSequence( $moderatorPresentationName, $moderatorSlideData, + $kvData, + $service, $tempDir, $playlistItems, $embeddedFiles, @@ -88,6 +97,9 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $patterns = array_map('trim', explode(',', $announcementPatterns)); $matcher = app(AgendaMatcherService::class); if ($matcher->matchesAny($item->title, $patterns)) { + if ($this->shouldInsertKvTransition($realContentEmitted, $lastEndedWithKv, $kvData)) { + $this->addKeyVisualTransition($service, $kvData, $tempDir, $playlistItems, $embeddedFiles); + } $this->addSlidesFromCollection( $informationSlides, 'information', @@ -100,6 +112,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda 'information', ); $announcementInserted = true; + $realContentEmitted = true; + $lastEndedWithKv = false; } } @@ -113,6 +127,10 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $skippedUnmatched++; $warnings[] = "Lied '".$song->title."' übersprungen: keine Inhaltsfolien."; } else { + if ($this->shouldInsertKvTransition($realContentEmitted, $lastEndedWithKv, $kvData)) { + $this->addKeyVisualTransition($service, $kvData, $tempDir, $playlistItems, $embeddedFiles); + } + $selectedArr = $serviceSong->arrangement ?? ($song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first()); @@ -135,6 +153,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $playlistItems[] = $playlistItem; $realContentEmitted = true; + $lastEndedWithKv = false; } } else { $skippedUnmatched++; @@ -144,11 +163,29 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $isSermon = $partType === 'sermon'; if ($isSermon) { - $this->addSermonIntroPresentation($service, $tempDir, $playlistItems, $embeddedFiles); + // The sermon sequence starts with the preacher name-tag / key-visual + // intro, which already begins (and ends) on the key-visual, so NO + // separate key-visual transition is inserted before it. + $introEndedWithKv = $this->addSermonIntroPresentation( + $service, + $kvData, + $tempDir, + $playlistItems, + $embeddedFiles, + ); + if ($introEndedWithKv) { + $lastEndedWithKv = true; + } if ($item->slides->isNotEmpty()) { $countBefore = count($playlistItems); $label = $item->title ?: 'Folien'; + // 'sermon' position macros (first_slide / last_slide) anchor to the + // sermon CONTENT slides emitted here, NOT to the key-visual/name-tag + // intro presentation above (which carries only the Namenseinblender + // macro). Rule: first_slide fires on the first actual preaching slide + // and last_slide on the last, so a "trigger scene at sermon start" + // macro lands when the content begins rather than during the intro. $this->addSlidesFromCollection( $item->slides, 'agenda_'.$item->id, @@ -163,9 +200,15 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda if (count($playlistItems) > $countBefore) { $realContentEmitted = true; + $lastEndedWithKv = false; } } } elseif ($item->slides->isNotEmpty()) { + if ($this->shouldInsertKvTransition($realContentEmitted, $lastEndedWithKv, $kvData)) { + $this->addKeyVisualTransition($service, $kvData, $tempDir, $playlistItems, $embeddedFiles); + $lastEndedWithKv = true; + } + $countBefore = count($playlistItems); $label = $item->title ?: 'Folien'; $this->addSlidesFromCollection( @@ -182,23 +225,16 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda if (count($playlistItems) > $countBefore) { $realContentEmitted = true; + $lastEndedWithKv = false; } } elseif (! $this->isNameTagAgendaItem($item)) { - if ($realContentEmitted && ! $keyvisualFallbackEmitted) { - $this->addKeyVisualFallbackPresentation( - $item, - $service, - $tempDir, - $playlistItems, - $embeddedFiles, - ); - $keyvisualFallbackEmitted = true; - } else { - $this->addHeadlineItem( - $item, - $playlistItems, - ); - } + // Content-less, non-name-tag item → plain headline. The key-visual + // now appears as a transition BETWEEN real content items (see + // addKeyVisualTransition), replacing the former one-shot fallback. + $this->addHeadlineItem( + $item, + $playlistItems, + ); } } @@ -212,6 +248,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $keywordItem = $this->embedExportProFile($kf, $embeddedFiles, $warnings); if ($keywordItem !== null) { $playlistItems[] = $keywordItem; + $realContentEmitted = true; + $lastEndedWithKv = false; } } } @@ -256,6 +294,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $outputFilename = $dateFormatted.' '.$safeTitle.'.proplaylist'; } + $this->addCountdownPresentation($service, $tempDir, $playlistItems, $embeddedFiles); + $outputPath = $tempDir.'/'.$outputFilename; $this->writePlaylistFile($outputPath, $playlistName, $playlistItems, $embeddedFiles); @@ -383,6 +423,8 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false) $outputFilename = $dateFormatted.' '.$safeTitle.'.proplaylist'; } + $this->addCountdownPresentation($service, $tempDir, $playlistItems, $embeddedFiles); + $outputPath = $tempDir.'/'.$outputFilename; $this->writePlaylistFile($outputPath, $playlistName, $playlistItems, $embeddedFiles); @@ -545,30 +587,39 @@ private function addSlidePresentation( ); } - private function addKeyVisualFallbackPresentation( - ServiceAgendaItem $item, + /** + * Whether a standalone key-visual transition should be inserted before the next + * real-content emission: only once real content exists (no leading key-visual), + * only when the previous emission did not already end on a key-visual, and only + * when a key-visual is resolvable. + */ + private function shouldInsertKvTransition(bool $realContentEmitted, bool $lastEndedWithKv, ?array $kvData): bool + { + return $realContentEmitted && ! $lastEndedWithKv && $kvData !== null; + } + + /** + * Emit a standalone key-visual transition presentation (an image-only slide with + * the key-visual as a BACKGROUND media action) between two real content items. + */ + private function addKeyVisualTransition( Service $service, + array $kvData, string $tempDir, array &$playlistItems, array &$embeddedFiles, ): void { - $background = $this->keyVisualData($service); - - if ($background === null) { - return; - } - $this->embedKeyVisual($service, $embeddedFiles); - $label = $item->title ?: 'Keyvisual'; + $name = 'Keyvisual'; $groups = [ [ - 'name' => 'Keyvisual', + 'name' => $name, 'color' => [0, 0, 0, 1], 'slides' => [ [ 'imageOnly' => true, - 'background' => $background, + 'background' => $kvData, ], ], ], @@ -576,25 +627,89 @@ private function addKeyVisualFallbackPresentation( $arrangements = [ [ 'name' => 'normal', - 'groupNames' => ['Keyvisual'], + 'groupNames' => [$name], ], ]; - $safeLabel = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $label); - $proFilename = $safeLabel.'.pro'; + $proFilename = 'Keyvisual-'.uniqid().'.pro'; $proPath = $tempDir.'/'.$proFilename; - $this->writeProFile($proPath, $label, $groups, $arrangements); + $this->writeProFile($proPath, $name, $groups, $arrangements); $embeddedFiles[$proFilename] = file_get_contents($proPath); $playlistItems[] = [ 'type' => 'presentation', - 'name' => $label, + 'name' => $name, 'path' => $proFilename, ]; } + /** + * Prepend the pre-service COUNTDOWN standby presentation as the very FIRST + * playlist item. It is a single image-only slide: the key-visual as a + * background media layer plus a live wall-clock (hh:mm, 24h) text element in + * the upper-left corner. + * + * Only emitted when the `add_countdown_slide` setting is enabled AND a + * key-visual is resolvable; without a key-visual the clock has no backdrop + * and the slide is skipped gracefully. + */ + private function addCountdownPresentation( + Service $service, + string $tempDir, + array &$playlistItems, + array &$embeddedFiles, + ): void { + if (Setting::get('add_countdown_slide') !== '1') { + return; + } + + $kvData = $this->keyVisualData($service); + if ($kvData === null) { + return; + } + + $this->embedKeyVisual($service, $embeddedFiles); + + $name = 'Countdown'; + $groups = [ + [ + 'name' => $name, + 'color' => [0, 0, 0, 1], + 'slides' => [ + [ + 'imageOnly' => true, + 'background' => $kvData, + 'clock' => [ + 'format' => 'HH:mm', + 'bounds' => ['x' => 60, 'y' => 40, 'width' => 600, 'height' => 200], + ], + ], + ], + ], + ]; + $arrangements = [ + [ + 'name' => 'normal', + 'groupNames' => [$name], + ], + ]; + + $proFilename = 'Countdown-'.uniqid().'.pro'; + $proPath = $tempDir.'/'.$proFilename; + + $this->writeProFile($proPath, $name, $groups, $arrangements); + + $embeddedFiles[$proFilename] = file_get_contents($proPath); + + array_unshift($playlistItems, [ + 'type' => 'presentation', + 'name' => $name, + 'path' => $proFilename, + ]); + } + private function addHeadlineItem( ServiceAgendaItem $item, array &$playlistItems, @@ -867,49 +982,94 @@ private function buildPreacherSlideData(Service $service): ?array } /** - * Emit the sermon intro as ONE presentation with up to two cues: - * slide 1 = key-visual (image only) - * slide 2 = key-visual WITH the preacher name tag rendered on top + * Emit the sermon intro as ONE presentation that begins (and ends) on the + * key-visual: + * - With a preacher name tag: the unified 3-cue name-tag sequence + * (key-visual → name tag (10s auto-next) → key-visual), named "Prediger - ". + * - Without a name tag but with a key-visual: a single key-visual slide named + * "Keyvisual-Predigt". + * - With neither: nothing. * - * The presentation is named after the preacher ("Prediger - ") when a - * name tag is configured, otherwise "Keyvisual-Predigt". Emits nothing when - * neither a key-visual nor a name tag is available. + * Returns true when a presentation ending on a key-visual slide was emitted. */ - private function addSermonIntroPresentation(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles): void + private function addSermonIntroPresentation(Service $service, ?array $kvData, string $tempDir, array &$playlistItems, array &$embeddedFiles): bool { - $kvData = $this->keyVisualData($service); $nameTagData = $this->buildPreacherSlideData($service); if ($kvData === null && $nameTagData === null) { - return; - } - - $slides = []; - - if ($kvData !== null) { - $this->embedKeyVisual($service, $embeddedFiles); - $slides[] = ['imageOnly' => true, 'background' => $kvData]; + return false; } if ($nameTagData !== null) { - if ($kvData !== null) { - $nameTagData['background'] = $kvData; - } - $slides[] = $nameTagData; - $preacher = trim((string) ($nameTagData['text'] ?? '')); $name = $preacher !== '' ? 'Prediger - '.$preacher : 'Prediger'; - } else { - $name = 'Keyvisual-Predigt'; + + return $this->addNameTagSequence($name, $nameTagData, $kvData, $service, $tempDir, $playlistItems, $embeddedFiles); } - $groups = [['name' => $name, 'color' => [0, 0, 0, 1], 'slides' => $slides]]; + // Key-visual present, no name tag → a single key-visual slide. + $this->embedKeyVisual($service, $embeddedFiles); + $name = 'Keyvisual-Predigt'; + $groups = [['name' => $name, 'color' => [0, 0, 0, 1], 'slides' => [['imageOnly' => true, 'background' => $kvData]]]]; $arrangements = [['name' => 'normal', 'groupNames' => [$name]]]; $filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name).'-'.uniqid().'.pro'; $path = $tempDir.'/'.$filename; $this->writeProFile($path, $name, $groups, $arrangements); $embeddedFiles[$filename] = file_get_contents($path); $playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename]; + + return true; + } + + /** + * Emit a name tag as ONE presentation. With a resolvable key-visual it is a + * THREE-cue sequence: + * cue 1 = key-visual only (image, no text) + * cue 2 = name tag over the key-visual, its Namenseinblender macro, 10s auto-next + * cue 3 = key-visual only + * Without a key-visual it degrades to a single name-tag text slide. + * + * Returns true when the presentation ends on a key-visual slide (the 3-cue form + * was emitted); used to de-duplicate key-visual transitions between items. + * + * @param array $nameTagData + * @param array|null $kvData + */ + private function addNameTagSequence( + string $presentationName, + array $nameTagData, + ?array $kvData, + Service $service, + string $tempDir, + array &$playlistItems, + array &$embeddedFiles, + ): bool { + $endsWithKv = $kvData !== null; + + if ($endsWithKv) { + $this->embedKeyVisual($service, $embeddedFiles); + + $nameTagData['background'] = $kvData; + $nameTagData['completion'] = ['time' => 10.0, 'target' => 'next']; + + $slides = [ + ['imageOnly' => true, 'background' => $kvData], + $nameTagData, + ['imageOnly' => true, 'background' => $kvData], + ]; + } else { + $slides = [$nameTagData]; + } + + $groups = [['name' => $presentationName, 'color' => [0, 0, 0, 1], 'slides' => $slides]]; + $arrangements = [['name' => 'normal', 'groupNames' => [$presentationName]]]; + $filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $presentationName).'-'.uniqid().'.pro'; + $path = $tempDir.'/'.$filename; + $this->writeProFile($path, $presentationName, $groups, $arrangements); + $embeddedFiles[$filename] = file_get_contents($path); + $playlistItems[] = ['type' => 'presentation', 'name' => $presentationName, 'path' => $filename]; + + return $endsWithKv; } /** @@ -1013,17 +1173,6 @@ private function addAbspannPresentation(Service $service, string $tempDir, array $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]]]; - $arrangements = [['name' => 'normal', 'groupNames' => [$name]]]; - $filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name).'-'.uniqid().'.pro'; - $path = $tempDir.'/'.$filename; - $this->writeProFile($path, $name, $groups, $arrangements); - $embeddedFiles[$filename] = file_get_contents($path); - $playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename]; - } - /** * Number of content slides in the song's default arrangement. * Content = SongSlide rows belonging to NON-locked sections (locked false/null) diff --git a/app/Services/ProBundleExportService.php b/app/Services/ProBundleExportService.php index 95058af..5fe367d 100644 --- a/app/Services/ProBundleExportService.php +++ b/app/Services/ProBundleExportService.php @@ -126,22 +126,29 @@ private function buildBundleFromSlides( $backgroundAttached = true; } - if ($service !== null && $partType !== null) { - $slideIndex = count($slideData); - $totalSlides = $slides->count(); + $slideData[] = $singleSlideData; + } + + // Second pass: position-aware macros over the ACTUALLY EMITTED slides so + // first_slide/last_slide anchor to the true first/last visible slide even + // when some source files were skipped (missing on disk). Using the raw + // $slides->count() as total would make last_slide never match once a slide + // is skipped. + if ($service !== null && $partType !== null) { + $total = count($slideData); + foreach ($slideData as $i => &$sd) { $macros = $this->macroResolutionService->macrosForSlide( $service, $partType, - ['index' => $slideIndex, 'total' => $totalSlides, 'label_id' => null], + ['index' => $i, 'total' => $total, 'label_id' => null], ); if (! empty($macros)) { // ProPresenter parser currently supports one `macro` entry per slide - $singleSlideData['macro'] = $macros[0]; + $sd['macro'] = $macros[0]; } } - - $slideData[] = $singleSlideData; + unset($sd); } if ($backgroundAttached) { diff --git a/tests/Feature/CountdownSlideExportTest.php b/tests/Feature/CountdownSlideExportTest.php new file mode 100644 index 0000000..e7007ee --- /dev/null +++ b/tests/Feature/CountdownSlideExportTest.php @@ -0,0 +1,180 @@ +put('slides/kv.jpg', 'keyvisual-image'); + + $service = $this->serviceWithSong('Mit Countdown', 'slides/kv.jpg'); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + $entries = $playlist->getEntries(); + + $first = $entries[0]; + $this->assertSame('Countdown', $first->getName(), 'Countdown must be the very first playlist item'); + $this->assertTrue($first->isPresentation()); + + $countdownSong = $playlist->getEmbeddedSong($first->getDocumentFilename()); + $this->assertNotNull($countdownSong, 'Embedded Countdown .pro missing'); + + $slides = $this->allParserSlides($countdownSong); + $this->assertCount(1, $slides); + + $slide = $slides[0]; + $this->assertTrue($slide->hasClock(), 'Countdown slide must carry a live clock element'); + $this->assertSame('HH:mm', $slide->getClockFormat()); + $this->assertTrue($slide->hasBackgroundMedia(), 'Countdown slide must have the key-visual as background'); + $this->assertSame('KEY_VISUAL.jpg', $slide->getBackgroundMediaUrl()); + + $embeddedMedia = $playlist->getEmbeddedMediaFiles(); + $this->assertArrayHasKey('KEY_VISUAL.jpg', $embeddedMedia, 'Key-visual must be embedded under fixed name'); + $this->assertSame('keyvisual-image', $embeddedMedia['KEY_VISUAL.jpg']); + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_no_countdown_slide_when_setting_disabled(): void + { + Setting::set('add_countdown_slide', '0'); + + Storage::disk('public')->put('slides/kv.jpg', 'keyvisual-image'); + + $service = $this->serviceWithSong('Ohne Countdown', 'slides/kv.jpg'); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + $names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries()); + + $this->assertNotContains('Countdown', $names); + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_no_countdown_slide_when_enabled_but_no_keyvisual(): void + { + Setting::set('add_countdown_slide', '1'); + + $service = $this->serviceWithSong('Countdown ohne KV', null); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + $names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries()); + + $this->assertNotContains('Countdown', $names); + + $this->cleanupTempDir($result['temp_dir']); + } + + private function serviceWithSong(string $title, ?string $keyVisualFilename): Service + { + $service = Service::factory()->create([ + 'title' => $title, + 'date' => now(), + 'key_visual_filename' => $keyVisualFilename, + 'background_filename' => null, + 'has_agenda' => true, + ]); + + $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 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/PlaylistExportTest.php b/tests/Feature/PlaylistExportTest.php index 70c68dd..29f49ee 100644 --- a/tests/Feature/PlaylistExportTest.php +++ b/tests/Feature/PlaylistExportTest.php @@ -89,7 +89,10 @@ protected function writePlaylistFile(string $path, string $name, array $items, a { $content = 'mock-playlist:'.$name; foreach ($items as $item) { - $content .= "\n".$item['name']; + // Prefix mit dem Item-Typ, damit Tests einen Headline-Header + // ('header') von einer Inhalts-Präsentation ('presentation') + // unterscheiden können. + $content .= "\n".($item['type'] ?? 'presentation').':'.$item['name']; } file_put_contents($path, $content); } @@ -419,12 +422,16 @@ public function test_agenda_export_content_less_items_werden_als_headline_oder_u $this->assertEquals(0, $result['skipped']); $playlistContent = file_get_contents($result['path']); - // Song always appears - $this->assertStringContainsString('Einziger Song', $playlistContent); - // Begrüßung (before real content) → HEADLINE → appears in playlist - $this->assertStringContainsString('Begrüßung', $playlistContent); - // Gebet (first content-less item after real content, no keyvisual) → omitted - $this->assertStringNotContainsString('Gebet', $playlistContent); + // Song mit Inhalt → Präsentation, erscheint immer. + $this->assertStringContainsString('presentation:Einziger Song', $playlistContent); + // Inhaltsloser Nicht-Namenseinblender-Punkt → Headline-Header (type 'header', + // keine Folien) – unabhängig von der Position: vor UND nach echtem Inhalt. + $this->assertStringContainsString('header:Begrüßung', $playlistContent); + $this->assertStringContainsString('header:Gebet', $playlistContent); + // Gebet darf KEINE (leere) Inhalts-Präsentation erzeugen, nur einen Header. + $this->assertStringNotContainsString('presentation:Gebet', $playlistContent); + // Ohne gesetztes Keyvisual wird KEIN Keyvisual-Übergang eingefügt. + $this->assertStringNotContainsString('Keyvisual', $playlistContent); $this->cleanupTempDir($result['temp_dir']); } diff --git a/tests/Feature/PlaylistSequenceTest.php b/tests/Feature/PlaylistSequenceTest.php index 705d928..1a96fc1 100644 --- a/tests/Feature/PlaylistSequenceTest.php +++ b/tests/Feature/PlaylistSequenceTest.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Storage; use ProPresenter\Parser\PlaylistArchive; use ProPresenter\Parser\ProPlaylistReader; +use Rv\Data\Cue\CompletionTargetType; use Tests\Support\InspectsSlideFillMedia; use Tests\TestCase; @@ -63,19 +64,21 @@ public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded // 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. + // The sermon intro is ONE presentation with THREE cues: the key-visual + // alone, the key-visual with the preacher name tag on top (auto-advancing + // after 10s), then the key-visual alone again. $introSlides = $this->slidesForEntry($playlist, $entries[$offset]); - $this->assertCount(2, $introSlides); + $this->assertCount(3, $introSlides); - // Key-visual is a BACKGROUND media action on both intro cues (no content element). + // Cue 1: key-visual as a BACKGROUND media action (no content element). $this->assertTrue($introSlides[0]->hasBackgroundMedia()); $this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl()); $this->assertSame([], $this->fillMediaUrls($introSlides[0])); $this->assertFalse($this->hasForegroundImageMediaAction($introSlides[0])); $this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); - // Second cue: key-visual as a BACKGROUND media action with the name-tag text element above it. + // Cue 2: key-visual BACKGROUND media action with the name-tag text element + // above it, the Namenseinblender macro, and a 10s auto-advance to the next cue. $this->assertTrue($introSlides[1]->hasBackgroundMedia()); $this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl()); $this->assertTrue($this->elementHasTextAt($introSlides[1], 0)); @@ -86,6 +89,17 @@ public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded $this->assertSame('Erika Predigt', $name); $this->assertSame('Predigt', $subtitle); $this->assertTrue($introSlides[1]->hasMacro()); + $this->assertSame(10.0, $introSlides[1]->getCue()->getCompletionTime()); + $this->assertSame( + CompletionTargetType::COMPLETION_TARGET_TYPE_NEXT, + $introSlides[1]->getCue()->getCompletionTargetType(), + ); + + // Cue 3: key-visual alone again (no content element, no name tag). + $this->assertTrue($introSlides[2]->hasBackgroundMedia()); + $this->assertSame('KEY_VISUAL.jpg', $introSlides[2]->getBackgroundMediaUrl()); + $this->assertSame([], $this->fillMediaUrls($introSlides[2])); + $this->assertFalse($introSlides[2]->hasMacro()); $sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]); $this->assertCount(2, $sermonSlides); @@ -294,10 +308,11 @@ public function test_sermon_item_without_uploaded_slides_still_emits_intro_and_n // 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. + // The intro .pro carries THREE cues: key-visual alone, key-visual with the + // name tag (10s auto-next), then the key-visual alone again. $introSlides = $this->slidesForEntry($playlist, $entries[0]); - $this->assertCount(2, $introSlides); - // Key-visual is a BACKGROUND media action on both intro cues (no content element). + $this->assertCount(3, $introSlides); + // Key-visual is a BACKGROUND media action on the flanking cues (no content element). $this->assertTrue($introSlides[0]->hasBackgroundMedia()); $this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl()); $this->assertSame([], $this->fillMediaUrls($introSlides[0])); @@ -309,6 +324,12 @@ public function test_sermon_item_without_uploaded_slides_still_emits_intro_and_n $this->assertSame('Erika Predigt', $name); $this->assertSame('Predigt', $subtitle); $this->assertTrue($introSlides[1]->hasMacro()); + $this->assertSame(10.0, $introSlides[1]->getCue()->getCompletionTime()); + // Cue 3: key-visual alone again. + $this->assertTrue($introSlides[2]->hasBackgroundMedia()); + $this->assertSame('KEY_VISUAL.jpg', $introSlides[2]->getBackgroundMediaUrl()); + $this->assertSame([], $this->fillMediaUrls($introSlides[2])); + $this->assertFalse($introSlides[2]->hasMacro()); // No separate sermon-images presentation is emitted. $this->assertNull($playlist->getEmbeddedSong('Predigt.pro')); @@ -316,6 +337,114 @@ public function test_sermon_item_without_uploaded_slides_still_emits_intro_and_n $this->cleanupTempDir($result['temp_dir']); } + public function test_moderator_nametag_with_keyvisual_is_three_cue_sequence(): void + { + $this->configureNameTagMacro(); + Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image'); + + $service = Service::factory()->create([ + 'title' => 'Moderator mit Keyvisual', + 'date' => now(), + 'key_visual_filename' => 'slides/keyvisual.jpg', + 'moderator_name' => 'Max Moderation', + ]); + $song = $this->createSongWithContent('Startlied'); + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Startlied', + 'order' => 1, + ]); + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Startlied', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 1, + 'is_before_event' => false, + 'responsible' => [], + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + $entries = $playlist->getEntries(); + + $this->assertSame('Moderator - Max Moderation', $entries[0]->getName()); + $moderatorSlides = $this->slidesForEntry($playlist, $entries[0]); + $this->assertCount(3, $moderatorSlides); + + // Cue 1 + cue 3: key-visual only (no content element, no macro). + $this->assertTrue($moderatorSlides[0]->hasBackgroundMedia()); + $this->assertSame('KEY_VISUAL.jpg', $moderatorSlides[0]->getBackgroundMediaUrl()); + $this->assertSame([], $this->fillMediaUrls($moderatorSlides[0])); + $this->assertFalse($moderatorSlides[0]->hasMacro()); + $this->assertTrue($moderatorSlides[2]->hasBackgroundMedia()); + $this->assertSame([], $this->fillMediaUrls($moderatorSlides[2])); + $this->assertFalse($moderatorSlides[2]->hasMacro()); + + // Cue 2: name tag + Namenseinblender macro + 10s auto-next. + $this->assertTrue($moderatorSlides[1]->hasBackgroundMedia()); + $this->assertTrue($this->elementHasTextAt($moderatorSlides[1], 0)); + [$name, $subtitle] = $this->nameTagNameAndSubtitle($moderatorSlides[1]); + $this->assertSame('Max Moderation', $name); + $this->assertSame('Moderation', $subtitle); + $this->assertTrue($moderatorSlides[1]->hasMacro()); + $this->assertSame(10.0, $moderatorSlides[1]->getCue()->getCompletionTime()); + $this->assertSame( + CompletionTargetType::COMPLETION_TARGET_TYPE_NEXT, + $moderatorSlides[1]->getCue()->getCompletionTargetType(), + ); + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_keyvisual_transition_inserted_between_consecutive_songs(): void + { + Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image'); + + $service = Service::factory()->create([ + 'title' => 'Zwei Lieder', + 'date' => now(), + 'key_visual_filename' => 'slides/keyvisual.jpg', + 'moderator_name' => null, + ]); + + foreach (['Lied A', 'Lied B'] as $i => $title) { + $song = $this->createSongWithContent($title); + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => $title, + 'order' => $i + 1, + ]); + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => $title, + 'service_song_id' => $serviceSong->id, + 'sort_order' => $i + 1, + 'is_before_event' => false, + 'responsible' => [], + ]); + } + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + $names = $this->entryNames($playlist); + + // A single key-visual transition sits BETWEEN the two songs; none leads the + // first song, and the trailing key-visual is provided by the looping Abspann. + $this->assertSame(['Lied A', 'Keyvisual', 'Lied B', 'Abspann'], $names); + + $entries = $playlist->getEntries(); + $kvSlides = $this->slidesForEntry($playlist, $entries[1]); + $this->assertCount(1, $kvSlides); + $this->assertTrue($kvSlides[0]->hasBackgroundMedia()); + $this->assertSame('KEY_VISUAL.jpg', $kvSlides[0]->getBackgroundMediaUrl()); + $this->assertSame([], $this->fillMediaUrls($kvSlides[0])); + $this->assertFalse($this->hasForegroundImageMediaAction($kvSlides[0])); + + $this->cleanupTempDir($result['temp_dir']); + } + private function configureNameTagMacro(): void { Setting::set('namenseinblender_macro_name', 'Namenseinblender'); diff --git a/tests/Feature/PlaylistSermonMacroTest.php b/tests/Feature/PlaylistSermonMacroTest.php index 7300da3..627f9b7 100644 --- a/tests/Feature/PlaylistSermonMacroTest.php +++ b/tests/Feature/PlaylistSermonMacroTest.php @@ -27,10 +27,11 @@ protected function setUp(): void /** * Agenda-based sermon export must attach position macros (first_slide / - * last_slide / all_slides) for part_type='sermon' onto the sermon slides. - * The parser stores only one macro per slide (the app keeps $macros[0]); the - * lowest `order` among the matching assignments wins, so first/last override - * all_slides on the first/last slide while the middle slide keeps all_slides. + * last_slide / all_slides) for part_type='sermon' onto the sermon CONTENT + * slides. The parser stores only one macro per slide (the app keeps + * $macros[0]); the MOST SPECIFIC position wins (by_label > first/last > + * all_slides, ties break by `order`), so first/last override all_slides on + * the first/last slide while the middle slide keeps all_slides. */ public function test_playlist_sermon_slides_carry_position_macros(): void { @@ -42,7 +43,8 @@ public function test_playlist_sermon_slides_carry_position_macros(): void $macroLast = Macro::factory()->create(['name' => 'Predigt Letzte']); $macroAll = Macro::factory()->create(['name' => 'Predigt Alle']); - // Lower `order` wins when several assignments match one slide. + // Most specific position wins when several assignments match one slide + // (first/last beat all_slides regardless of `order`); see MacroResolutionService. MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroFirst->id, 'position' => 'first_slide', 'order' => 0]); MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroLast->id, 'position' => 'last_slide', 'order' => 1]); MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroAll->id, 'position' => 'all_slides', 'order' => 2]); @@ -98,14 +100,14 @@ public function test_playlist_sermon_slides_carry_position_macros(): void $this->assertTrue($slide->hasMacro(), 'Every sermon slide must carry a macro (all_slides guarantees at least one)'); } - // First slide: first_slide wins over all_slides (lower order). + // First slide: first_slide wins over all_slides (more specific position). $this->assertSame('Predigt Erste', $slides[0]->getMacroName()); $this->assertSame($macroFirst->uuid, $slides[0]->getMacroUuid()); // Middle slide: only all_slides matches. $this->assertSame('Predigt Alle', $slides[1]->getMacroName()); - // Last slide: last_slide wins over all_slides (lower order). + // Last slide: last_slide wins over all_slides (more specific position). $this->assertSame('Predigt Letzte', $slides[2]->getMacroName()); $this->assertSame($macroLast->uuid, $slides[2]->getMacroUuid()); diff --git a/tests/Feature/ProFileExportTest.php b/tests/Feature/ProFileExportTest.php index 7a73b26..2125d4b 100644 --- a/tests/Feature/ProFileExportTest.php +++ b/tests/Feature/ProFileExportTest.php @@ -357,6 +357,63 @@ public function test_export_last_slide_macro_nur_auf_letzter_folie_des_ganzen_so $this->assertSame('Letzte Folie Macro', $slides[2]->getMacroName()); } + public function test_export_kombiniert_all_first_last_platziert_spezifischste_macro(): void + { + $service = Service::factory()->create(); + // Song: Verse(2 slides) + Chorus(1 slide) → 3 slides total. + $song = $this->createSongWithContent(); + + // all_slides has the LOWEST order, yet first/last must still win on the + // first/last slide because position specificity beats `order`. + $this->makeAssignedMacro('song', 'all_slides', 'ALLE', 0); + $this->makeAssignedMacro('song', 'first_slide', 'ERSTE', 1); + $this->makeAssignedMacro('song', 'last_slide', 'LETZTE', 2); + + $parserSong = app(ProExportService::class)->generateParserSong($song, $service); + $slides = $this->allParserSlides($parserSong); + + $this->assertCount(3, $slides); + $this->assertSame('ERSTE', $slides[0]->getMacroName(), 'Erste Folie: first_slide schlägt all_slides'); + $this->assertSame('ALLE', $slides[1]->getMacroName(), 'Mittlere Folie: nur all_slides trifft'); + $this->assertSame('LETZTE', $slides[2]->getMacroName(), 'Letzte Folie: last_slide schlägt all_slides'); + } + + public function test_bundle_sermon_first_last_macro_ueberlebt_uebersprungene_folie(): void + { + Storage::fake('public'); + Storage::disk('public')->put('slides/s1.jpg', 'a'); + Storage::disk('public')->put('slides/s3.jpg', 'c'); + // s2.jpg is intentionally NOT written → skipped during export. + + $service = Service::factory()->create(); + $this->makeAssignedMacro('sermon', 'all_slides', 'ALLE', 0); + $this->makeAssignedMacro('sermon', 'first_slide', 'ERSTE', 1); + $this->makeAssignedMacro('sermon', 'last_slide', 'LETZTE', 2); + + foreach ([['s1.jpg', 0], ['s2.jpg', 1], ['s3.jpg', 2]] as [$file, $sort]) { + Slide::factory()->create([ + 'service_id' => $service->id, + 'type' => 'sermon', + 'original_filename' => $file, + 'stored_filename' => 'slides/'.$file, + 'cover_mode' => false, + 'sort_order' => $sort, + ]); + } + + $bundlePath = app(ProBundleExportService::class)->generateBundle($service, 'sermon'); + $slides = $this->allParserSlides(ProBundleReader::read($bundlePath)->getSong()); + + // s2 skipped (missing file). first_slide lands on the first SURVIVING slide + // and last_slide on the last SURVIVING slide: the total is the emitted count, + // not the raw slide count (which would make last_slide never match). + $this->assertCount(2, $slides); + $this->assertSame('ERSTE', $slides[0]->getMacroName()); + $this->assertSame('LETZTE', $slides[1]->getMacroName()); + + @unlink($bundlePath); + } + public function test_export_ohne_background_enthaelt_keine_background_actions(): void { Storage::fake('public'); @@ -543,6 +600,24 @@ public function test_playlist_export_setzt_background_auf_sermon_folien_und_nich $this->cleanupTempDir($result['temp_dir']); } + private function makeAssignedMacro(string $partType, string $position, string $name, int $order): Macro + { + $macro = Macro::factory()->create(['name' => $name]); + $collection = MacroCollection::create([ + 'uuid' => strtoupper(\Illuminate\Support\Str::uuid()->toString()), + 'name' => 'Coll '.$name, + ]); + $collection->macros()->attach($macro->id, ['order' => 0]); + MacroAssignment::create([ + 'part_type' => $partType, + 'macro_id' => $macro->id, + 'position' => $position, + 'order' => $order, + ]); + + return $macro; + } + private function createMacroForExport(string $name, array $attributes = []): Macro { $macro = Macro::factory()->create(array_merge([