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:
parent
2a98f1b30e
commit
94170857b6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
352
app/Services/SlideMediaElementBuilder.php
Normal file
352
app/Services/SlideMediaElementBuilder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
|
|
|
|||
220
tests/Feature/SlideMediaElementTest.php
Normal file
220
tests/Feature/SlideMediaElementTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
174
tests/Support/InspectsSlideFillMedia.php
Normal file
174
tests/Support/InspectsSlideFillMedia.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue