From 0e3c647cfca36642d39e102166d8e0b6d9df8e3f Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Mon, 30 Mar 2026 10:29:37 +0200 Subject: [PATCH] feat: probundle export with media, image upscaling, upload dimension warnings - Fix probundle exports missing images (double slides/ prefix in storage path) - Replace manual ZipArchive with PresentationBundle + ProBundleWriter from parser plugin - Add per-agenda-item download route and buttons for songs and slide items - Remove text layer from image-only slides in .pro generation - Fix image conversion: upscale small images, black bars on 2 sides max (contain) - Add upload warnings for non-16:9 and sub-1920x1080 images (German, non-blocking) - Update SlideFactory and all tests to use slides/ prefix in stored_filename - Add 11 new tests (agenda download, image conversion, upload warnings) --- app/Http/Controllers/ServiceController.php | 24 +++ app/Http/Controllers/SlideController.php | 25 ++- app/Services/FileConversionService.php | 35 +++- app/Services/PlaylistExportService.php | 5 +- app/Services/ProBundleExportService.php | 82 +++++--- app/Services/ProExportService.php | 26 ++- composer.json | 2 +- composer.lock | 6 +- database/factories/SlideFactory.php | 2 +- resources/js/Components/AgendaItemRow.vue | 59 ++++++ resources/js/Components/SlideUploader.vue | 48 ++++- resources/js/Components/SongAgendaItem.vue | 61 ++++++ routes/web.php | 1 + tests/Feature/AgendaItemDownloadTest.php | 232 +++++++++++++++++++++ tests/Feature/ChurchToolsSyncTest.php | 2 +- tests/Feature/FileConversionTest.php | 66 ++++++ tests/Feature/PlaylistExportTest.php | 8 +- tests/Feature/ProBundleExportTest.php | 20 +- tests/Feature/SlideControllerTest.php | 62 ++++++ 19 files changed, 697 insertions(+), 69 deletions(-) create mode 100644 tests/Feature/AgendaItemDownloadTest.php diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index 7ab41b1..3f2cf6a 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -16,6 +16,7 @@ use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; +use RuntimeException; use Symfony\Component\HttpFoundation\BinaryFileResponse; class ServiceController extends Controller @@ -388,4 +389,27 @@ public function downloadBundle(Request $request, Service $service, string $block ) ->deleteFileAfterSend(true); } + + public function downloadAgendaItem(Service $service, ServiceAgendaItem $agendaItem): JsonResponse|BinaryFileResponse + { + if ((int) $agendaItem->service_id !== (int) $service->id) { + abort(404); + } + + try { + $bundlePath = app(ProBundleExportService::class)->generateAgendaItemBundle($agendaItem); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + $safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $agendaItem->title ?: 'element'); + + return response() + ->download( + $bundlePath, + $safeTitle.'.probundle', + ['Content-Type' => 'application/zip'] + ) + ->deleteFileAfterSend(true); + } } diff --git a/app/Http/Controllers/SlideController.php b/app/Http/Controllers/SlideController.php index 2195a06..d8033e1 100644 --- a/app/Http/Controllers/SlideController.php +++ b/app/Http/Controllers/SlideController.php @@ -175,10 +175,16 @@ private function handleImage( 'sort_order' => $this->nextSortOrder($type, $serviceId), ]); - return response()->json([ + $response = [ 'success' => true, 'slide' => $slide, - ]); + ]; + + if (! empty($result['warnings'])) { + $response['warnings'] = $result['warnings']; + } + + return response()->json($response); } catch (InvalidArgumentException $e) { return response()->json([ 'success' => false, @@ -237,6 +243,7 @@ private function handleZip( try { $results = $conversionService->processZip($file); $slides = []; + $allWarnings = []; $sortOrder = $this->nextSortOrder($type, $serviceId); @@ -258,13 +265,23 @@ private function handleZip( 'uploaded_at' => now(), 'sort_order' => $sortOrder++, ]); + + if (! empty($result['warnings'])) { + array_push($allWarnings, ...$result['warnings']); + } } - return response()->json([ + $response = [ 'success' => true, 'slides' => $slides, 'count' => count($slides), - ]); + ]; + + if (! empty($allWarnings)) { + $response['warnings'] = array_values(array_unique($allWarnings)); + } + + return response()->json($response); } catch (InvalidArgumentException $e) { Log::warning('ZIP-Verarbeitung fehlgeschlagen', ['error' => $e->getMessage()]); diff --git a/app/Services/FileConversionService.php b/app/Services/FileConversionService.php index 1f39ec0..768863c 100644 --- a/app/Services/FileConversionService.php +++ b/app/Services/FileConversionService.php @@ -41,17 +41,21 @@ public function convertImage(UploadedFile|string|SplFileInfo $file): array $this->ensureDirectory(dirname($targetPath)); $manager = $this->createImageManager(); - $canvas = $manager->create(1920, 1080)->fill('000000'); $image = $manager->read($sourcePath); - $image->scaleDown(width: 1920, height: 1080); - $canvas->place($image, 'center'); - $canvas->save($targetPath, quality: 90); + + $originalWidth = $image->width(); + $originalHeight = $image->height(); + $warnings = $this->checkImageDimensions($originalWidth, $originalHeight); + + $image->contain(1920, 1080, '000000', 'center'); + $image->save($targetPath, quality: 90); $thumbnailPath = $this->generateThumbnail($relativePath); return [ 'filename' => $relativePath, 'thumbnail' => $thumbnailPath, + 'warnings' => $warnings, ]; } @@ -252,6 +256,29 @@ private function deleteDirectory(string $directory): void @rmdir($directory); } + /** @return string[] */ + private function checkImageDimensions(int $width, int $height): array + { + $warnings = []; + + $isExactOrLarger16by9 = $width >= 1920 && $height >= 1080 + && abs($width / $height - 16 / 9) < 0.01; + + if (! $isExactOrLarger16by9) { + $warnings[] = 'Das Bild hat nicht das optimale Seitenverhältnis von 16:9. ' + .'Für die beste Darstellung verwende bitte Bilder mit exakt 1920×1080 Pixeln. ' + .'Das Bild wurde trotzdem verarbeitet, es können aber schwarze Ränder entstehen.'; + } + + if ($width < 1920 || $height < 1080) { + $warnings[] = "Das Bild ({$width}×{$height}) ist kleiner als 1920×1080 und wurde hochskaliert. " + .'Dadurch kann die Qualität schlechter sein. ' + .'Lade am besten Bilder mit mindestens 1920×1080 Pixeln hoch.'; + } + + return $warnings; + } + private function ensureDirectory(string $directory): void { if (is_dir($directory)) { diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index 03bd5c1..f923224 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -261,20 +261,19 @@ private function addSlidesFromCollection( $imageFiles = []; foreach ($slides->values() as $index => $slide) { - $storedPath = Storage::disk('public')->path('slides/'.$slide->stored_filename); + $storedPath = Storage::disk('public')->path($slide->stored_filename); if (! file_exists($storedPath)) { continue; } - $imageFilename = $prefix.'_'.($index + 1).'_'.$slide->stored_filename; + $imageFilename = $prefix.'_'.($index + 1).'_'.basename($slide->stored_filename); $destPath = $tempDir.'/'.$imageFilename; copy($storedPath, $destPath); $imageFiles[$imageFilename] = file_get_contents($destPath); $slideDataList[] = [ - 'text' => '', 'media' => $imageFilename, 'format' => 'JPG', 'label' => $slide->original_filename, diff --git a/app/Services/ProBundleExportService.php b/app/Services/ProBundleExportService.php index 78fd06b..fbadd56 100644 --- a/app/Services/ProBundleExportService.php +++ b/app/Services/ProBundleExportService.php @@ -3,11 +3,13 @@ namespace App\Services; use App\Models\Service; +use App\Models\ServiceAgendaItem; use Illuminate\Support\Facades\Storage; use InvalidArgumentException; +use ProPresenter\Parser\PresentationBundle; +use ProPresenter\Parser\ProBundleWriter; use ProPresenter\Parser\ProFileGenerator; use RuntimeException; -use ZipArchive; class ProBundleExportService { @@ -25,11 +27,53 @@ public function generateBundle(Service $service, string $blockType): string ->get(); $groupName = ucfirst($blockType); + + return $this->buildBundleFromSlides($slides, $groupName); + } + + public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string + { + $agendaItem->loadMissing([ + 'slides', + 'serviceSong.song.groups.slides', + 'serviceSong.song.arrangements.arrangementGroups.group', + ]); + + $title = $agendaItem->title ?: 'Ablauf-Element'; + + if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) { + $song = $agendaItem->serviceSong->song; + + if ($song->groups()->count() === 0) { + throw new RuntimeException('Song "'.$song->title.'" hat keine Gruppen.'); + } + + $parserSong = (new ProExportService)->generateParserSong($song); + $proFilename = self::safeFilename($song->title).'.pro'; + + $bundle = new PresentationBundle($parserSong, $proFilename); + $bundlePath = sys_get_temp_dir().'/'.uniqid('agenda-song-').'.probundle'; + ProBundleWriter::write($bundle, $bundlePath); + + return $bundlePath; + } + + $slides = $agendaItem->slides() + ->whereNull('deleted_at') + ->orderBy('sort_order') + ->get(); + + return $this->buildBundleFromSlides($slides, $title); + } + + /** @param \Illuminate\Database\Eloquent\Collection $slides */ + private function buildBundleFromSlides($slides, string $groupName): string + { $slideData = []; $mediaFiles = []; foreach ($slides as $slide) { - $sourcePath = Storage::disk('public')->path('slides/'.$slide->stored_filename); + $sourcePath = Storage::disk('public')->path($slide->stored_filename); if (! file_exists($sourcePath)) { continue; } @@ -43,9 +87,9 @@ public function generateBundle(Service $service, string $blockType): string $mediaFiles[$imageFilename] = $imageContent; $slideData[] = [ - 'text' => $slide->original_filename ?? '', 'media' => $imageFilename, 'format' => 'JPG', + 'label' => $slide->original_filename, ]; } @@ -65,37 +109,17 @@ public function generateBundle(Service $service, string $blockType): string ]; $song = ProFileGenerator::generate($groupName, $groups, $arrangements); - $protoBytes = $song->getPresentation()->serializeToString(); + $proFilename = self::safeFilename($groupName).'.pro'; - $bundlePath = sys_get_temp_dir().'/'.uniqid($blockType.'-').'.probundle'; - - $zip = new ZipArchive; - $openResult = $zip->open($bundlePath, ZipArchive::CREATE | ZipArchive::OVERWRITE); - if ($openResult !== true) { - throw new InvalidArgumentException('Konnte .probundle nicht erstellen.'); - } - - $this->addStoredEntry($zip, 'data', $protoBytes); - - foreach ($mediaFiles as $filename => $contents) { - $this->addStoredEntry($zip, $filename, $contents); - } - - if (! $zip->close()) { - throw new RuntimeException('Fehler beim Finalisieren der .probundle Datei.'); - } + $bundle = new PresentationBundle($song, $proFilename, $mediaFiles); + $bundlePath = sys_get_temp_dir().'/'.uniqid('bundle-').'.probundle'; + ProBundleWriter::write($bundle, $bundlePath); return $bundlePath; } - private function addStoredEntry(ZipArchive $zip, string $entryName, string $contents): void + private static function safeFilename(string $name): string { - if (! $zip->addFromString($entryName, $contents)) { - throw new RuntimeException(sprintf('Fehler beim Hinzufügen von %s zur .probundle Datei.', $entryName)); - } - - if (! $zip->setCompressionName($entryName, ZipArchive::CM_STORE)) { - throw new RuntimeException(sprintf('Fehler beim Setzen der Kompression für %s.', $entryName)); - } + return preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name) ?: 'presentation'; } } diff --git a/app/Services/ProExportService.php b/app/Services/ProExportService.php index 45f5c24..11a0a1b 100644 --- a/app/Services/ProExportService.php +++ b/app/Services/ProExportService.php @@ -10,19 +10,31 @@ class ProExportService { public function generateProFile(Song $song): string { - $song->loadMissing(['groups.slides', 'arrangements.arrangementGroups.group']); - - $groups = $this->buildGroups($song); - $arrangements = $this->buildArrangements($song); - $ccli = $this->buildCcliMetadata($song); - $tempPath = sys_get_temp_dir().'/'.uniqid('pro-export-').'.pro'; - ProFileGenerator::generateAndWrite($tempPath, $song->title, $groups, $arrangements, $ccli); + ProFileGenerator::generateAndWrite( + $tempPath, + $song->title, + $this->buildGroups($song), + $this->buildArrangements($song), + $this->buildCcliMetadata($song), + ); return $tempPath; } + public function generateParserSong(Song $song): \ProPresenter\Parser\Song + { + $song->loadMissing(['groups.slides', 'arrangements.arrangementGroups.group']); + + return ProFileGenerator::generate( + $song->title, + $this->buildGroups($song), + $this->buildArrangements($song), + $this->buildCcliMetadata($song), + ); + } + private function buildGroups(Song $song): array { $groups = []; diff --git a/composer.json b/composer.json index 156cd8d..6e42c35 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "repositories": [ { "type": "path", - "url": "../propresenter-work/php" + "url": "../propresenter/php" } ], "require": { diff --git a/composer.lock b/composer.lock index e188d5f..088b016 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "acd6c572df1b1867949b4c8a5061553e", + "content-hash": "746ee5a4cf131b6b821d1abae2818edf", "packages": [ { "name": "5pm-hdh/churchtools-api", @@ -3815,10 +3815,10 @@ }, { "name": "propresenter/parser", - "version": "dev-propresenter-parser", + "version": "dev-master", "dist": { "type": "path", - "url": "../propresenter-work/php", + "url": "../propresenter/php", "reference": "43b6fa020bc05e5e9b58254ebcfbfb811f920ccf" }, "require": { diff --git a/database/factories/SlideFactory.php b/database/factories/SlideFactory.php index de4f86d..7e2a1d0 100644 --- a/database/factories/SlideFactory.php +++ b/database/factories/SlideFactory.php @@ -16,7 +16,7 @@ public function definition(): array 'type' => $this->faker->randomElement(['information', 'moderation', 'sermon']), 'service_id' => Service::factory(), 'original_filename' => $this->faker->word().'.jpg', - 'stored_filename' => $this->faker->uuid().'.jpg', + 'stored_filename' => 'slides/'.$this->faker->uuid().'.jpg', 'thumbnail_filename' => $this->faker->uuid().'_thumb.jpg', 'expire_date' => $this->faker->optional()->dateTimeBetween('now', '+12 months'), 'uploader_name' => $this->faker->name(), diff --git a/resources/js/Components/AgendaItemRow.vue b/resources/js/Components/AgendaItemRow.vue index 0518d02..de4f499 100644 --- a/resources/js/Components/AgendaItemRow.vue +++ b/resources/js/Components/AgendaItemRow.vue @@ -14,6 +14,46 @@ const props = defineProps({ const emit = defineEmits(['slides-updated', 'scroll-to-info']) const showUploader = ref(false) +const downloading = ref(false) + +async function downloadBundle() { + downloading.value = true + try { + const response = await fetch( + route('services.agenda-item.download', { + service: props.serviceId, + agendaItem: props.agendaItem.id, + }), + { + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '', + }, + }, + ) + + if (!response.ok) { + const data = await response.json().catch(() => ({})) + alert(data.message || 'Fehler beim Herunterladen.') + return + } + + const disposition = response.headers.get('content-disposition') + const filenameMatch = disposition?.match(/filename="?([^"]+)"?/) + const filename = filenameMatch?.[1] || 'element.probundle' + + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + } catch { + alert('Fehler beim Herunterladen.') + } finally { + downloading.value = false + } +} // Preview state const previewSlide = ref(null) @@ -263,6 +303,25 @@ function scrollToInfo() {
+ + +