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
This commit is contained in:
Thorsten Bus 2026-08-03 08:59:54 +02:00
parent 4a2fa0b753
commit c3c8f7f50d
8 changed files with 672 additions and 99 deletions

View file

@ -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,
};
}
/**

View file

@ -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 - <Name>".
* - 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 - <Name>") 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<string, mixed> $nameTagData
* @param array<string, mixed>|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)

View file

@ -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) {

View file

@ -0,0 +1,180 @@
<?php
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Setting;
use App\Models\Song;
use App\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
final class CountdownSlideExportTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
public function test_countdown_slide_is_first_with_clock_and_keyvisual_background(): void
{
Setting::set('add_countdown_slide', '1');
Storage::disk('public')->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);
}
}

View file

@ -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']);
}

View file

@ -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');

View file

@ -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());

View file

@ -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([