From 94170857b67118abe66ce6e2749e4571089ee3f5 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Tue, 7 Jul 2026 22:15:12 +0200 Subject: [PATCH] feat(export): render uploaded slide images as slide content, keep background/keyvisual as background media action - New SlideMediaElementBuilder converts FOREGROUND (uploaded) image media actions into slide-content fill.media elements placed at the top of the slide content, so uploaded information/moderation/sermon images render as real content rather than a media-layer action. - Service background and key-visual images remain BACKGROUND media actions on the bottom layer; song text and name-tag stay text content elements. - Wire the builder into ProExportService and ProBundleExportService (after generate) and split PlaylistExportService::writeProFile into generate -> inject -> write via ProFileWriter so the playlist path applies the same rule. - Parser library unchanged. - Migrate export tests to the combined rule via a shared InspectsSlideFillMedia support trait and add SlideMediaElementTest. --- app/Services/PlaylistExportService.php | 8 +- app/Services/ProBundleExportService.php | 7 + app/Services/ProExportService.php | 6 + app/Services/SlideMediaElementBuilder.php | 352 ++++++++++++++++++ tests/Feature/AbspannExportTest.php | 15 +- .../Feature/ExportBundleRelativeMediaTest.php | 16 +- tests/Feature/FullPlaylistExportTest.php | 16 +- tests/Feature/KeyVisualFallbackTest.php | 6 + tests/Feature/PlaylistSequenceTest.php | 12 + tests/Feature/ProFileExportTest.php | 31 +- tests/Feature/SlideMediaElementTest.php | 220 +++++++++++ tests/Support/InspectsSlideFillMedia.php | 174 +++++++++ 12 files changed, 848 insertions(+), 15 deletions(-) create mode 100644 app/Services/SlideMediaElementBuilder.php create mode 100644 tests/Feature/SlideMediaElementTest.php create mode 100644 tests/Support/InspectsSlideFillMedia.php diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index caaf75d..21684cc 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Storage; use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProFileReader; +use ProPresenter\Parser\ProFileWriter; use ProPresenter\Parser\ProPlaylistGenerator; use ProPresenter\Parser\Zip64Fixer; use ZipArchive; @@ -837,7 +838,12 @@ private function extractProBundle(string $absPath): ?array protected function writeProFile(string $path, string $name, array $groups, array $arrangements, array $options = []): void { - ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements, [], $options); + // Split generate → inject → write: convert FOREGROUND (uploaded) image media + // actions into content fill.media ELEMENTS; BACKGROUND actions (service + // background / key-visual) stay as background-layer media actions. + $song = ProFileGenerator::generate($name, $groups, $arrangements, [], $options); + app(SlideMediaElementBuilder::class)->replaceImageActionsWithElements($song); + ProFileWriter::write($song, $path); } private function buildModeratorSlideData(Service $service): ?array diff --git a/app/Services/ProBundleExportService.php b/app/Services/ProBundleExportService.php index 6bd45ee..95058af 100644 --- a/app/Services/ProBundleExportService.php +++ b/app/Services/ProBundleExportService.php @@ -20,6 +20,7 @@ class ProBundleExportService public function __construct( private readonly MacroResolutionService $macroResolutionService, private readonly ServiceImageResolver $imageResolver, + private readonly SlideMediaElementBuilder $slideMediaElementBuilder, ) {} public function generateBundle(Service $service, string $blockType): string @@ -163,6 +164,12 @@ private function buildBundleFromSlides( ]; $song = ProFileGenerator::generate($groupName, $groups, $arrangements); + + // Convert FOREGROUND (uploaded) image media actions into content fill.media + // ELEMENTS; the BACKGROUND service-image action is left untouched as a + // background-layer media action beneath the uploaded content. + $this->slideMediaElementBuilder->replaceImageActionsWithElements($song); + $proFilename = self::safeFilename($groupName).'.pro'; $bundle = new PresentationBundle($song, $proFilename, $mediaFiles); diff --git a/app/Services/ProExportService.php b/app/Services/ProExportService.php index be679c3..38e0e62 100644 --- a/app/Services/ProExportService.php +++ b/app/Services/ProExportService.php @@ -13,6 +13,7 @@ class ProExportService public function __construct( private readonly MacroResolutionService $macroResolutionService, private readonly ServiceImageResolver $imageResolver, + private readonly SlideMediaElementBuilder $slideMediaElementBuilder, ) {} public function generateProFile(Song $song, ?Service $service = null, ?SongArrangement $selected = null): string @@ -36,6 +37,11 @@ public function generateParserSong(Song $song, ?Service $service = null, ?SongAr $this->buildCcliMetadata($song), ); + // Convert FOREGROUND (uploaded) image media actions into content fill.media + // elements; the BACKGROUND service-image action is left untouched so it stays + // a background layer beneath the song text. + $this->slideMediaElementBuilder->replaceImageActionsWithElements($parserSong); + // Pre-select the chosen arrangement (protobuf field 10) by name so the export honours the // per-service selection while keeping ALL arrangements available in the .pro file. $chosen = $this->chosenArrangement($song, $selected); diff --git a/app/Services/SlideMediaElementBuilder.php b/app/Services/SlideMediaElementBuilder.php new file mode 100644 index 0000000..1e76e71 --- /dev/null +++ b/app/Services/SlideMediaElementBuilder.php @@ -0,0 +1,352 @@ +setWidth($width); + $naturalSize->setHeight($height); + + $drawing = new DrawingProperties; + $drawing->setScaleBehavior($scaleBehavior); + $drawing->setNaturalSize($naturalSize); + $drawing->setAlphaType(AlphaType::ALPHA_TYPE_STRAIGHT); + + $fileProperties = new FileProperties; + $fileProperties->setLocalUrl($this->bundleRelativeUrl($bundleRelativeFilename)); + + $image = new ImageTypeProperties; + $image->setDrawing($drawing); + $image->setFile($fileProperties); + + $metadata = new Metadata; + $metadata->setFormat('JPG'); + + $media = new Media; + $media->setUuid($this->newUuid()); + $media->setUrl($this->bundleRelativeUrl($bundleRelativeFilename)); + $media->setMetadata($metadata); + $media->setImage($image); + + $fill = new Fill; + $fill->setEnable(true); + $fill->setMedia($media); + + $graphicsElement = new GraphicsElement; + $graphicsElement->setUuid($this->newUuid()); + $graphicsElement->setName($elementName); + $graphicsElement->setBounds($this->fullFrameBounds($width, $height)); + $graphicsElement->setOpacity(1.0); + $graphicsElement->setPath($this->rectanglePath()); + $graphicsElement->setFill($fill); + + $slideElement = new SlideElement; + $slideElement->setElement($graphicsElement); + $slideElement->setInfo(0); + + return $slideElement; + } + + /** + * Self-contained REPLACE: derive the per-cue plan from the Song's own + * FOREGROUND image media actions, then strip those actions and inject content + * elements. This is the method the export services call after + * ProFileGenerator::generate(). BACKGROUND image actions are left in place. + */ + public function replaceImageActionsWithElements(Song $song): void + { + $this->injectIntoSong($song, $this->planFromSong($song)); + } + + /** + * Convert each cue's FOREGROUND (uploaded) image media action into a + * full-frame content fill.media element appended on top of the generated + * text/content elements, and strip that foreground action. The BACKGROUND + * media action (service background / key-visual) is left untouched so it + * keeps rendering as a background layer beneath all slide content. + * + * $plan is an ordered list (one entry per cue, in cue order). Each entry: + * ['foreground' => ?string, 'foregroundName' => ?string] + * A null/absent foreground leaves the cue exactly as generated (text-only + * songs, key-visual-only slides, name-tag slides). + * + * @param array $plan + */ + public function injectIntoSong(Song $song, array $plan): void + { + $index = 0; + + foreach ($song->getPresentation()->getCues() as $cue) { + $entry = $plan[$index] ?? []; + $index++; + + $baseSlide = $this->baseSlideForCue($cue); + if ($baseSlide === null) { + continue; + } + + $foreground = $entry['foreground'] ?? null; + + if ($foreground === null || $foreground === '') { + // No foreground image: leave the cue (text elements, BACKGROUND + // media action, macro) exactly as the generator produced it. + continue; + } + + $this->stripForegroundImageMediaActions($cue); + + $elements = []; + foreach ($baseSlide->getElements() as $element) { + $elements[] = $element; + } + + $elements[] = $this->mediaFillElement($foreground, $entry['foregroundName'] ?? 'Bild'); + + $baseSlide->setElements($elements); + } + } + + /** + * Derive the injection plan from the FOREGROUND image media actions the + * generator added. BACKGROUND-layer image actions (service background / + * key-visual) are intentionally ignored so they remain media actions. + * + * @return array + */ + private function planFromSong(Song $song): array + { + $plan = []; + + foreach ($song->getPresentation()->getCues() as $cue) { + $foreground = null; + $foregroundName = null; + + foreach ($cue->getActions() as $action) { + if ($action->getType() !== ActionType::ACTION_TYPE_MEDIA) { + continue; + } + + $mediaType = $action->getMedia(); + $mediaElement = $mediaType?->getElement(); + if ($mediaElement === null || ! $mediaElement->hasImage()) { + continue; + } + + // Only FOREGROUND images become content elements. BACKGROUND-layer + // images (service background / key-visual) stay as media actions. + if ($mediaType->getLayerType() === LayerType::LAYER_TYPE_BACKGROUND) { + continue; + } + + $filename = $mediaElement->getUrl()?->getAbsoluteString(); + if ($filename === null || $filename === '') { + continue; + } + + $foreground = $filename; + $foregroundName = $action->getName() !== '' ? $action->getName() : null; + } + + $plan[] = [ + 'foreground' => $foreground, + 'foregroundName' => $foregroundName, + ]; + } + + return $plan; + } + + /** Resolve the base slide via Cue → SLIDE action → presentation → base slide. */ + private function baseSlideForCue(Cue $cue): ?BaseSlide + { + foreach ($cue->getActions() as $action) { + if ($action->getType() === ActionType::ACTION_TYPE_PRESENTATION_SLIDE) { + return $action->getSlide()?->getPresentation()?->getBaseSlide(); + } + } + + return null; + } + + /** + * Remove only FOREGROUND-layer media actions whose element carries an image. + * BACKGROUND-layer image actions (service background / key-visual) are kept. + */ + private function stripForegroundImageMediaActions(Cue $cue): void + { + $kept = []; + $stripped = false; + + foreach ($cue->getActions() as $action) { + if ($action->getType() === ActionType::ACTION_TYPE_MEDIA) { + $mediaType = $action->getMedia(); + $mediaElement = $mediaType?->getElement(); + if ( + $mediaElement !== null + && $mediaElement->hasImage() + && $mediaType->getLayerType() !== LayerType::LAYER_TYPE_BACKGROUND + ) { + $stripped = true; + + continue; + } + } + + $kept[] = $action; + } + + if ($stripped) { + $cue->setActions($kept); + } + } + + private function bundleRelativeUrl(string $filename): URL + { + $local = new LocalRelativePath; + $local->setRoot(LocalRelativePath\Root::ROOT_CURRENT_RESOURCE); + $local->setPath($filename); + + $url = new URL; + $url->setAbsoluteString($filename); + $url->setLocal($local); + $url->setPlatform(UrlPlatform::PLATFORM_MACOS); + + return $url; + } + + private function fullFrameBounds(int $width, int $height): Rect + { + $origin = new Point; + $origin->setX(0); + $origin->setY(0); + + $size = new Size; + $size->setWidth($width); + $size->setHeight($height); + + $rect = new Rect; + $rect->setOrigin($origin); + $rect->setSize($size); + + return $rect; + } + + private function rectanglePath(): Path + { + $points = []; + foreach ([[0, 0], [1, 0], [1, 1], [0, 1]] as [$x, $y]) { + $point = new Point; + $point->setX((float) $x); + $point->setY((float) $y); + + $bezier = new BezierPoint; + $bezier->setPoint($point); + $bezier->setQ0($point); + $bezier->setQ1($point); + $bezier->setCurved(false); + + $points[] = $bezier; + } + + $path = new Path; + $path->setClosed(true); + $path->setPoints($points); + + $shape = new Shape; + $shape->setType(ShapeType::TYPE_RECTANGLE); + $path->setShape($shape); + + return $path; + } + + private function newUuid(): UUID + { + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0F) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3F) | 0x80); + $hex = bin2hex($bytes); + + $uuid = new UUID; + $uuid->setString(strtoupper(sprintf( + '%s-%s-%s-%s-%s', + substr($hex, 0, 8), + substr($hex, 8, 4), + substr($hex, 12, 4), + substr($hex, 16, 4), + substr($hex, 20, 12), + ))); + + return $uuid; + } +} diff --git a/tests/Feature/AbspannExportTest.php b/tests/Feature/AbspannExportTest.php index 065f883..0b7ef70 100644 --- a/tests/Feature/AbspannExportTest.php +++ b/tests/Feature/AbspannExportTest.php @@ -16,10 +16,12 @@ use ProPresenter\Parser\ProPlaylistReader; use Rv\Data\Cue\CompletionActionType; use Rv\Data\Cue\CompletionTargetType; +use Tests\Support\InspectsSlideFillMedia; use Tests\TestCase; final class AbspannExportTest extends TestCase { + use InspectsSlideFillMedia; use RefreshDatabase; protected function setUp(): void @@ -55,17 +57,20 @@ public function test_abspann_is_last_item_with_keyvisual_first_then_information_ $slides = $this->allParserSlides($abspann); $this->assertCount(3, $slides); - // First cue is the key-visual (image-only background media). + // First cue is the key-visual as a BACKGROUND media action (no content element). $this->assertTrue($slides[0]->hasBackgroundMedia(), 'First Abspann cue must be the key-visual'); $this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl()); - $this->assertFalse($slides[0]->hasMedia(), 'Key-visual cue has no foreground media'); + $this->assertSame([], $this->fillMediaUrls($slides[0]), 'Key-visual cue has no content image element'); + $this->assertFalse($this->hasForegroundImageMediaAction($slides[0]), 'No foreground media action may remain'); $this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); - // Remaining cues are the information slides (foreground media, labelled by filename). - $this->assertTrue($slides[1]->hasMedia()); + // Remaining cues are the information slides as content image elements, labelled by filename. + $this->assertNotEmpty($this->fillMediaUrls($slides[1])); + $this->assertNotContains('KEY_VISUAL.jpg', $this->fillMediaUrls($slides[1])); $this->assertFalse($slides[1]->hasBackgroundMedia()); + $this->assertFalse($this->hasImageMediaAction($slides[1])); $this->assertSame('info1.jpg', $slides[1]->getLabel()); - $this->assertTrue($slides[2]->hasMedia()); + $this->assertNotEmpty($this->fillMediaUrls($slides[2])); $this->assertSame('info2.jpg', $slides[2]->getLabel()); // The Abspann is a self-advancing loop on the live parser round-trip: every cue diff --git a/tests/Feature/ExportBundleRelativeMediaTest.php b/tests/Feature/ExportBundleRelativeMediaTest.php index 91e6177..1ffc371 100644 --- a/tests/Feature/ExportBundleRelativeMediaTest.php +++ b/tests/Feature/ExportBundleRelativeMediaTest.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; use ProPresenter\Parser\ProBundleReader; +use Tests\Support\InspectsSlideFillMedia; use Tests\TestCase; /** @@ -19,6 +20,7 @@ */ final class ExportBundleRelativeMediaTest extends TestCase { + use InspectsSlideFillMedia; use RefreshDatabase; protected function setUp(): void @@ -124,14 +126,16 @@ public function test_probundle_export_referenziert_vordergrund_medien_bundle_rel $bundle = ProBundleReader::read($bundlePath); $slides = $bundle->getSong()->getSlides(); - $mediaSlides = array_filter($slides, fn ($slide) => $slide->hasMedia()); - $this->assertNotEmpty($mediaSlides, 'Expected a foreground-media slide in the bundle.'); + // The foreground image is now a content fill.media element, not a media ACTION. + $mediaSlides = array_filter($slides, fn ($slide) => $this->fillMediaUrls($slide) !== []); + $this->assertNotEmpty($mediaSlides, 'Expected a foreground-image content slide in the bundle.'); foreach ($mediaSlides as $slide) { - $url = $slide->getMediaUrl(); - $this->assertNotNull($url); - // Bundle-relative resources use a bare basename (no absolute path / no slash). - $this->assertStringNotContainsString('/', $url, 'Foreground media URL must be bundle-relative (basename only).'); + $this->assertFalse($this->hasImageMediaAction($slide), 'No image media ACTION may remain.'); + foreach ($this->fillMediaUrls($slide) as $url) { + // Bundle-relative resources use a bare basename (no absolute path / no slash). + $this->assertStringNotContainsString('/', $url, 'Foreground media URL must be bundle-relative (basename only).'); + } } @unlink($bundlePath); diff --git a/tests/Feature/FullPlaylistExportTest.php b/tests/Feature/FullPlaylistExportTest.php index 7f5e742..3a0097f 100644 --- a/tests/Feature/FullPlaylistExportTest.php +++ b/tests/Feature/FullPlaylistExportTest.php @@ -13,10 +13,12 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; use ProPresenter\Parser\ProPlaylistReader; +use Tests\Support\InspectsSlideFillMedia; use Tests\TestCase; final class FullPlaylistExportTest extends TestCase { + use InspectsSlideFillMedia; use RefreshDatabase; protected function setUp(): void @@ -114,8 +116,12 @@ public function test_full_service_playlist_includes_all_features_in_correct_orde $songSlides = $this->allParserSlides($songParser); $this->assertNotEmpty($songSlides); foreach ($songSlides as $slide) { - $this->assertTrue($slide->hasBackgroundMedia(), 'Song slide must have background media'); + // Service background stays a BACKGROUND media action behind the song text. + $this->assertTrue($slide->hasBackgroundMedia(), 'Song slide must have background action'); $this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl()); + $this->assertTrue($this->elementHasTextAt($slide, 0)); + $this->assertSame([], $this->fillMediaUrls($slide)); + $this->assertFalse($this->hasForegroundImageMediaAction($slide)); } $embeddedMedia = $playlist->getEmbeddedMediaFiles(); @@ -129,7 +135,13 @@ public function test_full_service_playlist_includes_all_features_in_correct_orde $sermonSlides = $this->allParserSlides($sermonParser); $this->assertCount(2, $sermonSlides); foreach ($sermonSlides as $slide) { - $this->assertTrue($slide->hasBackgroundMedia(), 'Sermon slide must have background media'); + // Uploaded sermon image is a content element with the service background + // behind it as a BACKGROUND media action (background is NOT a content element). + $this->assertTrue($slide->hasBackgroundMedia(), 'Sermon slide must have background action'); + $this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl()); + $this->assertNotEmpty($this->fillMediaUrls($slide)); + $this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide)); + $this->assertFalse($this->hasForegroundImageMediaAction($slide)); } $this->assertSame($slideCountBefore, Slide::count()); diff --git a/tests/Feature/KeyVisualFallbackTest.php b/tests/Feature/KeyVisualFallbackTest.php index a4dd73d..13d13cc 100644 --- a/tests/Feature/KeyVisualFallbackTest.php +++ b/tests/Feature/KeyVisualFallbackTest.php @@ -12,10 +12,12 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; use ProPresenter\Parser\ProPlaylistReader; +use Tests\Support\InspectsSlideFillMedia; use Tests\TestCase; final class KeyVisualFallbackTest extends TestCase { + use InspectsSlideFillMedia; use RefreshDatabase; protected function setUp(): void @@ -153,7 +155,11 @@ public function test_sermon_agenda_item_with_uploaded_slides_prepends_keyvisual_ $slides = $this->allParserSlides($sermonSong); $this->assertCount(1, $slides); + // Sermon slide is a foreground content image; no service background configured → no background layer. $this->assertFalse($slides[0]->hasBackgroundMedia()); + $this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slides[0])); + $this->assertNotEmpty($this->fillMediaUrls($slides[0])); + $this->assertFalse($this->hasImageMediaAction($slides[0])); $this->cleanupTempDir($result['temp_dir']); } diff --git a/tests/Feature/PlaylistSequenceTest.php b/tests/Feature/PlaylistSequenceTest.php index f0c0088..705d928 100644 --- a/tests/Feature/PlaylistSequenceTest.php +++ b/tests/Feature/PlaylistSequenceTest.php @@ -14,10 +14,12 @@ use Illuminate\Support\Facades\Storage; use ProPresenter\Parser\PlaylistArchive; use ProPresenter\Parser\ProPlaylistReader; +use Tests\Support\InspectsSlideFillMedia; use Tests\TestCase; final class PlaylistSequenceTest extends TestCase { + use InspectsSlideFillMedia; use RefreshDatabase; protected function setUp(): void @@ -66,12 +68,18 @@ public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded $introSlides = $this->slidesForEntry($playlist, $entries[$offset]); $this->assertCount(2, $introSlides); + // Key-visual is a BACKGROUND media action on both intro cues (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. $this->assertTrue($introSlides[1]->hasBackgroundMedia()); $this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl()); + $this->assertTrue($this->elementHasTextAt($introSlides[1], 0)); + $this->assertFalse($this->hasForegroundImageMediaAction($introSlides[1])); // Name and role are split: the name is the main (\fs84) run and the // role is a separate smaller, non-bold (\b0\fs50) subtitle run. [$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]); @@ -289,10 +297,14 @@ public function test_sermon_item_without_uploaded_slides_still_emits_intro_and_n // The intro .pro carries both cues: key-visual alone + key-visual with name tag. $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->assertTrue($introSlides[0]->hasBackgroundMedia()); $this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl()); + $this->assertSame([], $this->fillMediaUrls($introSlides[0])); $this->assertTrue($introSlides[1]->hasBackgroundMedia()); $this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl()); + $this->assertTrue($this->elementHasTextAt($introSlides[1], 0)); + $this->assertFalse($this->hasForegroundImageMediaAction($introSlides[1])); [$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]); $this->assertSame('Erika Predigt', $name); $this->assertSame('Predigt', $subtitle); diff --git a/tests/Feature/ProFileExportTest.php b/tests/Feature/ProFileExportTest.php index d6d21cf..7a73b26 100644 --- a/tests/Feature/ProFileExportTest.php +++ b/tests/Feature/ProFileExportTest.php @@ -18,10 +18,12 @@ use Illuminate\Support\Facades\Storage; use ProPresenter\Parser\ProBundleReader; use ProPresenter\Parser\ProPlaylistReader; +use Tests\Support\InspectsSlideFillMedia; use Tests\TestCase; final class ProFileExportTest extends TestCase { + use InspectsSlideFillMedia; use RefreshDatabase; private function createSongWithContent(): Song @@ -292,9 +294,13 @@ public function test_export_mit_service_background_enthaelt_background_auf_allen $this->assertNotEmpty($slides); foreach ($slides as $slide) { + // Service background stays a BACKGROUND media action behind the song text. $this->assertTrue($slide->hasBackgroundMedia()); $this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl()); - $this->assertSame('JPG', $slide->getBackgroundMediaFormat()); + // Text is a content element; no image content element, no foreground action. + $this->assertTrue($this->elementHasTextAt($slide, 0)); + $this->assertSame([], $this->fillMediaUrls($slide)); + $this->assertFalse($this->hasForegroundImageMediaAction($slide)); } } @@ -361,7 +367,10 @@ public function test_export_ohne_background_enthaelt_keine_background_actions(): $parserSong = app(ProExportService::class)->generateParserSong($song, $service); foreach ($this->allParserSlides($parserSong) as $slide) { + // No background configured → no background fill.media element and no media ACTION. $this->assertFalse($slide->hasBackgroundMedia()); + $this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide)); + $this->assertFalse($this->hasImageMediaAction($slide)); } } @@ -398,9 +407,18 @@ public function test_sermon_export_ueberspringt_background_bei_full_cover_folien $slides = $this->allParserSlides($bundle->getSong()); $this->assertCount(2, $slides); + // contain slide: the uploaded image is a content element with the service + // background behind it as a BACKGROUND media action. $this->assertTrue($slides[0]->hasBackgroundMedia()); $this->assertSame('BACKGROUND.jpg', $slides[0]->getBackgroundMediaUrl()); + $this->assertSame('contain.jpg', $this->fillMediaUrlAt($slides[0], 0)); + $this->assertTrue($this->isFullFrameAt($slides[0], 0)); + $this->assertFalse($this->hasForegroundImageMediaAction($slides[0])); + // cover slide: uploaded content element only, no background layer. $this->assertFalse($slides[1]->hasBackgroundMedia()); + $this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slides[1])); + $this->assertSame('cover.jpg', $this->fillMediaUrlAt($slides[1], 0)); + $this->assertFalse($this->hasForegroundImageMediaAction($slides[1])); $this->assertTrue($bundle->hasMediaFile('BACKGROUND.jpg'), 'Background image must be embedded under fixed name'); $this->assertSame('background-image', $bundle->getMediaFile('BACKGROUND.jpg')); @@ -437,7 +455,9 @@ public function test_information_und_moderation_exports_erhalten_keinen_backgrou $bundlePath = app(ProBundleExportService::class)->generateBundle($service, $blockType); foreach ($this->allParserSlides(ProBundleReader::read($bundlePath)->getSong()) as $slide) { + // Info/moderation slides carry only the foreground image element, never a background layer. $this->assertFalse($slide->hasBackgroundMedia()); + $this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide)); } @unlink($bundlePath); @@ -504,12 +524,21 @@ public function test_playlist_export_setzt_background_auf_sermon_folien_und_nich foreach ($this->allParserSlides($informationSong) as $slide) { $this->assertFalse($slide->hasBackgroundMedia()); + $this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide)); } $sermonSlides = $this->allParserSlides($sermonSong); $this->assertCount(2, $sermonSlides); + // contain slide: uploaded content element with the service background behind it + // as a BACKGROUND media action (background is NOT a content element). $this->assertTrue($sermonSlides[0]->hasBackgroundMedia()); + $this->assertSame('BACKGROUND.jpg', $sermonSlides[0]->getBackgroundMediaUrl()); + $this->assertNotEmpty($this->fillMediaUrls($sermonSlides[0])); + $this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($sermonSlides[0])); + $this->assertFalse($this->hasForegroundImageMediaAction($sermonSlides[0])); + // cover slide: uploaded content element only, no background layer. $this->assertFalse($sermonSlides[1]->hasBackgroundMedia()); + $this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($sermonSlides[1])); $this->cleanupTempDir($result['temp_dir']); } diff --git a/tests/Feature/SlideMediaElementTest.php b/tests/Feature/SlideMediaElementTest.php new file mode 100644 index 0000000..24d4056 --- /dev/null +++ b/tests/Feature/SlideMediaElementTest.php @@ -0,0 +1,220 @@ + $name, 'format' => 'JPG', 'width' => 1920, 'height' => 1080, 'bundleRelative' => true]; + } + + /** Generate a one-slide song and run the production builder over it. */ + private function build(array $slideData, string $name = 'Test'): ParserSlide + { + $groups = [['name' => 'Gruppe', 'color' => [0, 0, 0, 1], 'slides' => [$slideData]]]; + $arrangements = [['name' => 'normal', 'groupNames' => ['Gruppe']]]; + + $song = ProFileGenerator::generate($name, $groups, $arrangements); + app(SlideMediaElementBuilder::class)->replaceImageActionsWithElements($song); + + return $song->getSlides()[0]; + } + + public function test_song_slide_keeps_background_action_and_text_without_image_element(): void + { + $groups = [['name' => 'Gruppe', 'color' => [0, 0, 0, 1], 'slides' => [[ + 'text' => 'Textzeile', + 'background' => $this->bgData(), + ]]]]; + $arrangements = [['name' => 'normal', 'groupNames' => ['Gruppe']]]; + + $song = ProFileGenerator::generate('Song', $groups, $arrangements); + + // Sanity: the generator emits a BACKGROUND media action before conversion. + $this->assertTrue($song->getSlides()[0]->hasBackgroundMedia()); + + app(SlideMediaElementBuilder::class)->replaceImageActionsWithElements($song); + $slide = $song->getSlides()[0]; + + // Background stays a BACKGROUND media action (NOT converted to an element). + $this->assertTrue($slide->hasBackgroundMedia()); + $this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl()); + + // Text element preserved; no content fill.media element exists. + $this->assertTrue($this->elementHasTextAt($slide, 0)); + $this->assertSame([], $this->fillMediaUrls($slide)); + + // No foreground image action. + $this->assertFalse($this->hasForegroundImageMediaAction($slide)); + $this->assertFalse($slide->hasMedia()); + } + + public function test_uploaded_content_slide_becomes_full_frame_element_without_foreground_action(): void + { + // Production uploaded-slide data: media + format + label + bundleRelative (no text). + $slide = $this->build([ + 'media' => 'UPLOAD.jpg', + 'format' => 'JPG', + 'label' => 'upload.jpg', + 'bundleRelative' => true, + ]); + + // A single full-frame content fill.media element, no text. + $this->assertCount(1, $this->baseSlideElements($slide)); + $this->assertSame('UPLOAD.jpg', $this->fillMediaUrlAt($slide, 0)); + $this->assertTrue($this->isFullFrameAt($slide, 0)); + $this->assertFalse($this->elementHasTextAt($slide, 0)); + + // No foreground media action remains; no background configured at all. + $this->assertFalse($this->hasForegroundImageMediaAction($slide)); + $this->assertFalse($slide->hasBackgroundMedia()); + $this->assertFalse($this->hasImageMediaAction($slide)); + } + + public function test_uploaded_content_slide_with_service_background_keeps_background_action_behind_element(): void + { + $slide = $this->build([ + 'media' => 'UPLOAD.jpg', + 'format' => 'JPG', + 'label' => 'upload.jpg', + 'bundleRelative' => true, + 'background' => $this->bgData(), + ]); + + // Uploaded image is a full-frame content element on top. + $this->assertSame('UPLOAD.jpg', $this->fillMediaUrlAt($slide, 0)); + $this->assertTrue($this->isFullFrameAt($slide, 0)); + $this->assertFalse($this->hasForegroundImageMediaAction($slide)); + + // Service background stays a BACKGROUND media action behind the element. + $this->assertTrue($slide->hasBackgroundMedia()); + $this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl()); + } + + public function test_key_visual_slide_is_background_action_only_without_content_element(): void + { + // Production key-visual slide data: imageOnly + background. + $slide = $this->build([ + 'imageOnly' => true, + 'background' => $this->bgData('KEY_VISUAL.jpg'), + ]); + + // Key-visual is a BACKGROUND media action, not a content element. + $this->assertTrue($slide->hasBackgroundMedia()); + $this->assertSame('KEY_VISUAL.jpg', $slide->getBackgroundMediaUrl()); + + // No content fill.media element and no foreground action. + $this->assertSame([], $this->fillMediaUrls($slide)); + $this->assertFalse($this->hasForegroundImageMediaAction($slide)); + } + + public function test_name_tag_slide_keeps_text_background_action_and_macro(): void + { + $slide = $this->build([ + 'text' => 'Erika Predigt', + 'subtitle' => 'Predigt', + 'background' => $this->bgData('KEY_VISUAL.jpg'), + 'macro' => [ + 'name' => 'Namenseinblender', + 'uuid' => '11111111-1111-4111-8111-111111111111', + 'collectionName' => '--MAIN--', + 'collectionUuid' => null, + ], + ]); + + // Text element preserved (name-tag text sits above the background). + $this->assertTrue($this->elementHasTextAt($slide, 0)); + $this->assertSame([], $this->fillMediaUrls($slide)); + + // Key-visual behind the name tag stays a BACKGROUND media action. + $this->assertTrue($slide->hasBackgroundMedia()); + $this->assertSame('KEY_VISUAL.jpg', $slide->getBackgroundMediaUrl()); + + // Macro action preserved. + $this->assertTrue($slide->hasMacro()); + $this->assertSame('Namenseinblender', $slide->getMacroName()); + + // No image content element, no foreground action. + $this->assertFalse($this->hasForegroundImageMediaAction($slide)); + } + + public function test_exported_song_pro_roundtrip_keeps_background_action_and_text(): 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(); + + $path = app(ProExportService::class)->generateProFile($song, $service); + $parserSong = ProFileReader::read($path); + @unlink($path); + + $slides = []; + foreach ($parserSong->getGroups() as $group) { + foreach ($parserSong->getSlidesForGroup($group) as $slide) { + $slides[] = $slide; + } + } + + $this->assertNotEmpty($slides); + + foreach ($slides as $slide) { + // Background survives the .pro write+read as a BACKGROUND media action. + $this->assertTrue($slide->hasBackgroundMedia()); + $this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl()); + // Text preserved; no content image element; no foreground action. + $this->assertTrue($this->elementHasTextAt($slide, 0)); + $this->assertNotSame('', $slide->getPlainText()); + $this->assertSame([], $this->fillMediaUrls($slide)); + $this->assertFalse($this->hasForegroundImageMediaAction($slide)); + } + } + + private function createSongWithContent(): Song + { + $song = Song::create([ + 'title' => 'Fill Media Song', + 'ccli_id' => '99887', + 'author' => 'Test Author', + 'copyright_text' => 'Test Publisher', + ]); + + $verse = Label::firstOrCreate(['name' => 'Verse 1 - Fill Media Song'], ['color' => '#2196F3']); + $section = $song->sections()->create(['label_id' => $verse->id, 'order' => 0]); + $section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']); + $section->slides()->create(['order' => 1, 'text_content' => 'Zweite Zeile']); + + $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); + $arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]); + + return $song; + } +} diff --git a/tests/Support/InspectsSlideFillMedia.php b/tests/Support/InspectsSlideFillMedia.php new file mode 100644 index 0000000..634cf76 --- /dev/null +++ b/tests/Support/InspectsSlideFillMedia.php @@ -0,0 +1,174 @@ +hasBackgroundMedia() and + * $slide->getBackgroundMediaUrl(). + * - UPLOADED content slide image → full-frame fill.media CONTENT element. + * Assert via fillMediaUrlAt()/fillMediaUrls()/isFullFrameAt() and the + * foreground-action absence via hasForegroundImageMediaAction() == false. + * - Text (songtext / name-tag) → text CONTENT element (elementHasTextAt()). + * + * Content elements are full-frame Graphics\Element fills whose z-order is the + * base-slide element array order (index 0 = bottom layer): text first, the + * uploaded foreground image appended on top. The background image is NOT an + * element; it renders beneath the stack from its BACKGROUND media action. + */ +trait InspectsSlideFillMedia +{ + /** + * Ordered base-slide content elements (z-order; index 0 = bottom layer). + * + * @return array + */ + protected function baseSlideElements(Slide $slide): array + { + foreach ($slide->getCue()->getActions() as $action) { + if ($action->getType() !== ActionType::ACTION_TYPE_PRESENTATION_SLIDE) { + continue; + } + + $baseSlide = $action->getSlide()?->getPresentation()?->getBaseSlide(); + if ($baseSlide === null) { + return []; + } + + $elements = []; + foreach ($baseSlide->getElements() as $element) { + $elements[] = $element; + } + + return $elements; + } + + return []; + } + + /** Bundle-relative fill-media URL of the element at $index, or null when it has no media fill. */ + protected function fillMediaUrlAt(Slide $slide, int $index): ?string + { + $elements = $this->baseSlideElements($slide); + $graphics = ($elements[$index] ?? null)?->getElement(); + $fill = $graphics?->getFill(); + + if ($fill === null || ! $fill->hasMedia()) { + return null; + } + + return $fill->getMedia()?->getUrl()?->getAbsoluteString(); + } + + /** + * Bounds [x, y, width, height] of the element at $index, or null. + * + * @return array{0: float, 1: float, 2: float, 3: float}|null + */ + protected function fillBoundsAt(Slide $slide, int $index): ?array + { + $elements = $this->baseSlideElements($slide); + $bounds = ($elements[$index] ?? null)?->getElement()?->getBounds(); + if ($bounds === null) { + return null; + } + + return [ + (float) ($bounds->getOrigin()?->getX() ?? 0.0), + (float) ($bounds->getOrigin()?->getY() ?? 0.0), + (float) ($bounds->getSize()?->getWidth() ?? 0.0), + (float) ($bounds->getSize()?->getHeight() ?? 0.0), + ]; + } + + /** True when the element at $index is a full-frame (0,0 → 1920×1080) fill. */ + protected function isFullFrameAt(Slide $slide, int $index): bool + { + return $this->fillBoundsAt($slide, $index) === [0.0, 0.0, 1920.0, 1080.0]; + } + + /** + * All fill-media URLs on the slide, in z-order (bottom → top). + * + * @return array + */ + protected function fillMediaUrls(Slide $slide): array + { + $urls = []; + foreach ($this->baseSlideElements($slide) as $element) { + $fill = $element->getElement()?->getFill(); + if ($fill !== null && $fill->hasMedia()) { + $urls[] = $fill->getMedia()?->getUrl()?->getAbsoluteString() ?? ''; + } + } + + return $urls; + } + + /** Whether any base-slide element carries a fill-media image with the given URL. */ + protected function hasFillMediaUrl(Slide $slide, string $url): bool + { + return in_array($url, $this->fillMediaUrls($slide), true); + } + + /** True while the element at $index carries text (used to assert text sits above the background). */ + protected function elementHasTextAt(Slide $slide, int $index): bool + { + $graphics = ($this->baseSlideElements($slide)[$index] ?? null)?->getElement(); + + return $graphics !== null && $graphics->hasText(); + } + + /** + * Whether the cue carries ANY image MEDIA action (either layer). Useful only + * when no service background is configured; when a background IS present it + * survives as a BACKGROUND action, so prefer hasForegroundImageMediaAction(). + */ + protected function hasImageMediaAction(Slide $slide): bool + { + foreach ($slide->getCue()->getActions() as $action) { + if ($action->getType() !== ActionType::ACTION_TYPE_MEDIA) { + continue; + } + + $mediaElement = $action->getMedia()?->getElement(); + if ($mediaElement !== null && $mediaElement->hasImage()) { + return true; + } + } + + return false; + } + + /** + * Whether the cue still carries a FOREGROUND-layer image MEDIA action. Must be + * false after conversion — uploaded images become content elements. A + * BACKGROUND-layer action (service background / key-visual) is ignored here. + */ + protected function hasForegroundImageMediaAction(Slide $slide): bool + { + foreach ($slide->getCue()->getActions() as $action) { + if ($action->getType() !== ActionType::ACTION_TYPE_MEDIA) { + continue; + } + + $mediaType = $action->getMedia(); + $mediaElement = $mediaType?->getElement(); + if ( + $mediaElement !== null + && $mediaElement->hasImage() + && $mediaType->getLayerType() !== LayerType::LAYER_TYPE_BACKGROUND + ) { + return true; + } + } + + return false; + } +}