feat(parser): background-layer + image-only slide support

This commit is contained in:
Thorsten Bus 2026-05-30 23:47:58 +02:00
parent 8e80a8af88
commit 582ef85c2a
3 changed files with 220 additions and 12 deletions

View file

@ -15,8 +15,9 @@ use Rv\Data\AlphaType;
use Rv\Data\ApplicationInfo; use Rv\Data\ApplicationInfo;
use Rv\Data\ApplicationInfo\Application; use Rv\Data\ApplicationInfo\Application;
use Rv\Data\ApplicationInfo\Platform; use Rv\Data\ApplicationInfo\Platform;
use Rv\Data\Color; use Rv\Data\Background;
use Rv\Data\CollectionElementType; use Rv\Data\CollectionElementType;
use Rv\Data\Color;
use Rv\Data\Cue; use Rv\Data\Cue;
use Rv\Data\FileProperties; use Rv\Data\FileProperties;
use Rv\Data\Graphics\EdgeInsets; use Rv\Data\Graphics\EdgeInsets;
@ -47,17 +48,16 @@ use Rv\Data\Presentation;
use Rv\Data\Presentation\Arrangement; use Rv\Data\Presentation\Arrangement;
use Rv\Data\Presentation\CCLI; use Rv\Data\Presentation\CCLI;
use Rv\Data\Presentation\CueGroup; use Rv\Data\Presentation\CueGroup;
use Rv\Data\Presentation\Timeline;
use Rv\Data\PresentationSlide; use Rv\Data\PresentationSlide;
use Rv\Data\Slide; use Rv\Data\Slide;
use Rv\Data\Slide\Element as SlideElement; use Rv\Data\Slide\Element as SlideElement;
use Rv\Data\Slide\Element\TextScroller; use Rv\Data\Slide\Element\TextScroller;
use Rv\Data\Background;
use Rv\Data\URL; use Rv\Data\URL;
use Rv\Data\URL\LocalRelativePath; use Rv\Data\URL\LocalRelativePath;
use Rv\Data\URL\Platform as UrlPlatform; use Rv\Data\URL\Platform as UrlPlatform;
use Rv\Data\UUID; use Rv\Data\UUID;
use Rv\Data\Version; use Rv\Data\Version;
use Rv\Data\Presentation\Timeline;
final class ProFileGenerator final class ProFileGenerator
{ {
@ -180,7 +180,9 @@ final class ProFileGenerator
private static function buildCue(string $cueUuid, array $slideData): Cue private static function buildCue(string $cueUuid, array $slideData): Cue
{ {
$elements = []; $elements = [];
if (isset($slideData['text'])) { $imageOnly = ($slideData['imageOnly'] ?? false) === true;
if (! $imageOnly && isset($slideData['text'])) {
$hasTranslation = isset($slideData['translation']) && $slideData['translation'] !== null; $hasTranslation = isset($slideData['translation']) && $slideData['translation'] !== null;
if ($hasTranslation) { if ($hasTranslation) {
@ -210,6 +212,10 @@ final class ProFileGenerator
$actions = [self::buildSlideAction($slideType)]; $actions = [self::buildSlideAction($slideType)];
if (isset($slideData['background']) && is_array($slideData['background'])) {
$actions[] = self::buildBackgroundMediaAction($slideData['background']);
}
if (isset($slideData['media'])) { if (isset($slideData['media'])) {
// Derive name from label OR filename without extension // Derive name from label OR filename without extension
$mediaName = $slideData['label'] ?? null; $mediaName = $slideData['label'] ?? null;
@ -282,8 +288,28 @@ final class ProFileGenerator
return $action; return $action;
} }
private static function buildMediaAction(string $absoluteUrl, string $format, ?string $name = null, int $imageWidth = 0, int $imageHeight = 0, bool $bundleRelative = false): Action private static function buildBackgroundMediaAction(array $backgroundData): Action
{ {
return self::buildMediaAction(
(string) ($backgroundData['path'] ?? ''),
(string) ($backgroundData['format'] ?? 'JPG'),
null,
(int) ($backgroundData['width'] ?? 1920),
(int) ($backgroundData['height'] ?? 1080),
(bool) ($backgroundData['bundleRelative'] ?? false),
LayerType::LAYER_TYPE_BACKGROUND,
);
}
private static function buildMediaAction(
string $absoluteUrl,
string $format,
?string $name = null,
int $imageWidth = 0,
int $imageHeight = 0,
bool $bundleRelative = false,
int $layerType = LayerType::LAYER_TYPE_FOREGROUND,
): Action {
if ($bundleRelative) { if ($bundleRelative) {
$filename = basename($absoluteUrl); $filename = basename($absoluteUrl);
$url = self::buildBundleRelativeUrl($filename); $url = self::buildBundleRelativeUrl($filename);
@ -335,7 +361,7 @@ final class ProFileGenerator
$mediaElement->setImage($imageTypeProperties); $mediaElement->setImage($imageTypeProperties);
$mediaType = new MediaType(); $mediaType = new MediaType();
$mediaType->setLayerType(LayerType::LAYER_TYPE_FOREGROUND); $mediaType->setLayerType($layerType);
$mediaType->setElement($mediaElement); $mediaType->setElement($mediaElement);
$mediaType->setAudio(new Audio()); $mediaType->setAudio(new Audio());
@ -558,6 +584,7 @@ final class ProFileGenerator
$color->setAlpha(1.0); $color->setAlpha(1.0);
$background = new Background(); $background = new Background();
$background->setColor($color); $background->setColor($color);
return $background; return $background;
} }
@ -565,6 +592,7 @@ final class ProFileGenerator
{ {
$url = new URL(); $url = new URL();
$url->setPlatform(UrlPlatform::PLATFORM_MACOS); $url->setPlatform(UrlPlatform::PLATFORM_MACOS);
return $url; return $url;
} }
@ -572,6 +600,7 @@ final class ProFileGenerator
{ {
$timeline = new Timeline(); $timeline = new Timeline();
$timeline->setDuration(300.0); $timeline->setDuration(300.0);
return $timeline; return $timeline;
} }
@ -694,8 +723,8 @@ RTF);
private static function newUuidString(): string private static function newUuidString(): string
{ {
$bytes = random_bytes(16); $bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); $bytes[6] = chr((ord($bytes[6]) & 0x0F) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); $bytes[8] = chr((ord($bytes[8]) & 0x3F) | 0x80);
$hex = bin2hex($bytes); $hex = bin2hex($bytes);
return strtoupper(sprintf( return strtoupper(sprintf(

View file

@ -6,6 +6,7 @@ namespace ProPresenter\Parser;
use Rv\Data\Action; use Rv\Data\Action;
use Rv\Data\Action\ActionType; use Rv\Data\Action\ActionType;
use Rv\Data\Action\LayerType;
use Rv\Data\Action\MacroType; use Rv\Data\Action\MacroType;
use Rv\Data\CollectionElementType; use Rv\Data\CollectionElementType;
use Rv\Data\Cue; use Rv\Data\Cue;
@ -118,6 +119,7 @@ class Slide
public function getTranslation(): ?TextElement public function getTranslation(): ?TextElement
{ {
$textElements = $this->getTextElements(); $textElements = $this->getTextElements();
return $textElements[1] ?? null; return $textElements[1] ?? null;
} }
@ -149,24 +151,28 @@ class Slide
public function getMacroName(): ?string public function getMacroName(): ?string
{ {
$macro = $this->findMacroAction(); $macro = $this->findMacroAction();
return $macro?->getMacro()?->getIdentification()?->getParameterName(); return $macro?->getMacro()?->getIdentification()?->getParameterName();
} }
public function getMacroUuid(): ?string public function getMacroUuid(): ?string
{ {
$macro = $this->findMacroAction(); $macro = $this->findMacroAction();
return $macro?->getMacro()?->getIdentification()?->getParameterUuid()?->getString(); return $macro?->getMacro()?->getIdentification()?->getParameterUuid()?->getString();
} }
public function getMacroCollectionName(): ?string public function getMacroCollectionName(): ?string
{ {
$macro = $this->findMacroAction(); $macro = $this->findMacroAction();
return $macro?->getMacro()?->getIdentification()?->getParentCollection()?->getParameterName(); return $macro?->getMacro()?->getIdentification()?->getParentCollection()?->getParameterName();
} }
public function getMacroCollectionUuid(): ?string public function getMacroCollectionUuid(): ?string
{ {
$macro = $this->findMacroAction(); $macro = $this->findMacroAction();
return $macro?->getMacro()?->getIdentification()?->getParentCollection()?->getParameterUuid()?->getString(); return $macro?->getMacro()?->getIdentification()?->getParentCollection()?->getParameterUuid()?->getString();
} }
@ -195,6 +201,7 @@ class Slide
$existingMacroAction->setType(ActionType::ACTION_TYPE_MACRO); $existingMacroAction->setType(ActionType::ACTION_TYPE_MACRO);
$existingMacroAction->setMacro($macroType); $existingMacroAction->setMacro($macroType);
$existingMacroAction->setIsEnabled(true); $existingMacroAction->setIsEnabled(true);
return; return;
} }
@ -232,18 +239,40 @@ class Slide
public function getMediaUrl(): ?string public function getMediaUrl(): ?string
{ {
$media = $this->findMediaAction(); $media = $this->findMediaAction();
return $media?->getMedia()?->getElement()?->getUrl()?->getAbsoluteString(); return $media?->getMedia()?->getElement()?->getUrl()?->getAbsoluteString();
} }
public function getMediaUuid(): ?string public function getMediaUuid(): ?string
{ {
$media = $this->findMediaAction(); $media = $this->findMediaAction();
return $media?->getMedia()?->getElement()?->getUuid()?->getString(); return $media?->getMedia()?->getElement()?->getUuid()?->getString();
} }
public function getMediaFormat(): ?string public function getMediaFormat(): ?string
{ {
$media = $this->findMediaAction(); $media = $this->findMediaAction();
return $media?->getMedia()?->getElement()?->getMetadata()?->getFormat();
}
public function hasBackgroundMedia(): bool
{
return $this->findBackgroundMediaAction() !== null;
}
public function getBackgroundMediaUrl(): ?string
{
$media = $this->findBackgroundMediaAction();
return $media?->getMedia()?->getElement()?->getUrl()?->getAbsoluteString();
}
public function getBackgroundMediaFormat(): ?string
{
$media = $this->findBackgroundMediaAction();
return $media?->getMedia()?->getElement()?->getMetadata()?->getFormat(); return $media?->getMedia()?->getElement()?->getMetadata()?->getFormat();
} }
@ -304,9 +333,19 @@ class Slide
} }
private function findMediaAction(): ?Action private function findMediaAction(): ?Action
{
return $this->findMediaActionByLayerType(LayerType::LAYER_TYPE_FOREGROUND);
}
private function findBackgroundMediaAction(): ?Action
{
return $this->findMediaActionByLayerType(LayerType::LAYER_TYPE_BACKGROUND);
}
private function findMediaActionByLayerType(int $layerType): ?Action
{ {
foreach ($this->cue->getActions() as $action) { foreach ($this->cue->getActions() as $action) {
if ($action->getType() === ActionType::ACTION_TYPE_MEDIA) { if ($action->getType() === ActionType::ACTION_TYPE_MEDIA && $action->getMedia()?->getLayerType() === $layerType) {
return $action; return $action;
} }
} }

View file

@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser\Tests;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ProPresenter\Parser\ProFileGenerator;
use Rv\Data\Action;
use Rv\Data\Action\ActionType;
use Rv\Data\Action\LayerType;
class BackgroundLayerTest extends TestCase
{
#[Test]
public function generated_text_slide_can_have_background_layer_media(): void
{
$song = ProFileGenerator::generate(
'Background Text Song',
[[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [[
'text' => 'Text auf Folie',
'background' => [
'path' => 'file:///tmp/bg.jpg',
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
],
]],
]],
[['name' => 'normal', 'groupNames' => ['Verse 1']]],
);
$slide = $song->getSlides()[0];
$mediaActions = self::mediaActions($slide->getCue()->getActions());
$this->assertCount(1, $slide->getTextElements());
$this->assertSame('Text auf Folie', $slide->getPlainText());
$this->assertCount(1, $mediaActions);
$this->assertSame(LayerType::LAYER_TYPE_BACKGROUND, $mediaActions[0]->getMedia()->getLayerType());
$this->assertSame('file:///tmp/bg.jpg', $mediaActions[0]->getMedia()->getElement()->getUrl()->getAbsoluteString());
$this->assertSame('JPG', $mediaActions[0]->getMedia()->getElement()->getMetadata()->getFormat());
$this->assertSame(1920.0, $mediaActions[0]->getMedia()->getElement()->getImage()->getDrawing()->getNaturalSize()->getWidth());
$this->assertSame(1080.0, $mediaActions[0]->getMedia()->getElement()->getImage()->getDrawing()->getNaturalSize()->getHeight());
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('file:///tmp/bg.jpg', $slide->getBackgroundMediaUrl());
$this->assertSame('JPG', $slide->getBackgroundMediaFormat());
}
#[Test]
public function generated_image_only_slide_has_no_text_elements(): void
{
$song = ProFileGenerator::generate(
'Image Only Background Song',
[[
'name' => 'Image',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [[
'text' => 'Dieser Text darf nicht erscheinen',
'imageOnly' => true,
'background' => [
'path' => 'file:///tmp/image-only.jpg',
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
],
]],
]],
[['name' => 'normal', 'groupNames' => ['Image']]],
);
$slide = $song->getSlides()[0];
$mediaActions = self::mediaActions($slide->getCue()->getActions());
$this->assertCount(0, $slide->getTextElements());
$this->assertSame('', $slide->getPlainText());
$this->assertCount(1, $mediaActions);
$this->assertSame(LayerType::LAYER_TYPE_BACKGROUND, $mediaActions[0]->getMedia()->getLayerType());
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('file:///tmp/image-only.jpg', $slide->getBackgroundMediaUrl());
}
#[Test]
public function generated_slide_can_have_background_and_foreground_media_in_order(): void
{
$song = ProFileGenerator::generate(
'Mixed Media Song',
[[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [[
'text' => 'Text vor Bild',
'background' => [
'path' => 'file:///tmp/background.jpg',
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
],
'media' => 'file:///tmp/foreground.png',
'format' => 'PNG',
'mediaWidth' => 640,
'mediaHeight' => 360,
]],
]],
[['name' => 'normal', 'groupNames' => ['Verse 1']]],
);
$slide = $song->getSlides()[0];
$mediaActions = self::mediaActions($slide->getCue()->getActions());
$this->assertCount(2, $mediaActions);
$this->assertSame(LayerType::LAYER_TYPE_BACKGROUND, $mediaActions[0]->getMedia()->getLayerType());
$this->assertSame(LayerType::LAYER_TYPE_FOREGROUND, $mediaActions[1]->getMedia()->getLayerType());
$this->assertSame('file:///tmp/background.jpg', $mediaActions[0]->getMedia()->getElement()->getUrl()->getAbsoluteString());
$this->assertSame('file:///tmp/foreground.png', $mediaActions[1]->getMedia()->getElement()->getUrl()->getAbsoluteString());
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertTrue($slide->hasMedia());
$this->assertSame('file:///tmp/background.jpg', $slide->getBackgroundMediaUrl());
$this->assertSame('file:///tmp/foreground.png', $slide->getMediaUrl());
}
/**
* @param iterable<Action> $actions
* @return Action[]
*/
private static function mediaActions(iterable $actions): array
{
$mediaActions = [];
foreach ($actions as $action) {
if ($action->getType() === ActionType::ACTION_TYPE_MEDIA) {
$mediaActions[] = $action;
}
}
return $mediaActions;
}
}