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:
parent
4a2fa0b753
commit
c3c8f7f50d
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
180
tests/Feature/CountdownSlideExportTest.php
Normal file
180
tests/Feature/CountdownSlideExportTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Reference in a new issue