[AI] add ProFileGenerator to create .pro files from scratch

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Thorsten Bus 2026-03-01 16:34:04 +01:00
parent 83fb1a71f4
commit 22d98e2225
2 changed files with 742 additions and 0 deletions

View file

@ -0,0 +1,421 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser;
use Rv\Data\Action;
use Rv\Data\Action\ActionType;
use Rv\Data\Action\SlideType;
use Rv\Data\ApplicationInfo;
use Rv\Data\ApplicationInfo\Application;
use Rv\Data\ApplicationInfo\Platform;
use Rv\Data\Color;
use Rv\Data\Cue;
use Rv\Data\Graphics\Element as GraphicsElement;
use Rv\Data\Graphics\Feather;
use Rv\Data\Graphics\Feather\Style as FeatherStyle;
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\Shadow;
use Rv\Data\Graphics\Shadow\Style as ShadowStyle;
use Rv\Data\Graphics\Size;
use Rv\Data\Graphics\Stroke;
use Rv\Data\Graphics\Stroke\Style as StrokeStyle;
use Rv\Data\Graphics\Text;
use Rv\Data\Graphics\Text\VerticalAlignment;
use Rv\Data\Group;
use Rv\Data\Presentation;
use Rv\Data\Presentation\Arrangement;
use Rv\Data\Presentation\CCLI;
use Rv\Data\Presentation\CueGroup;
use Rv\Data\PresentationSlide;
use Rv\Data\Slide;
use Rv\Data\Slide\Element as SlideElement;
use Rv\Data\Slide\Element\TextScroller;
use Rv\Data\UUID;
use Rv\Data\Version;
final class ProFileGenerator
{
public static function generate(
string $name,
array $groups,
array $arrangements,
array $ccli = [],
): Song {
$presentation = new Presentation();
$presentation->setApplicationInfo(self::buildApplicationInfo());
$presentation->setUuid(self::newUuid());
$presentation->setName($name);
$cueGroups = [];
$cues = [];
$groupUuidsByName = [];
foreach ($groups as $groupData) {
$groupUuid = self::newUuidString();
$groupUuidsByName[$groupData['name']] = $groupUuid;
$group = new Group();
$group->setUuid(self::uuidFromString($groupUuid));
$group->setName($groupData['name']);
$group->setColor(self::colorFromArray($groupData['color']));
$cueIdentifiers = [];
foreach ($groupData['slides'] as $slideData) {
$cueUuid = self::newUuidString();
$cueIdentifiers[] = self::uuidFromString($cueUuid);
$cues[] = self::buildCue($cueUuid, $slideData['text'], $slideData['translation'] ?? null);
}
$cueGroup = new CueGroup();
$cueGroup->setGroup($group);
$cueGroup->setCueIdentifiers($cueIdentifiers);
$cueGroups[] = $cueGroup;
}
$presentation->setCues($cues);
$presentation->setCueGroups($cueGroups);
$arrangementProtos = [];
foreach ($arrangements as $arrangementData) {
$arrangement = new Arrangement();
$arrangement->setUuid(self::newUuid());
$arrangement->setName($arrangementData['name']);
$groupIdentifiers = [];
foreach ($arrangementData['groupNames'] as $groupName) {
if (!isset($groupUuidsByName[$groupName])) {
continue;
}
$groupIdentifiers[] = self::uuidFromString($groupUuidsByName[$groupName]);
}
$arrangement->setGroupIdentifiers($groupIdentifiers);
$arrangementProtos[] = $arrangement;
}
$presentation->setArrangements($arrangementProtos);
if (isset($arrangementProtos[0])) {
$presentation->setSelectedArrangement($arrangementProtos[0]->getUuid());
}
self::applyCcliMetadata($presentation, $ccli);
return new Song($presentation);
}
public static function generateAndWrite(
string $filePath,
string $name,
array $groups,
array $arrangements,
array $ccli = [],
): Song {
$song = self::generate($name, $groups, $arrangements, $ccli);
ProFileWriter::write($song, $filePath);
return $song;
}
private static function buildApplicationInfo(): ApplicationInfo
{
$version = new Version();
$version->setBuild('335544354');
$applicationInfo = new ApplicationInfo();
$applicationInfo->setPlatform(Platform::PLATFORM_MACOS);
$applicationInfo->setApplication(Application::APPLICATION_PROPRESENTER);
$applicationInfo->setPlatformVersion($version);
$applicationInfo->setApplicationVersion($version);
return $applicationInfo;
}
private static function buildCue(string $cueUuid, string $text, ?string $translation): Cue
{
$elements = [self::buildSlideElement('Orginal', $text)];
if ($translation !== null) {
$elements[] = self::buildSlideElement('Deutsch', $translation);
}
$slide = new Slide();
$slide->setUuid(self::newUuid());
$slide->setElements($elements);
$presentationSlide = new PresentationSlide();
$presentationSlide->setBaseSlide($slide);
$slideType = new SlideType();
$slideType->setPresentation($presentationSlide);
$action = new Action();
$action->setUuid(self::newUuid());
$action->setType(ActionType::ACTION_TYPE_PRESENTATION_SLIDE);
$action->setSlide($slideType);
$action->setIsEnabled(true);
$cue = new Cue();
$cue->setUuid(self::uuidFromString($cueUuid));
$cue->setActions([$action]);
$cue->setIsEnabled(true);
return $cue;
}
private static function buildSlideElement(string $name, string $text): SlideElement
{
$graphicsElement = new GraphicsElement();
$graphicsElement->setUuid(self::newUuid());
$graphicsElement->setName($name);
$graphicsElement->setBounds(self::buildBounds());
$graphicsElement->setOpacity(1.0);
$graphicsElement->setPath(self::buildPath());
$graphicsElement->setFill(self::buildFill());
$graphicsElement->setStroke(self::buildStroke());
$graphicsElement->setShadow(self::buildShadow());
$graphicsElement->setFeather(self::buildFeather());
$graphicsText = new Text();
$graphicsText->setRtfData(self::buildRtf($text));
$graphicsText->setVerticalAlignment(VerticalAlignment::VERTICAL_ALIGNMENT_MIDDLE);
$graphicsElement->setText($graphicsText);
$slideElement = new SlideElement();
$slideElement->setElement($graphicsElement);
$slideElement->setInfo(3);
$slideElement->setTextScroller(self::buildTextScroller());
return $slideElement;
}
private static function buildBounds(): Rect
{
$origin = new Point();
$origin->setX(150);
$origin->setY(100);
$size = new Size();
$size->setWidth(1620);
$size->setHeight(880);
$rect = new Rect();
$rect->setOrigin($origin);
$rect->setSize($size);
return $rect;
}
private static function buildPath(): Path
{
$path = new Path();
$path->setClosed(true);
$path->setPoints([
self::buildBezierPoint(0.0, 0.0),
self::buildBezierPoint(1.0, 0.0),
self::buildBezierPoint(1.0, 1.0),
self::buildBezierPoint(0.0, 1.0),
]);
$shape = new Shape();
$shape->setType(ShapeType::TYPE_RECTANGLE);
$path->setShape($shape);
return $path;
}
private static function buildBezierPoint(float $x, float $y): BezierPoint
{
$point = new Point();
$point->setX($x);
$point->setY($y);
$bezierPoint = new BezierPoint();
$bezierPoint->setPoint($point);
$bezierPoint->setQ0($point);
$bezierPoint->setQ1($point);
$bezierPoint->setCurved(false);
return $bezierPoint;
}
private static function buildFill(): Fill
{
$fill = new Fill();
$fill->setEnable(true);
$fill->setColor(self::buildColor(0.13, 0.59, 0.95, 1.0));
return $fill;
}
private static function buildStroke(): Stroke
{
$stroke = new Stroke();
$stroke->setStyle(StrokeStyle::STYLE_SOLID_LINE);
$stroke->setEnable(true);
$stroke->setWidth(3.0);
$stroke->setColor(self::buildColor(1.0, 1.0, 1.0, 1.0));
return $stroke;
}
private static function buildShadow(): Shadow
{
$shadow = new Shadow();
$shadow->setStyle(ShadowStyle::STYLE_DROP);
$shadow->setEnable(true);
$shadow->setAngle(315.0);
$shadow->setOffset(5.0);
$shadow->setRadius(5.0);
$shadow->setColor(self::buildColor(0.0, 0.0, 0.0, 1.0));
$shadow->setOpacity(0.75);
return $shadow;
}
private static function buildFeather(): Feather
{
$feather = new Feather();
$feather->setStyle(FeatherStyle::STYLE_INSIDE);
$feather->setEnable(true);
$feather->setRadius(0.05);
return $feather;
}
private static function buildTextScroller(): TextScroller
{
$textScroller = new TextScroller();
$textScroller->setShouldScroll(true);
$textScroller->setScrollRate(0.5);
$textScroller->setShouldRepeat(true);
$textScroller->setRepeatDistance(0.0617);
return $textScroller;
}
private static function colorFromArray(array $rgba): Color
{
return self::buildColor(
(float) ($rgba[0] ?? 0.0),
(float) ($rgba[1] ?? 0.0),
(float) ($rgba[2] ?? 0.0),
(float) ($rgba[3] ?? 1.0),
);
}
private static function buildColor(float $r, float $g, float $b, float $a): Color
{
$color = new Color();
$color->setRed($r);
$color->setGreen($g);
$color->setBlue($b);
$color->setAlpha($a);
return $color;
}
private static function applyCcliMetadata(Presentation $presentation, array $ccli): void
{
if ($ccli === []) {
return;
}
$metadata = new CCLI();
if (isset($ccli['author'])) {
$metadata->setAuthor((string) $ccli['author']);
}
if (isset($ccli['song_title'])) {
$metadata->setSongTitle((string) $ccli['song_title']);
}
if (isset($ccli['publisher'])) {
$metadata->setPublisher((string) $ccli['publisher']);
}
if (isset($ccli['copyright_year'])) {
$metadata->setCopyrightYear((int) $ccli['copyright_year']);
}
if (isset($ccli['song_number'])) {
$metadata->setSongNumber((int) $ccli['song_number']);
}
if (isset($ccli['display'])) {
$metadata->setDisplay((bool) $ccli['display']);
}
if (isset($ccli['artist_credits'])) {
$metadata->setArtistCredits((string) $ccli['artist_credits']);
}
if (isset($ccli['album'])) {
$metadata->setAlbum((string) $ccli['album']);
}
$presentation->setCcli($metadata);
}
private static function buildRtf(string $text): string
{
$encodedText = self::encodePlainTextForRtf($text);
return str_replace('ENCODED_TEXT_HERE', $encodedText, <<<'RTF'
{\rtf1\ansi\ansicpg1252\cocoartf2761
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 HelveticaNeue;}
{\colortbl;\red255\green255\blue255;\red255\green255\blue255;}
{\*\expandedcolortbl;;\csgray\c100000;}
\deftab1680
\pard\pardeftab1680\pardirnatural\qc\partightenfactor0
\f0\fs84 \cf2 \CocoaLigature0 ENCODED_TEXT_HERE}
RTF);
}
private static function encodePlainTextForRtf(string $text): string
{
$text = str_replace(["\r\n", "\r"], "\n", $text);
$text = strtr($text, [
'ü' => "\\'fc",
'ö' => "\\'f6",
'ä' => "\\'e4",
'ß' => "\\'df",
'Ü' => "\\'dc",
'Ö' => "\\'d6",
'Ä' => "\\'c4",
]);
return str_replace("\n", "\\\n", $text);
}
private static function newUuid(): UUID
{
return self::uuidFromString(self::newUuidString());
}
private static function uuidFromString(string $uuid): UUID
{
$message = new UUID();
$message->setString($uuid);
return $message;
}
private static function newUuidString(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
$hex = bin2hex($bytes);
return 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),
);
}
}

View file

@ -0,0 +1,321 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser\Tests;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProFileReader;
use ProPresenter\Parser\ProFileWriter;
class ProFileGeneratorTest extends TestCase
{
private string $tmpDir;
protected function setUp(): void
{
$this->tmpDir = sys_get_temp_dir() . '/propresenter-generator-test-' . uniqid();
mkdir($this->tmpDir, 0777, true);
}
protected function tearDown(): void
{
if (!is_dir($this->tmpDir)) {
return;
}
foreach (scandir($this->tmpDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
@unlink($this->tmpDir . '/' . $entry);
}
@rmdir($this->tmpDir);
}
#[Test]
public function testGenerateCreatesValidSong(): void
{
$song = ProFileGenerator::generate(
'Simple Song',
[
[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [
['text' => 'Hello World'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['Verse 1']],
],
);
$this->assertSame('Simple Song', $song->getName());
$this->assertCount(1, $song->getGroups());
$this->assertCount(1, $song->getSlides());
$this->assertSame('Hello World', $song->getSlides()[0]->getPlainText());
$arrangement = $song->getArrangementByName('normal');
$this->assertNotNull($arrangement);
$groups = $song->getGroupsForArrangement($arrangement);
$this->assertCount(1, $groups);
$this->assertSame('Verse 1', $groups[0]->getName());
}
#[Test]
public function testGenerateWithMultipleGroupsAndArrangements(): void
{
$song = ProFileGenerator::generate(
'Multi Song',
[
[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [
['text' => 'V1.1'],
['text' => 'V1.2'],
],
],
[
'name' => 'Chorus',
'color' => [0.4, 0.5, 0.6, 1.0],
'slides' => [
['text' => 'C1'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['Verse 1', 'Chorus']],
['name' => 'short', 'groupNames' => ['Chorus']],
],
);
$this->assertSame(['Verse 1', 'Chorus'], array_map(fn ($group) => $group->getName(), $song->getGroups()));
$verse1 = $song->getGroupByName('Verse 1');
$this->assertNotNull($verse1);
$verseSlides = $song->getSlidesForGroup($verse1);
$this->assertSame(['V1.1', 'V1.2'], array_map(fn ($slide) => $slide->getPlainText(), $verseSlides));
$chorus = $song->getGroupByName('Chorus');
$this->assertNotNull($chorus);
$chorusSlides = $song->getSlidesForGroup($chorus);
$this->assertSame(['C1'], array_map(fn ($slide) => $slide->getPlainText(), $chorusSlides));
$normal = $song->getArrangementByName('normal');
$this->assertNotNull($normal);
$this->assertSame(
['Verse 1', 'Chorus'],
array_map(fn ($group) => $group->getName(), $song->getGroupsForArrangement($normal)),
);
$short = $song->getArrangementByName('short');
$this->assertNotNull($short);
$this->assertSame(
['Chorus'],
array_map(fn ($group) => $group->getName(), $song->getGroupsForArrangement($short)),
);
}
#[Test]
public function testGenerateWithTranslation(): void
{
$song = ProFileGenerator::generate(
'Translation Song',
[
[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [
['text' => 'Original', 'translation' => 'Translated'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['Verse 1']],
],
);
$slide = $song->getSlides()[0];
$this->assertTrue($slide->hasTranslation());
$this->assertSame('Translated', $slide->getTranslation()?->getPlainText());
}
#[Test]
public function testGenerateWithCcliMetadata(): void
{
$song = ProFileGenerator::generate(
'CCLI Song',
[
[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [
['text' => 'Line'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['Verse 1']],
],
[
'author' => 'Author Name',
'song_title' => 'Song Title',
'publisher' => 'Publisher Name',
'copyright_year' => 2024,
'song_number' => 12345,
'display' => true,
'artist_credits' => 'Artist Credits',
'album' => 'Album Name',
],
);
$this->assertSame('Author Name', $song->getCcliAuthor());
$this->assertSame('Song Title', $song->getCcliSongTitle());
$this->assertSame('Publisher Name', $song->getCcliPublisher());
$this->assertSame(2024, $song->getCcliCopyrightYear());
$this->assertSame(12345, $song->getCcliSongNumber());
$this->assertTrue($song->getCcliDisplay());
$this->assertSame('Artist Credits', $song->getCcliArtistCredits());
$this->assertSame('Album Name', $song->getCcliAlbum());
}
#[Test]
public function testRoundTripFromTestPro(): void
{
$original = ProFileReader::read('/Users/thorsten/AI/propresenter/ref/Test.pro');
$groups = [];
foreach ($original->getGroups() as $group) {
$color = $group->getColor();
$slides = [];
foreach ($original->getSlidesForGroup($group) as $slide) {
$slides[] = [
'text' => $slide->getPlainText(),
'translation' => $slide->hasTranslation() ? $slide->getTranslation()?->getPlainText() : null,
];
}
$groups[] = [
'name' => $group->getName(),
'color' => [
$color['r'] ?? 0.0,
$color['g'] ?? 0.0,
$color['b'] ?? 0.0,
$color['a'] ?? 1.0,
],
'slides' => $slides,
];
}
$arrangements = [];
foreach ($original->getArrangements() as $arrangement) {
$arrangements[] = [
'name' => $arrangement->getName(),
'groupNames' => array_map(
fn ($group) => $group->getName(),
$original->getGroupsForArrangement($arrangement),
),
];
}
$ccli = [
'author' => $original->getCcliAuthor(),
'song_title' => $original->getCcliSongTitle(),
'publisher' => $original->getCcliPublisher(),
'copyright_year' => $original->getCcliCopyrightYear(),
'song_number' => $original->getCcliSongNumber(),
'display' => $original->getCcliDisplay(),
'artist_credits' => $original->getCcliArtistCredits(),
'album' => $original->getCcliAlbum(),
];
$generated = ProFileGenerator::generate($original->getName(), $groups, $arrangements, $ccli);
$filePath = $this->tmpDir . '/test-roundtrip.pro';
ProFileWriter::write($generated, $filePath);
$roundTrip = ProFileReader::read($filePath);
$this->assertSame($original->getName(), $roundTrip->getName());
$this->assertSame(
array_map(fn ($group) => $group->getName(), $original->getGroups()),
array_map(fn ($group) => $group->getName(), $roundTrip->getGroups()),
);
foreach ($original->getGroups() as $group) {
$actualGroup = $roundTrip->getGroupByName($group->getName());
$this->assertNotNull($actualGroup);
$expectedSlides = $original->getSlidesForGroup($group);
$actualSlides = $roundTrip->getSlidesForGroup($actualGroup);
$this->assertCount(count($expectedSlides), $actualSlides);
foreach ($expectedSlides as $index => $expectedSlide) {
$actualSlide = $actualSlides[$index];
$this->assertSame($expectedSlide->getPlainText(), $actualSlide->getPlainText());
$this->assertSame($expectedSlide->hasTranslation(), $actualSlide->hasTranslation());
if ($expectedSlide->hasTranslation()) {
$this->assertSame(
$expectedSlide->getTranslation()?->getPlainText(),
$actualSlide->getTranslation()?->getPlainText(),
);
}
}
}
$this->assertSame(
array_map(fn ($arrangement) => $arrangement->getName(), $original->getArrangements()),
array_map(fn ($arrangement) => $arrangement->getName(), $roundTrip->getArrangements()),
);
foreach ($original->getArrangements() as $arrangement) {
$roundTripArrangement = $roundTrip->getArrangementByName($arrangement->getName());
$this->assertNotNull($roundTripArrangement);
$expectedNames = array_map(
fn ($group) => $group->getName(),
$original->getGroupsForArrangement($arrangement),
);
$actualNames = array_map(
fn ($group) => $group->getName(),
$roundTrip->getGroupsForArrangement($roundTripArrangement),
);
$this->assertSame($expectedNames, $actualNames);
}
}
#[Test]
public function testGenerateAndWriteCreatesFile(): void
{
$filePath = $this->tmpDir . '/generated.pro';
ProFileGenerator::generateAndWrite(
$filePath,
'Write Song',
[
[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [
['text' => 'Line 1'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['Verse 1']],
],
);
$this->assertFileExists($filePath);
$song = ProFileReader::read($filePath);
$this->assertSame('Write Song', $song->getName());
}
}