pp-planer/app/Services/SlideMediaElementBuilder.php
Thorsten Bus 94170857b6 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.
2026-07-07 22:15:12 +02:00

353 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}