From 22d98e22257284e20e249fdbf3fc5fc0cbf70b0f Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 1 Mar 2026 16:34:04 +0100 Subject: [PATCH] [AI] add ProFileGenerator to create .pro files from scratch Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- php/src/ProFileGenerator.php | 421 +++++++++++++++++++++++++++++ php/tests/ProFileGeneratorTest.php | 321 ++++++++++++++++++++++ 2 files changed, 742 insertions(+) create mode 100644 php/src/ProFileGenerator.php create mode 100644 php/tests/ProFileGeneratorTest.php diff --git a/php/src/ProFileGenerator.php b/php/src/ProFileGenerator.php new file mode 100644 index 0000000..fe50cf9 --- /dev/null +++ b/php/src/ProFileGenerator.php @@ -0,0 +1,421 @@ +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), + ); + } +} diff --git a/php/tests/ProFileGeneratorTest.php b/php/tests/ProFileGeneratorTest.php new file mode 100644 index 0000000..dbe893b --- /dev/null +++ b/php/tests/ProFileGeneratorTest.php @@ -0,0 +1,321 @@ +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()); + } +}