feat(export): background layer on song/sermon slides

This commit is contained in:
Thorsten Bus 2026-05-31 00:37:23 +02:00
parent d2193bb3b2
commit 196657b52b
10 changed files with 480 additions and 5 deletions

View file

@ -0,0 +1,15 @@
Task T8 evidence — no background / excluded slide types
Verified by `Tests\Feature\ProFileExportTest`:
- `test_export_ohne_background_enthaelt_keine_background_actions`
- Service without resolved background generates successfully
- exported song slides contain zero BACKGROUND media actions
- `test_information_und_moderation_exports_erhalten_keinen_background`
- information bundle gets no BACKGROUND media
- moderation bundle gets no BACKGROUND media
- `test_playlist_export_setzt_background_auf_sermon_folien_und_nicht_auf_informationen`
- final playlist keeps information slides without BACKGROUND media
Verification commands:
- `ddev exec php artisan test tests/Feature/ProFileExportTest.php` → 13 passed, 76 assertions
- `ddev exec php artisan test` → 537 passed, 2683 assertions

View file

@ -0,0 +1,20 @@
Task T8 evidence — song/sermon background layer
RED:
- `ddev exec php artisan test tests/Feature/ProFileExportTest.php`
- Failed as expected before implementation:
- song slides had no BACKGROUND media action
- slides table had no `cover_mode` column for full-cover detection
GREEN:
- `ddev exec php artisan test tests/Feature/ProFileExportTest.php`
- 13 passed, 76 assertions
- Verified:
- every song text slide gets BACKGROUND media when ServiceImageResolver resolves a background
- sermon image slides get BACKGROUND media when not full-cover
- full-cover sermon image slides (`cover_mode=true`) skip BACKGROUND media
- final .proplaylist export preserves the same sermon/full-cover behavior
Full suite:
- `ddev exec ./vendor/bin/pint ... && ddev exec php artisan test`
- 537 passed, 2683 assertions

View file

@ -59,3 +59,32 @@ ## [2026-05-31] Task: T5
- Return contract is the raw storage-relative filename (`slides/abc.jpg`), NOT the Service model `/storage/...` URL accessor output.
- Test file `tests/Feature/ServiceImageResolverTest.php` covers service wins, global fallback, null, missing service-file fallthrough, and background resolution branches.
- Full suite green: `ddev exec php artisan test` → 519 passed, 2627 assertions. Pint clean for resolver + tests.
## [2026-05-31] Task: T6 — ServiceImageController
- New controller `app/Http/Controllers/ServiceImageController.php`: `storeKeyVisual()` + `storeBackground()` both delegate to private `store($request, $service, $column, $settingKey)`.
- Validation: `file` => required|file|mimes:jpg,jpeg,png|max:20480 (20MB); `scope` => required|Rule::in(['service','default']). German messages via 2nd arg to `$request->validate([...], [...])`.
- Conversion: `app(FileConversionService::class)->convertImageCover($request->file('file'))` → stores `$result['filename']` (slides/{uuid}.jpg) into `key_visual_filename`/`background_filename` via `$service->update([...])`.
- scope=default ALSO calls `Setting::set('current_key_visual'|'current_background', $result['filename'])`. scope=service leaves global Setting untouched.
- Old file NOT deleted on replace (protects finalized snapshots). Verified by test.
- Routes (inside auth group): `POST /services/{service}/key-visual``services.key-visual.store`; `POST /services/{service}/background``services.background.store`.
- Response: `back()->with('success', 'Bild wurde gespeichert.')` (Inertia redirect). Web validation failures redirect 302; tests use `postJson()` to assert the 422 JSON contract.
- GOTCHA: after adding controller, route cache + autoload were stale → `ddev exec composer dump-autoload && ddev exec php artisan optimize:clear`. Also the `use` import edit silently didn't stick first time — verify imports after editing routes/web.php.
- Test file `tests/Feature/ServiceImageControllerTest.php` (6 tests). Helper `makeImageUpload($name,$w,$h)` GD-based (same pattern as SlideControllerTest's makePngUploadForSlide).
- Full suite: 525 passed (519 baseline + 6). Pint clean.
- Evidence: `.sisyphus/evidence/task-6-default-upload.txt`, `task-6-invalid-upload.txt`.
## [2026-05-31] Task: T7 — NameTagResolver
- New service `App\Services\NameTagResolver`: `moderatorFor(Service): ?string`, `preacherFor(Service): ?string`.
- Moderator resolution: trimmed `services.moderator_name` wins; else first visible agenda item (`is_before_event=false`) ordered by `sort_order`, then `id`; responsible names joined by `', '`; no name => `null`.
- Preacher resolution: trimmed `services.preacher_name_override` wins; else trimmed `services.preacher_name`; else first visible non-song sermon agenda item (`service_song_id IS NULL`) responsible names; no name => `null`.
- `responsible` JSON findings: model casts to array; sync test proves associative object shape `{name: 'Max Mustermann'}`; expected multi-person shape is list of objects `[{name: 'Anna Müller'}, {name: 'Tom Klein'}]`. Resolver supports both plus string entries and `firstName`/`lastName` or snake_case fallbacks.
- Sermon detection method: use `Setting::get('agenda_sermon_matching')` comma patterns through `AgendaMatcherService::matchesAny()` (same matcher used by ServiceController/Service finalization). If no setting is configured, fallback checks title/type for `predigt` or `sermon`.
- Tests: `tests/Feature/NameTagResolverTest.php` covers override/fallback/null branches for moderator and preacher. Full suite green: `ddev exec php artisan test` → 532 passed (2659 assertions). Pint clean. Evidence: `.sisyphus/evidence/task-7-moderator.txt`, `.sisyphus/evidence/task-7-preacher.txt`.
## [2026-05-31] Task: T8 — Export background layer
- `ProExportService::buildGroups()` now resolves `ServiceImageResolver::backgroundFor($service)` once per song export and adds `slideData['background']` with `Storage::disk('public')->path(...)`, format JPG, 1920×1080 to every non-full-cover song slide.
- Sermon image exports use the same background contract in `PlaylistExportService` and `ProBundleExportService`; information/moderation slide exports explicitly remain without background.
- Full-cover detection is persisted with new nullable `slides.cover_mode` (`null` legacy/unknown, `false` contain, `true` cover). `SlideController` stores `$result['fullCover']` from image/ZIP conversions; existing contain conversions store `false`.
- 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.
- 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`.

View file

@ -169,6 +169,7 @@ private function handleImage(
'original_filename' => $file->getClientOriginalName(),
'stored_filename' => $result['filename'],
'thumbnail_filename' => $result['thumbnail'],
'cover_mode' => $result['fullCover'] ?? null,
'expire_date' => $expireDate,
'uploader_name' => $uploaderName,
'uploaded_at' => now(),
@ -260,6 +261,7 @@ private function handleZip(
'original_filename' => $file->getClientOriginalName(),
'stored_filename' => $result['filename'],
'thumbnail_filename' => $result['thumbnail'],
'cover_mode' => $result['fullCover'] ?? null,
'expire_date' => $expireDate,
'uploader_name' => $uploaderName,
'uploaded_at' => now(),

View file

@ -19,6 +19,7 @@ class Slide extends Model
'original_filename',
'stored_filename',
'thumbnail_filename',
'cover_mode',
'expire_date',
'uploader_name',
'uploaded_at',
@ -30,6 +31,7 @@ protected function casts(): array
return [
'expire_date' => 'date',
'uploaded_at' => 'datetime',
'cover_mode' => 'boolean',
];
}

View file

@ -73,6 +73,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$tempDir,
$playlistItems,
$embeddedFiles,
$service,
'information',
);
$announcementInserted = true;
}
@ -118,6 +120,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$tempDir,
$playlistItems,
$embeddedFiles,
$service,
$this->backgroundPartTypeForAgendaItem($item),
);
}
}
@ -132,6 +136,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$tempDir,
$prependItems,
$prependFiles,
$service,
'information',
);
$playlistItems = array_merge($prependItems, $playlistItems);
$embeddedFiles = array_merge($prependFiles, $embeddedFiles);
@ -260,9 +266,12 @@ private function addSlidesFromCollection(
string $tempDir,
array &$playlistItems,
array &$embeddedFiles,
?Service $service = null,
?string $backgroundPartType = null,
): void {
$slideDataList = [];
$imageFiles = [];
$background = $this->backgroundData($service);
foreach ($slides->values() as $index => $slide) {
$storedPath = Storage::disk('public')->path($slide->stored_filename);
@ -277,11 +286,17 @@ private function addSlidesFromCollection(
$imageFiles[$imageFilename] = file_get_contents($destPath);
$slideDataList[] = [
$singleSlideData = [
'media' => $imageFilename,
'format' => 'JPG',
'label' => $slide->original_filename,
];
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
$singleSlideData['background'] = $background;
}
$slideDataList[] = $singleSlideData;
}
if (empty($slideDataList)) {
@ -362,6 +377,8 @@ private function addSlidePresentation(
$tempDir,
$playlistItems,
$embeddedFiles,
$service,
$type,
);
}
@ -378,6 +395,57 @@ private function countSongLabels(\App\Models\Song $song): int
->sum('arrangement_labels_count');
}
private function backgroundData(?Service $service): ?array
{
if ($service === null) {
return null;
}
$background = app(ServiceImageResolver::class)->backgroundFor($service);
if ($background === null) {
return null;
}
return [
'path' => Storage::disk('public')->path($background),
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
];
}
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
{
if ($background === null) {
return false;
}
if ($backgroundPartType !== 'sermon' && $slide->type !== 'sermon') {
return false;
}
return $slide->cover_mode !== true;
}
private function backgroundPartTypeForAgendaItem(ServiceAgendaItem $item): string
{
if ($item->slides->contains(fn (Slide $slide) => $slide->type === 'sermon')) {
return 'sermon';
}
$sermonPatterns = Setting::get('agenda_sermon_matching');
if ($sermonPatterns === null) {
return 'agenda_item';
}
$patterns = array_map('trim', explode(',', $sermonPatterns));
return app(AgendaMatcherService::class)->matchesAny($item->title, $patterns)
? 'sermon'
: 'agenda_item';
}
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
{
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);

View file

@ -4,6 +4,8 @@
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\Setting;
use App\Models\Slide;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
use ProPresenter\Parser\PresentationBundle;
@ -17,6 +19,7 @@ class ProBundleExportService
public function __construct(
private readonly MacroResolutionService $macroResolutionService,
private readonly ServiceImageResolver $imageResolver,
) {}
public function generateBundle(Service $service, string $blockType): string
@ -32,7 +35,7 @@ public function generateBundle(Service $service, string $blockType): string
$groupName = ucfirst($blockType);
return $this->buildBundleFromSlides($slides, $groupName, $service, $blockType);
return $this->buildBundleFromSlides($slides, $groupName, $service, $blockType, $blockType);
}
public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
@ -72,14 +75,26 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
->orderBy('sort_order')
->get();
return $this->buildBundleFromSlides($slides, $title, $agendaItem->service, 'agenda_item');
return $this->buildBundleFromSlides(
$slides,
$title,
$agendaItem->service,
'agenda_item',
$this->backgroundPartTypeForAgendaItem($agendaItem),
);
}
/** @param \Illuminate\Database\Eloquent\Collection<int, \App\Models\Slide> $slides */
private function buildBundleFromSlides($slides, string $groupName, ?Service $service = null, ?string $partType = null): string
{
private function buildBundleFromSlides(
$slides,
string $groupName,
?Service $service = null,
?string $partType = null,
?string $backgroundPartType = null,
): string {
$slideData = [];
$mediaFiles = [];
$background = $this->backgroundData($service);
foreach ($slides as $slide) {
$sourcePath = Storage::disk('public')->path($slide->stored_filename);
@ -101,6 +116,10 @@ private function buildBundleFromSlides($slides, string $groupName, ?Service $ser
'label' => $slide->original_filename,
];
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
$singleSlideData['background'] = $background;
}
if ($service !== null && $partType !== null) {
$slideIndex = count($slideData);
$totalSlides = $slides->count();
@ -144,6 +163,57 @@ private function buildBundleFromSlides($slides, string $groupName, ?Service $ser
return $bundlePath;
}
private function backgroundData(?Service $service): ?array
{
if ($service === null) {
return null;
}
$background = $this->imageResolver->backgroundFor($service);
if ($background === null) {
return null;
}
return [
'path' => Storage::disk('public')->path($background),
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
];
}
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
{
if ($background === null) {
return false;
}
if ($backgroundPartType !== 'sermon' && $slide->type !== 'sermon') {
return false;
}
return $slide->cover_mode !== true;
}
private function backgroundPartTypeForAgendaItem(ServiceAgendaItem $agendaItem): string
{
if ($agendaItem->slides->contains(fn (Slide $slide) => $slide->type === 'sermon')) {
return 'sermon';
}
$sermonPatterns = Setting::get('agenda_sermon_matching');
if ($sermonPatterns === null) {
return 'agenda_item';
}
$patterns = array_map('trim', explode(',', $sermonPatterns));
return app(AgendaMatcherService::class)->matchesAny($agendaItem->title, $patterns)
? 'sermon'
: 'agenda_item';
}
private static function safeFilename(string $name): string
{
return preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name) ?: 'presentation';

View file

@ -4,12 +4,14 @@
use App\Models\Service;
use App\Models\Song;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator;
class ProExportService
{
public function __construct(
private readonly MacroResolutionService $macroResolutionService,
private readonly ServiceImageResolver $imageResolver,
) {}
public function generateProFile(Song $song, ?Service $service = null): string
@ -51,6 +53,7 @@ private function buildGroups(Song $song, ?Service $service = null): array
$groups = [];
$seenLabelIds = [];
$background = $this->backgroundData($service);
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) {
$label = $arrangementLabel->label;
@ -75,6 +78,10 @@ private function buildGroups(Song $song, ?Service $service = null): array
$slideData['translation'] = $slide->text_content_translated;
}
if ($background !== null && ! $this->isFullCoverImageSlide($slide, $slideData)) {
$slideData['background'] = $background;
}
if ($service !== null) {
$macros = $this->macroResolutionService->macrosForSlide(
$service,
@ -101,6 +108,35 @@ private function buildGroups(Song $song, ?Service $service = null): array
return $groups;
}
private function backgroundData(?Service $service): ?array
{
if ($service === null) {
return null;
}
$background = $this->imageResolver->backgroundFor($service);
if ($background === null) {
return null;
}
return [
'path' => Storage::disk('public')->path($background),
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
];
}
private function isFullCoverImageSlide(object $slide, array $slideData): bool
{
if (! isset($slideData['media'])) {
return false;
}
return ($slide->cover_mode ?? null) === true;
}
private function buildArrangements(Song $song): array
{
$arrangements = [];

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('slides', function (Blueprint $table) {
$table->boolean('cover_mode')->nullable()->after('thumbnail_filename');
});
}
public function down(): void
{
Schema::table('slides', function (Blueprint $table) {
$table->dropColumn('cover_mode');
});
}
};

View file

@ -7,10 +7,17 @@
use App\Models\MacroAssignment;
use App\Models\MacroCollection;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\Slide;
use App\Models\Song;
use App\Models\User;
use App\Services\PlaylistExportService;
use App\Services\ProBundleExportService;
use App\Services\ProExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProBundleReader;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
final class ProFileExportTest extends TestCase
@ -268,6 +275,187 @@ public function test_export_mit_ausgeblendeter_macro_enthaelt_keine_macro(): voi
}
}
public function test_export_mit_service_background_enthaelt_background_auf_allen_song_folien(): void
{
Storage::fake('public');
Storage::disk('public')->put('slides/background.jpg', 'background-image');
$service = Service::factory()->create([
'background_filename' => 'slides/background.jpg',
]);
$song = $this->createSongWithContent();
$expectedPath = Storage::disk('public')->path('slides/background.jpg');
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
$slides = $this->allParserSlides($parserSong);
$this->assertNotEmpty($slides);
foreach ($slides as $slide) {
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame($expectedPath, $slide->getBackgroundMediaUrl());
$this->assertSame('JPG', $slide->getBackgroundMediaFormat());
}
}
public function test_export_ohne_background_enthaelt_keine_background_actions(): void
{
Storage::fake('public');
$service = Service::factory()->create();
$song = $this->createSongWithContent();
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
foreach ($this->allParserSlides($parserSong) as $slide) {
$this->assertFalse($slide->hasBackgroundMedia());
}
}
public function test_sermon_export_ueberspringt_background_bei_full_cover_folien(): void
{
Storage::fake('public');
Storage::disk('public')->put('slides/background.jpg', 'background-image');
Storage::disk('public')->put('slides/contain.jpg', 'contain-image');
Storage::disk('public')->put('slides/cover.jpg', 'cover-image');
$service = Service::factory()->create([
'background_filename' => 'slides/background.jpg',
]);
Slide::factory()->create([
'service_id' => $service->id,
'type' => 'sermon',
'original_filename' => 'contain.jpg',
'stored_filename' => 'slides/contain.jpg',
'cover_mode' => false,
'sort_order' => 0,
]);
Slide::factory()->create([
'service_id' => $service->id,
'type' => 'sermon',
'original_filename' => 'cover.jpg',
'stored_filename' => 'slides/cover.jpg',
'cover_mode' => true,
'sort_order' => 1,
]);
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, 'sermon');
$slides = $this->allParserSlides(ProBundleReader::read($bundlePath)->getSong());
$this->assertCount(2, $slides);
$this->assertTrue($slides[0]->hasBackgroundMedia());
$this->assertFalse($slides[1]->hasBackgroundMedia());
@unlink($bundlePath);
}
public function test_information_und_moderation_exports_erhalten_keinen_background(): void
{
Storage::fake('public');
Storage::disk('public')->put('slides/background.jpg', 'background-image');
Storage::disk('public')->put('slides/information.jpg', 'information-image');
Storage::disk('public')->put('slides/moderation.jpg', 'moderation-image');
$service = Service::factory()->create([
'background_filename' => 'slides/background.jpg',
]);
Slide::factory()->create([
'service_id' => $service->id,
'type' => 'information',
'original_filename' => 'information.jpg',
'stored_filename' => 'slides/information.jpg',
'sort_order' => 0,
]);
Slide::factory()->create([
'service_id' => $service->id,
'type' => 'moderation',
'original_filename' => 'moderation.jpg',
'stored_filename' => 'slides/moderation.jpg',
'sort_order' => 0,
]);
foreach (['information', 'moderation'] as $blockType) {
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, $blockType);
foreach ($this->allParserSlides(ProBundleReader::read($bundlePath)->getSong()) as $slide) {
$this->assertFalse($slide->hasBackgroundMedia());
}
@unlink($bundlePath);
}
}
public function test_playlist_export_setzt_background_auf_sermon_folien_und_nicht_auf_informationen(): void
{
Storage::fake('public');
Storage::disk('public')->put('slides/background.jpg', 'background-image');
Storage::disk('public')->put('slides/information.jpg', 'information-image');
Storage::disk('public')->put('slides/sermon-contain.jpg', 'sermon-contain-image');
Storage::disk('public')->put('slides/sermon-cover.jpg', 'sermon-cover-image');
$service = Service::factory()->create([
'title' => 'Playlist Background',
'date' => now(),
'background_filename' => 'slides/background.jpg',
]);
Slide::factory()->create([
'service_id' => null,
'type' => 'information',
'original_filename' => 'information.jpg',
'stored_filename' => 'slides/information.jpg',
'uploaded_at' => now()->subDay(),
'sort_order' => 0,
]);
$sermonItem = 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' => $sermonItem->id,
'type' => 'sermon',
'original_filename' => 'sermon-contain.jpg',
'stored_filename' => 'slides/sermon-contain.jpg',
'cover_mode' => false,
'sort_order' => 0,
]);
Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $sermonItem->id,
'type' => 'sermon',
'original_filename' => 'sermon-cover.jpg',
'stored_filename' => 'slides/sermon-cover.jpg',
'cover_mode' => true,
'sort_order' => 1,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$informationSong = $playlist->getEmbeddedSong('Informationen.pro');
$sermonSong = $playlist->getEmbeddedSong('Predigt.pro');
$this->assertNotNull($informationSong);
$this->assertNotNull($sermonSong);
foreach ($this->allParserSlides($informationSong) as $slide) {
$this->assertFalse($slide->hasBackgroundMedia());
}
$sermonSlides = $this->allParserSlides($sermonSong);
$this->assertCount(2, $sermonSlides);
$this->assertTrue($sermonSlides[0]->hasBackgroundMedia());
$this->assertFalse($sermonSlides[1]->hasBackgroundMedia());
$this->cleanupTempDir($result['temp_dir']);
}
private function createMacroForExport(string $name, array $attributes = []): Macro
{
$macro = Macro::factory()->create(array_merge([
@ -305,4 +493,27 @@ private function assertStringContains(string $needle, ?string $haystack): void
"Failed asserting that '{$haystack}' contains '{$needle}'"
);
}
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);
}
}