feat(export): role+name nametag titles and combined keyvisual+nametag sermon intro

This commit is contained in:
Thorsten Bus 2026-07-05 23:20:35 +02:00
parent e849d43b74
commit e47299f90a
3 changed files with 179 additions and 45 deletions

View file

@ -74,8 +74,10 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
foreach ($agendaItems as $item) { foreach ($agendaItems as $item) {
if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) { if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) {
$moderatorName = trim((string) ($moderatorSlideData['text'] ?? ''));
$moderatorPresentationName = $moderatorName !== '' ? 'Moderator - '.$moderatorName : 'Moderator';
$this->writeProAndEmbed( $this->writeProAndEmbed(
'Moderator', $moderatorPresentationName,
$moderatorSlideData, $moderatorSlideData,
$tempDir, $tempDir,
$playlistItems, $playlistItems,
@ -136,12 +138,12 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
continue; continue;
} }
if ($item->slides->isNotEmpty()) { $isSermon = $this->backgroundPartTypeForAgendaItem($item) === 'sermon';
if ($this->backgroundPartTypeForAgendaItem($item) === 'sermon') {
$this->addKeyVisualSlide($service, $tempDir, $playlistItems, $embeddedFiles, 'Keyvisual-Predigt');
$this->addPreacherNameTag($service, $tempDir, $playlistItems, $embeddedFiles);
}
if ($isSermon) {
$this->addSermonIntroPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
if ($item->slides->isNotEmpty()) {
$countBefore = count($playlistItems); $countBefore = count($playlistItems);
$label = $item->title ?: 'Folien'; $label = $item->title ?: 'Folien';
$this->addSlidesFromCollection( $this->addSlidesFromCollection(
@ -158,11 +160,25 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
if (count($playlistItems) > $countBefore) { if (count($playlistItems) > $countBefore) {
$realContentEmitted = true; $realContentEmitted = true;
} }
continue;
} }
} elseif ($item->slides->isNotEmpty()) {
$countBefore = count($playlistItems);
$label = $item->title ?: 'Folien';
$this->addSlidesFromCollection(
$item->slides,
'agenda_'.$item->id,
$label,
$tempDir,
$playlistItems,
$embeddedFiles,
$service,
$this->backgroundPartTypeForAgendaItem($item),
);
if (! $this->isNameTagAgendaItem($item)) { if (count($playlistItems) > $countBefore) {
$realContentEmitted = true;
}
} elseif (! $this->isNameTagAgendaItem($item)) {
if ($realContentEmitted && ! $keyvisualFallbackEmitted) { if ($realContentEmitted && ! $keyvisualFallbackEmitted) {
$this->addKeyVisualFallbackPresentation( $this->addKeyVisualFallbackPresentation(
$item, $item,
@ -719,27 +735,50 @@ private function buildPreacherSlideData(Service $service): ?array
return app(NameTagSlideBuilder::class)->buildPreacherSlide($name); return app(NameTagSlideBuilder::class)->buildPreacherSlide($name);
} }
private function addKeyVisualSlide(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles, string $label = 'Keyvisual'): void /**
* 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
*
* The presentation is named after the preacher ("Prediger - <Name>") when a
* name tag is configured, otherwise "Keyvisual-Predigt". Emits nothing when
* neither a key-visual nor a name tag is available.
*/
private function addSermonIntroPresentation(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles): void
{ {
$kvData = $this->keyVisualData($service); $kvData = $this->keyVisualData($service);
if ($kvData === null) { $nameTagData = $this->buildPreacherSlideData($service);
if ($kvData === null && $nameTagData === null) {
return; return;
} }
$slides = [];
if ($kvData !== null) {
$this->embedKeyVisual($service, $embeddedFiles); $this->embedKeyVisual($service, $embeddedFiles);
$slides[] = ['imageOnly' => true, 'background' => $kvData];
$slideData = ['imageOnly' => true, 'background' => $kvData];
$this->writeProAndEmbed($label, $slideData, $tempDir, $playlistItems, $embeddedFiles);
} }
private function addPreacherNameTag(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles): void if ($nameTagData !== null) {
{ if ($kvData !== null) {
$slideData = $this->buildPreacherSlideData($service); $nameTagData['background'] = $kvData;
if ($slideData === null) { }
return; $slides[] = $nameTagData;
$preacher = trim((string) ($nameTagData['text'] ?? ''));
$name = $preacher !== '' ? 'Prediger - '.$preacher : 'Prediger';
} else {
$name = 'Keyvisual-Predigt';
} }
$this->writeProAndEmbed('Predigername', $slideData, $tempDir, $playlistItems, $embeddedFiles); $groups = [['name' => $name, 'color' => [0, 0, 0, 1], 'slides' => $slides]];
$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];
} }
private function writeProAndEmbed(string $name, array $slideData, string $tempDir, array &$playlistItems, array &$embeddedFiles): void private function writeProAndEmbed(string $name, array $slideData, string $tempDir, array &$playlistItems, array &$embeddedFiles): void

View file

@ -94,23 +94,20 @@ public function test_full_service_playlist_includes_all_features_in_correct_orde
$playlist = ProPlaylistReader::read($result['path']); $playlist = ProPlaylistReader::read($result['path']);
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries()); $names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
$moderatorIndex = array_search('Moderator', $names, true); $moderatorIndex = array_search('Moderator - Moderator Max', $names, true);
$songIndex = array_search('Großer Gott', $names, true); $songIndex = array_search('Großer Gott', $names, true);
$kvIndex = array_search('Keyvisual-Predigt', $names, true); $introIndex = array_search('Prediger - Pastor Paul', $names, true);
$preacherIndex = array_search('Predigername', $names, true);
$sermonIndex = array_search('Predigt', $names, true); $sermonIndex = array_search('Predigt', $names, true);
$this->assertNotFalse($moderatorIndex, 'Moderator nametag missing'); $this->assertNotFalse($moderatorIndex, 'Moderator nametag missing');
$this->assertNotFalse($songIndex, 'Song presentation missing'); $this->assertNotFalse($songIndex, 'Song presentation missing');
$this->assertNotFalse($kvIndex, 'Keyvisual-Predigt entry missing'); $this->assertNotFalse($introIndex, 'Prediger intro (keyvisual + nametag) missing');
$this->assertNotFalse($preacherIndex, 'Predigername (preacher nametag) missing');
$this->assertNotFalse($sermonIndex, 'Predigt (sermon slides) missing'); $this->assertNotFalse($sermonIndex, 'Predigt (sermon slides) missing');
$this->assertSame(0, $moderatorIndex, 'Moderator nametag must be first'); $this->assertSame(0, $moderatorIndex, 'Moderator nametag must be first');
$this->assertLessThan($songIndex, $moderatorIndex); $this->assertLessThan($songIndex, $moderatorIndex);
$this->assertLessThan($kvIndex, $songIndex); $this->assertLessThan($introIndex, $songIndex);
$this->assertLessThan($preacherIndex, $kvIndex, 'Keyvisual must come before preacher nametag'); $this->assertLessThan($sermonIndex, $introIndex, 'Prediger intro must come before sermon slides');
$this->assertLessThan($sermonIndex, $preacherIndex, 'Preacher nametag must come before sermon slides');
$songParser = $playlist->getEmbeddedSong('Großer Gott.pro'); $songParser = $playlist->getEmbeddedSong('Großer Gott.pro');
$this->assertNotNull($songParser, 'Embedded song .pro missing'); $this->assertNotNull($songParser, 'Embedded song .pro missing');

View file

@ -55,25 +55,30 @@ public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded
$entries = $playlist->getEntries(); $entries = $playlist->getEntries();
$names = $this->entryNames($playlist); $names = $this->entryNames($playlist);
$offset = $names[0] === 'Moderator' ? 1 : 0; // A moderator name tag may precede the sermon intro when the (factory-random)
$this->assertSame(['Keyvisual-Predigt', 'Predigername', 'Predigt'], array_slice($names, $offset)); // 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));
$keyVisualSlides = $this->slidesForEntry($playlist, $entries[$offset]); // The sermon intro is ONE presentation with two cues: the key-visual alone,
$this->assertCount(1, $keyVisualSlides); // then the same key-visual with the preacher name tag rendered on top.
$this->assertTrue($keyVisualSlides[0]->hasBackgroundMedia()); $introSlides = $this->slidesForEntry($playlist, $entries[$offset]);
$this->assertSame('KEY_VISUAL.jpg', $keyVisualSlides[0]->getBackgroundMediaUrl()); $this->assertCount(2, $introSlides);
$this->assertTrue($introSlides[0]->hasBackgroundMedia());
$this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl());
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); $this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
$nameTagSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]); $this->assertTrue($introSlides[1]->hasBackgroundMedia());
$this->assertCount(1, $nameTagSlides); $this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl());
// Name and role are now split: the name is the main (\fs84) run and the // Name and role are split: the name is the main (\fs84) run and the
// role is a separate smaller, non-bold (\b0\fs50) subtitle run. // role is a separate smaller, non-bold (\b0\fs50) subtitle run.
[$name, $subtitle] = $this->nameTagNameAndSubtitle($nameTagSlides[0]); [$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]);
$this->assertSame('Erika Predigt', $name); $this->assertSame('Erika Predigt', $name);
$this->assertSame('Predigt', $subtitle); $this->assertSame('Predigt', $subtitle);
$this->assertTrue($nameTagSlides[0]->hasMacro()); $this->assertTrue($introSlides[1]->hasMacro());
$sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 2]); $sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]);
$this->assertCount(2, $sermonSlides); $this->assertCount(2, $sermonSlides);
$this->assertSame('sermon-1.jpg', $sermonSlides[0]->getLabel()); $this->assertSame('sermon-1.jpg', $sermonSlides[0]->getLabel());
$this->assertSame('sermon-2.jpg', $sermonSlides[1]->getLabel()); $this->assertSame('sermon-2.jpg', $sermonSlides[1]->getLabel());
@ -117,7 +122,7 @@ public function test_moderator_nametag_is_first_presentation_for_first_visible_a
$playlist = ProPlaylistReader::read($result['path']); $playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries(); $entries = $playlist->getEntries();
$this->assertSame(['Moderator', 'Erstes sichtbares Lied'], $this->entryNames($playlist)); $this->assertSame(['Moderator - Max Moderation', 'Erstes sichtbares Lied'], $this->entryNames($playlist));
$moderatorSlides = $this->slidesForEntry($playlist, $entries[0]); $moderatorSlides = $this->slidesForEntry($playlist, $entries[0]);
$this->assertCount(1, $moderatorSlides); $this->assertCount(1, $moderatorSlides);
// Name and role are split: the name is the main run, the role the subtitle run. // Name and role are split: the name is the main run, the role the subtitle run.
@ -203,6 +208,99 @@ public function test_without_moderator_name_no_moderator_nametag_is_added(): voi
$this->cleanupTempDir($result['temp_dir']); $this->cleanupTempDir($result['temp_dir']);
} }
public function test_nametag_presentations_are_named_by_role_and_person(): void
{
$this->configureNameTagMacro();
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image');
$service = Service::factory()->create([
'title' => 'Rollen Benennung',
'date' => now(),
'key_visual_filename' => 'slides/keyvisual.jpg',
'moderator_name' => 'Max Moderation',
'preacher_name_override' => 'Erika Predigt',
]);
$song = $this->createSongWithContent('Eröffnungslied');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Eröffnungslied',
'order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Eröffnungslied',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
'is_before_event' => false,
]);
$sermonItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'sort_order' => 2,
'is_before_event' => false,
]);
$this->createSermonSlide($service, $sermonItem, 'sermon.jpg', 0);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$names = $this->entryNames($playlist);
$this->assertContains('Moderator - Max Moderation', $names);
$this->assertContains('Prediger - Erika Predigt', $names);
$this->cleanupTempDir($result['temp_dir']);
}
public function test_sermon_item_without_uploaded_slides_still_emits_intro_and_no_images_presentation(): void
{
$this->configureNameTagMacro();
Setting::set('agenda_sermon_matching', 'Predigt');
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
$service = Service::factory()->create([
'title' => 'Predigt ohne Folien',
'date' => now(),
'key_visual_filename' => 'slides/keyvisual.jpg',
'preacher_name_override' => 'Erika Predigt',
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => false,
'responsible' => [],
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$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));
// The intro .pro carries both cues: key-visual alone + key-visual with name tag.
$introSlides = $this->slidesForEntry($playlist, $entries[0]);
$this->assertCount(2, $introSlides);
$this->assertTrue($introSlides[0]->hasBackgroundMedia());
$this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl());
$this->assertTrue($introSlides[1]->hasBackgroundMedia());
$this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl());
[$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]);
$this->assertSame('Erika Predigt', $name);
$this->assertSame('Predigt', $subtitle);
$this->assertTrue($introSlides[1]->hasMacro());
// No separate sermon-images presentation is emitted.
$this->assertNull($playlist->getEmbeddedSong('Predigt.pro'));
$this->cleanupTempDir($result['temp_dir']);
}
private function configureNameTagMacro(): void private function configureNameTagMacro(): void
{ {
Setting::set('namenseinblender_macro_name', 'Namenseinblender'); Setting::set('namenseinblender_macro_name', 'Namenseinblender');