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.
This commit is contained in:
Thorsten Bus 2026-07-07 22:15:12 +02:00
parent 2a98f1b30e
commit 94170857b6
12 changed files with 848 additions and 15 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,352 @@
<?php
namespace App\Services;
use ProPresenter\Parser\Song;
use Rv\Data\Action\ActionType;
use Rv\Data\Action\LayerType;
use Rv\Data\AlphaType;
use Rv\Data\Cue;
use Rv\Data\FileProperties;
use Rv\Data\Graphics\Element as GraphicsElement;
use Rv\Data\Graphics\Fill;
use Rv\Data\Graphics\Path;
use Rv\Data\Graphics\Path\BezierPoint;
use Rv\Data\Graphics\Path\Shape;
use Rv\Data\Graphics\Path\Shape\Type as ShapeType;
use Rv\Data\Graphics\Point;
use Rv\Data\Graphics\Rect;
use Rv\Data\Graphics\Size;
use Rv\Data\Media;
use Rv\Data\Media\DrawingProperties;
use Rv\Data\Media\ImageTypeProperties;
use Rv\Data\Media\Metadata;
use Rv\Data\Media\ScaleBehavior;
use Rv\Data\Slide as BaseSlide;
use Rv\Data\Slide\Element as SlideElement;
use Rv\Data\URL;
use Rv\Data\URL\LocalRelativePath;
use Rv\Data\URL\Platform as UrlPlatform;
use Rv\Data\UUID;
/**
* Converts a generated Song's FOREGROUND image media ACTIONS (uploaded slide
* images from the Information/Moderation/Sermon blocks and the abspann info
* images) into full-frame slide-content fill.media ELEMENTS, and strips those
* foreground actions so none survives.
*
* BACKGROUND-layer image media actions the service background image AND the
* key-visual are LEFT UNTOUCHED. They stay as media ACTIONS on the BACKGROUND
* layer so ProPresenter renders them behind the whole slide-content stack. Text,
* translation, subtitle, macro and completion actions are never touched.
*
* Combined rule (content vs background):
*
* - Background image / key-visual media ACTION, layer BACKGROUND (kept).
* - Uploaded content slide image fill.media content ELEMENT (converted).
* - Songtext / name-tag text text content elements (kept).
*
* Layering contract for the resulting base-slide element array (index 0 = bottom):
*
* [0 .. n-1] text elements from ProFileGenerator::generate()
* [n] uploaded (foreground) image element, appended on top
*
* The background image is NOT an element; it is a media action on the BACKGROUND
* layer and renders beneath the entire element stack automatically.
*
* Every converted foreground image is referenced bundle-relative
* (ROOT_CURRENT_RESOURCE) by its bare filename so it resolves against the bytes
* embedded into the .pro/.probundle archive never by an absolute path.
*/
class SlideMediaElementBuilder
{
/**
* Build a full-frame (0,0 1920×1080) content element whose fill is the
* given bundle-relative image.
*
* Images are pre-sized to 1920×1080 (16:9) before export, so FILL and FIT are
* equivalent; FILL is used as the default scale behaviour.
*/
public function mediaFillElement(
string $bundleRelativeFilename,
string $elementName = '',
int $width = 1920,
int $height = 1080,
int $scaleBehavior = ScaleBehavior::SCALE_BEHAVIOR_FILL,
): SlideElement {
$naturalSize = new Size;
$naturalSize->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<int, array{foreground?: ?string, foregroundName?: ?string}> $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<int, array{foreground: ?string, foregroundName: ?string}>
*/
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;
}
}

View file

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

View file

@ -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,15 +126,17 @@ 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);
$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);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,220 @@
<?php
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\Song;
use App\Services\ProExportService;
use App\Services\SlideMediaElementBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProFileReader;
use ProPresenter\Parser\Slide as ParserSlide;
use Tests\Support\InspectsSlideFillMedia;
use Tests\TestCase;
/**
* Verifies the COMBINED image rule enforced by SlideMediaElementBuilder:
*
* 1. Song slide text element + BACKGROUND media action, NO image element.
* 2. Uploaded content slide full-frame fill.media content element, NO foreground
* action (a service background, if set, stays a
* BACKGROUND media action behind the element).
* 3. Key-visual slide BACKGROUND media action only, NO content element.
* 4. Name-tag slide text element + BACKGROUND media action + macro.
*/
final class SlideMediaElementTest extends TestCase
{
use InspectsSlideFillMedia;
use RefreshDatabase;
private function bgData(string $name = 'BACKGROUND.jpg'): array
{
return ['path' => $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;
}
}

View file

@ -0,0 +1,174 @@
<?php
namespace Tests\Support;
use ProPresenter\Parser\Slide;
use Rv\Data\Action\ActionType;
use Rv\Data\Action\LayerType;
/**
* Test helpers for the COMBINED image rule:
*
* - BACKGROUND service image / key-visual media ACTION on the BACKGROUND
* layer. Assert via the parser API: $slide->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<int, \Rv\Data\Slide\Element>
*/
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<int, string>
*/
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;
}
}