feat(export): keyvisual fallback slides

This commit is contained in:
Thorsten Bus 2026-05-31 00:43:59 +02:00
parent 196657b52b
commit a19c967594
5 changed files with 362 additions and 0 deletions

View file

@ -0,0 +1,15 @@
Task T9 keyvisual fallback evidence
Scenario: Service has key_visual_filename and a visible non-song agenda item with no uploaded/special slides.
Result:
- PlaylistExportService creates an embedded .pro for the agenda item title.
- The generated .pro contains exactly one image-only slide with background media.
- Background media URL is Storage::disk('public')->path($keyvisual).
- Slide rows are not created; Slide::count() remains unchanged.
Verification:
- RED before implementation: `ddev exec php artisan test tests/Feature/KeyVisualFallbackTest.php` failed with `Keine Songs mit Inhalt zum Exportieren gefunden.` for empty non-song agenda item.
- GREEN after implementation: `ddev exec php artisan test tests/Feature/KeyVisualFallbackTest.php` → 4 passed, 16 assertions.
- Full suite: `ddev exec php artisan test` → 541 passed, 2699 assertions.
- Pint: `ddev exec ./vendor/bin/pint app/Services/PlaylistExportService.php tests/Feature/KeyVisualFallbackTest.php` → PASS, 2 files.

View file

@ -0,0 +1,16 @@
Task T9 no-keyvisual evidence
Scenario: Service has no keyvisual and a visible non-song agenda item with no uploaded/special slides.
Result:
- Empty non-song agenda item adds no playlist entry and no .pro file.
- Song agenda items still export through the normal song .pro path.
- Uploaded agenda slides still export normally; no keyvisual slide is prepended.
- Song agenda items never receive keyvisual fallback slides.
Verification:
- `tests/Feature/KeyVisualFallbackTest.php` covers:
- no keyvisual → no empty-item .pro entry,
- song item → only song .pro,
- uploaded slides → exactly the uploaded slide presentation and no fallback background.
- Full suite after implementation: 541 passed, 2699 assertions.

View file

@ -88,3 +88,10 @@ ## [2026-05-31] Task: T8 — Export background layer
- Migration timestamp chosen as `2026_05_10_115950_add_cover_mode_to_slides_table.php` so the existing CCLI rollback test still rolls back the CCLI migration with `--step=1`. - Migration timestamp chosen as `2026_05_10_115950_add_cover_mode_to_slides_table.php` so the existing CCLI rollback test still rolls back the CCLI migration with `--step=1`.
- Tests appended to `tests/Feature/ProFileExportTest.php`: song background, null background, sermon full-cover skip, information/moderation exclusion, playlist sermon/information regression. - Tests appended to `tests/Feature/ProFileExportTest.php`: song background, null background, sermon full-cover skip, information/moderation exclusion, playlist sermon/information regression.
- Verification: RED targeted test failed before implementation; GREEN targeted `ProFileExportTest` 13 passed / 76 assertions; full suite `ddev exec php artisan test` 537 passed / 2683 assertions; Pint clean. Evidence: `.sisyphus/evidence/task-8-song-background.txt`, `.sisyphus/evidence/task-8-no-background.txt`. - Verification: RED targeted test failed before implementation; GREEN targeted `ProFileExportTest` 13 passed / 76 assertions; full suite `ddev exec php artisan test` 537 passed / 2683 assertions; Pint clean. Evidence: `.sisyphus/evidence/task-8-song-background.txt`, `.sisyphus/evidence/task-8-no-background.txt`.
## [2026-05-31] Task: T9 — Keyvisual fallback playlist slides
- `PlaylistExportService` now adds an ephemeral keyvisual fallback presentation only in the agenda export path after song handling and after uploaded agenda slides are considered.
- Eligible fallback items: visible agenda items with `service_song_id === null`, no uploaded/special slides, not a nametag/namenseinblender marker, and `ServiceImageResolver::keyVisualFor($service)` resolves an existing storage file.
- Fallback `.pro` uses parser T1 contract: one group `Keyvisual`, arrangement `normal`, slide data `['imageOnly' => true, 'background' => ['path' => Storage::disk('public')->path($keyvisual), 'format' => 'JPG', 'width' => 1920, 'height' => 1080]]`.
- Generated fallback slides are not persisted; tests assert `Slide::count()` stays unchanged.
- Tests: `tests/Feature/KeyVisualFallbackTest.php` covers fallback creation, song exclusion, uploaded-slide exclusion, and no-keyvisual no-op. Full suite green: `ddev exec php artisan test` → 541 passed / 2699 assertions. Pint clean. Evidence: `.sisyphus/evidence/task-9-fallback.txt`, `.sisyphus/evidence/task-9-no-keyvisual.txt`.

View file

@ -123,6 +123,18 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$service, $service,
$this->backgroundPartTypeForAgendaItem($item), $this->backgroundPartTypeForAgendaItem($item),
); );
continue;
}
if (! $this->isNameTagAgendaItem($item)) {
$this->addKeyVisualFallbackPresentation(
$item,
$service,
$tempDir,
$playlistItems,
$embeddedFiles,
);
} }
} }
@ -382,6 +394,54 @@ private function addSlidePresentation(
); );
} }
private function addKeyVisualFallbackPresentation(
ServiceAgendaItem $item,
Service $service,
string $tempDir,
array &$playlistItems,
array &$embeddedFiles,
): void {
$background = $this->keyVisualData($service);
if ($background === null) {
return;
}
$label = $item->title ?: 'Keyvisual';
$groups = [
[
'name' => 'Keyvisual',
'color' => [0, 0, 0, 1],
'slides' => [
[
'imageOnly' => true,
'background' => $background,
],
],
],
];
$arrangements = [
[
'name' => 'normal',
'groupNames' => ['Keyvisual'],
],
];
$safeLabel = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $label);
$proFilename = $safeLabel.'.pro';
$proPath = $tempDir.'/'.$proFilename;
$this->writeProFile($proPath, $label, $groups, $arrangements);
$embeddedFiles[$proFilename] = file_get_contents($proPath);
$playlistItems[] = [
'type' => 'presentation',
'name' => $label,
'path' => $proFilename,
];
}
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
{ {
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements); ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
@ -415,6 +475,22 @@ private function backgroundData(?Service $service): ?array
]; ];
} }
private function keyVisualData(Service $service): ?array
{
$keyVisual = app(ServiceImageResolver::class)->keyVisualFor($service);
if ($keyVisual === null) {
return null;
}
return [
'path' => Storage::disk('public')->path($keyVisual),
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
];
}
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
{ {
if ($background === null) { if ($background === null) {
@ -446,6 +522,17 @@ private function backgroundPartTypeForAgendaItem(ServiceAgendaItem $item): strin
: 'agenda_item'; : 'agenda_item';
} }
private function isNameTagAgendaItem(ServiceAgendaItem $item): bool
{
$title = mb_strtolower($item->title ?? '');
$type = mb_strtolower($item->type ?? '');
return str_contains($title, 'nametag')
|| str_contains($title, 'namenseinblender')
|| str_contains($type, 'nametag')
|| str_contains($type, 'namenseinblender');
}
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
{ {
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles); ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);

View file

@ -0,0 +1,237 @@
<?php
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Slide;
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 KeyVisualFallbackTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
public function test_non_song_agenda_item_without_slides_gets_ephemeral_keyvisual_fallback(): void
{
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
$service = Service::factory()->create([
'title' => 'Keyvisual Service',
'date' => now(),
'key_visual_filename' => 'slides/keyvisual.jpg',
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => false,
]);
$slideCountBefore = Slide::count();
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$embeddedProFiles = $playlist->getEmbeddedProFiles();
$fallbackSong = $playlist->getEmbeddedSong('Begrüßung.pro');
$this->assertCount(1, $embeddedProFiles);
$this->assertNotNull($fallbackSong);
$this->assertSame($slideCountBefore, Slide::count());
$slides = $this->allParserSlides($fallbackSong);
$this->assertCount(1, $slides);
$this->assertTrue($slides[0]->hasBackgroundMedia());
$this->assertSame(Storage::disk('public')->path('slides/keyvisual.jpg'), $slides[0]->getBackgroundMediaUrl());
$this->cleanupTempDir($result['temp_dir']);
}
public function test_song_agenda_item_does_not_get_keyvisual_fallback(): void
{
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
$service = Service::factory()->create([
'title' => 'Song Service',
'date' => now(),
'key_visual_filename' => 'slides/keyvisual.jpg',
]);
$song = $this->createSongWithContent('Nur ein Lied');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Nur ein Lied',
'order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Nur ein Lied',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
'is_before_event' => false,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$this->assertCount(1, $playlist->getEmbeddedProFiles());
$this->assertNotNull($playlist->getEmbeddedSong('Nur ein Lied.pro'));
$this->assertNull($playlist->getEmbeddedSong('Keyvisual.pro'));
$this->cleanupTempDir($result['temp_dir']);
}
public function test_agenda_item_with_uploaded_slides_exports_normally_without_extra_keyvisual_slide(): void
{
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image');
$service = Service::factory()->create([
'title' => 'Slides Service',
'date' => now(),
'key_visual_filename' => 'slides/keyvisual.jpg',
]);
$agendaItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => false,
]);
Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $agendaItem->id,
'type' => 'sermon',
'original_filename' => 'sermon.jpg',
'stored_filename' => 'slides/sermon.jpg',
'sort_order' => 0,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$sermonSong = $playlist->getEmbeddedSong('Predigt.pro');
$this->assertCount(1, $playlist->getEmbeddedProFiles());
$this->assertNotNull($sermonSong);
$slides = $this->allParserSlides($sermonSong);
$this->assertCount(1, $slides);
$this->assertFalse($slides[0]->hasBackgroundMedia());
$this->cleanupTempDir($result['temp_dir']);
}
public function test_without_keyvisual_empty_non_song_item_adds_no_playlist_entry(): void
{
$service = Service::factory()->create([
'title' => 'Ohne Keyvisual',
'date' => now(),
'key_visual_filename' => null,
]);
$song = $this->createSongWithContent('Vorhandenes Lied');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Vorhandenes Lied',
'order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung ohne Folien',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => false,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Vorhandenes Lied',
'service_song_id' => $serviceSong->id,
'sort_order' => 2,
'is_before_event' => false,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$this->assertCount(1, $playlist->getEmbeddedProFiles());
$this->assertNotNull($playlist->getEmbeddedSong('Vorhandenes Lied.pro'));
$this->assertNull($playlist->getEmbeddedSong('Begrüßung ohne Folien.pro'));
$this->cleanupTempDir($result['temp_dir']);
}
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'],
);
$label->songSlides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementLabels()->create(['label_id' => $label->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);
}
}