- 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.
353 lines
12 KiB
PHP
353 lines
12 KiB
PHP
<?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;
|
||
}
|
||
}
|