From 29d18386905b3240aa7e7d6611bc11d487eda4b7 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Thu, 18 Sep 2025 14:55:45 +0200 Subject: [PATCH] refactor: image render pipeline --- app/Services/ImageGenerationService.php | 354 ++++++------------ .../Services/ImageGenerationServiceTest.php | 4 +- 2 files changed, 109 insertions(+), 249 deletions(-) diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php index 3a8a88d..a0a78a3 100644 --- a/app/Services/ImageGenerationService.php +++ b/app/Services/ImageGenerationService.php @@ -6,79 +6,93 @@ use App\Enums\ImageFormat; use App\Models\Device; use App\Models\DeviceModel; use App\Models\Plugin; +use Bnussbau\TrmnlPipeline\Stages\BrowserStage; +use Bnussbau\TrmnlPipeline\Stages\ImageStage; +use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Exception; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; -use Imagick; -use ImagickException; -use ImagickPixel; use Ramsey\Uuid\Uuid; use RuntimeException; -use Spatie\Browsershot\Browsershot; use Wnx\SidecarBrowsershot\BrowsershotLambda; +use function config; +use function file_exists; +use function filesize; + class ImageGenerationService { public static function generateImage(string $markup, $deviceId): string { $device = Device::with('deviceModel')->find($deviceId); $uuid = Uuid::uuid4()->toString(); - $pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png'); - $bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp'); - // Get image generation settings from DeviceModel if available, otherwise use device settings - $imageSettings = self::getImageSettings($device); + try { + // Get image generation settings from DeviceModel if available, otherwise use device settings + $imageSettings = self::getImageSettings($device); - // Generate PNG - if (config('app.puppeteer_mode') === 'sidecar-aws') { - try { - $browsershot = BrowsershotLambda::html($markup) - ->windowSize(800, 480); + $fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png'; + $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); - if (config('app.puppeteer_wait_for_network_idle')) { - $browsershot->waitUntilNetworkIdle(); - } - - $browsershot->save($pngPath); - } catch (Exception $e) { - Log::error('Failed to generate PNG: '.$e->getMessage()); - throw new RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e); + // Create custom Browsershot instance if using AWS Lambda + $browsershotInstance = null; + if (config('app.puppeteer_mode') === 'sidecar-aws') { + $browsershotInstance = new BrowsershotLambda(); } - } else { - try { - $browsershot = Browsershot::html($markup) - ->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : []); - if (config('app.puppeteer_wait_for_network_idle')) { - $browsershot->waitUntilNetworkIdle(); - } - if (config('app.puppeteer_window_size_strategy') === 'v2') { - $browsershot->windowSize($imageSettings['width'], $imageSettings['height']); - } else { - $browsershot->windowSize(800, 480); - } - $browsershot->save($pngPath); - } catch (Exception $e) { - Log::error('Failed to generate PNG: '.$e->getMessage()); - throw new RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e); + + $browserStage = new BrowserStage($browsershotInstance); + $browserStage->html($markup); + + if (config('app.puppeteer_window_size_strategy') === 'v1') { + $browserStage + ->width($imageSettings['width']) + ->height($imageSettings['height']); + } else { + $browserStage + ->width(800) + ->height(480); } + + if (config('app.puppeteer_wait_for_network_idle')) { + $browserStage->setBrowsershotOption('waitUntil', 'networkidle0'); + } + + if (config('app.puppeteer_docker')) { + $browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']); + } + + $imageStage = new ImageStage(); + $imageStage->format($fileExtension) + ->width($imageSettings['width']) + ->height($imageSettings['height']) + ->colors($imageSettings['colors']) + ->bitDepth($imageSettings['bit_depth']) + ->rotation($imageSettings['rotation']) + ->offsetX($imageSettings['offset_x']) + ->offsetY($imageSettings['offset_y']) + ->outputPath($outputPath); + + (new TrmnlPipeline())->pipe($browserStage) + ->pipe($imageStage) + ->process(); + + if (! file_exists($outputPath)) { + throw new RuntimeException('Image file was not created: '.$outputPath); + } + + if (filesize($outputPath) === 0) { + throw new RuntimeException('Image file is empty: '.$outputPath); + } + + $device->update(['current_screen_image' => $uuid]); + Log::info("Device $device->id: updated with new image: $uuid"); + + return $uuid; + + } catch (Exception $e) { + Log::error('Failed to generate image: '.$e->getMessage()); + throw new RuntimeException('Failed to generate image: '.$e->getMessage(), 0, $e); } - - // Validate that the PNG file was created and is valid - if (! file_exists($pngPath)) { - throw new RuntimeException('PNG file was not created: '.$pngPath); - } - - if (filesize($pngPath) === 0) { - throw new RuntimeException('PNG file is empty: '.$pngPath); - } - - // Convert image based on DeviceModel settings or fallback to device settings - self::convertImage($pngPath, $bmpPath, $imageSettings); - - $device->update(['current_screen_image' => $uuid]); - Log::info("Device $device->id: updated with new image: $uuid"); - - return $uuid; } /** @@ -107,17 +121,22 @@ class ImageGenerationService } // Fallback to device settings + $imageFormat = $device->image_format ?? ImageFormat::AUTO->value; + $mimeType = self::getMimeTypeFromImageFormat($imageFormat); + $colors = self::getColorsFromImageFormat($imageFormat); + $bitDepth = self::getBitDepthFromImageFormat($imageFormat); + return [ 'width' => $device->width ?? 800, 'height' => $device->height ?? 480, - 'colors' => 2, - 'bit_depth' => 1, + 'colors' => $colors, + 'bit_depth' => $bitDepth, 'scale_factor' => 1.0, 'rotation' => $device->rotate ?? 0, - 'mime_type' => 'image/png', + 'mime_type' => $mimeType, 'offset_x' => 0, 'offset_y' => 0, - 'image_format' => $device->image_format, + 'image_format' => $imageFormat, 'use_model_settings' => false, ]; } @@ -146,207 +165,48 @@ class ImageGenerationService } /** - * Convert image based on the provided settings + * Get MIME type from ImageFormat */ - private static function convertImage(string $pngPath, string $bmpPath, array $settings): void + private static function getMimeTypeFromImageFormat(string $imageFormat): string { - $imageFormat = $settings['image_format']; - $useModelSettings = $settings['use_model_settings'] ?? false; - - if ($useModelSettings) { - // Use DeviceModel-specific conversion - self::convertUsingModelSettings($pngPath, $bmpPath, $settings); - } else { - // Use legacy device-specific conversion - self::convertUsingLegacySettings($pngPath, $bmpPath, $imageFormat, $settings); - } + return match ($imageFormat) { + ImageFormat::BMP3_1BIT_SRGB->value => 'image/bmp', + ImageFormat::PNG_8BIT_GRAYSCALE->value, + ImageFormat::PNG_8BIT_256C->value, + ImageFormat::PNG_2BIT_4C->value => 'image/png', + ImageFormat::AUTO->value => 'image/png', // Default for AUTO + default => 'image/png', + }; } /** - * Convert image using DeviceModel settings + * Get colors from ImageFormat */ - private static function convertUsingModelSettings(string $pngPath, string $bmpPath, array $settings): void + private static function getColorsFromImageFormat(string $imageFormat): int { - try { - $imagick = new Imagick($pngPath); - - // Apply scale factor if needed - if ($settings['scale_factor'] !== 1.0) { - $newWidth = (int) ($settings['width'] * $settings['scale_factor']); - $newHeight = (int) ($settings['height'] * $settings['scale_factor']); - $imagick->resizeImage($newWidth, $newHeight, Imagick::FILTER_LANCZOS, 1, true); - } else { - // Resize to model dimensions if different from generated size - if ($imagick->getImageWidth() !== $settings['width'] || $imagick->getImageHeight() !== $settings['height']) { - $imagick->resizeImage($settings['width'], $settings['height'], Imagick::FILTER_LANCZOS, 1, true); - } - } - - // Apply rotation - if ($settings['rotation'] !== 0) { - $imagick->rotateImage(new ImagickPixel('black'), $settings['rotation']); - } - - // Apply offset if specified - if ($settings['offset_x'] !== 0 || $settings['offset_y'] !== 0) { - $imagick->rollImage($settings['offset_x'], $settings['offset_y']); - } - - // Handle special case for 4-color, 2-bit PNG - if ($settings['colors'] === 4 && $settings['bit_depth'] === 2 && $settings['mime_type'] === 'image/png') { - self::convertTo4Color2BitPng($imagick, $settings['width'], $settings['height']); - } else { - // Set image type and color depth based on model settings - $imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE); - - if ($settings['bit_depth'] === 1) { - $imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false); - $imagick->setImageDepth(1); - } else { - $imagick->quantizeImage($settings['colors'], Imagick::COLORSPACE_GRAY, 0, true, false); - $imagick->setImageDepth($settings['bit_depth']); - } - } - - $imagick->stripImage(); - - // Save in the appropriate format - if ($settings['mime_type'] === 'image/bmp') { - $imagick->setFormat('BMP3'); - $imagick->writeImage($bmpPath); - } else { - $imagick->setFormat('png'); - $imagick->writeImage($pngPath); - } - - $imagick->clear(); - } catch (ImagickException $e) { - throw new RuntimeException('Failed to convert image using model settings: '.$e->getMessage(), 0, $e); - } + return match ($imageFormat) { + ImageFormat::BMP3_1BIT_SRGB->value, + ImageFormat::PNG_8BIT_GRAYSCALE->value => 2, + ImageFormat::PNG_8BIT_256C->value => 256, + ImageFormat::PNG_2BIT_4C->value => 4, + ImageFormat::AUTO->value => 2, // Default for AUTO + default => 2, + }; } /** - * Convert image to 4-color, 2-bit PNG using custom colormap and dithering + * Get bit depth from ImageFormat */ - private static function convertTo4Color2BitPng(Imagick $imagick, int $width, int $height): void + private static function getBitDepthFromImageFormat(string $imageFormat): int { - // Step 1: Create 4-color grayscale colormap in memory - $colors = ['#000000', '#555555', '#aaaaaa', '#ffffff']; - $colormap = new Imagick(); - - foreach ($colors as $color) { - $swatch = new Imagick(); - $swatch->newImage(1, 1, new ImagickPixel($color)); - $swatch->setImageFormat('png'); - $colormap->addImage($swatch); - } - - $colormap = $colormap->appendImages(true); // horizontal - $colormap->setType(Imagick::IMGTYPE_PALETTE); - $colormap->setImageFormat('png'); - - // Step 2: Resize to target dimensions without keeping aspect ratio - $imagick->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1, false); - - // Step 3: Apply Floyd–Steinberg dithering - $imagick->setOption('dither', 'FloydSteinberg'); - - // Step 4: Remap to our 4-color colormap - // $imagick->remapImage($colormap, Imagick::DITHERMETHOD_FLOYDSTEINBERG); - - // Step 5: Force 2-bit grayscale PNG - $imagick->setImageFormat('png'); - $imagick->setImageDepth(2); - $imagick->setType(Imagick::IMGTYPE_GRAYSCALE); - - // Cleanup colormap - $colormap->clear(); - } - - /** - * Convert image using legacy device settings - */ - private static function convertUsingLegacySettings(string $pngPath, string $bmpPath, string $imageFormat, array $settings): void - { - switch ($imageFormat) { - case ImageFormat::BMP3_1BIT_SRGB->value: - try { - self::convertToBmpImageMagick($pngPath, $bmpPath); - } catch (ImagickException $e) { - throw new RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e); - } - break; - case ImageFormat::PNG_8BIT_GRAYSCALE->value: - case ImageFormat::PNG_8BIT_256C->value: - try { - self::convertToPngImageMagick($pngPath, $settings['width'], $settings['height'], $settings['rotation'], quantize: $imageFormat === ImageFormat::PNG_8BIT_GRAYSCALE->value); - } catch (ImagickException $e) { - throw new RuntimeException('Failed to convert image to PNG: '.$e->getMessage(), 0, $e); - } - break; - case ImageFormat::AUTO->value: - default: - // For AUTO format, we need to check if this is a legacy device - // This would require checking if the device has a firmware version - // For now, we'll use the device's current logic - try { - self::convertToPngImageMagick($pngPath, $settings['width'], $settings['height'], $settings['rotation']); - } catch (ImagickException $e) { - throw new RuntimeException('Failed to convert image to PNG: '.$e->getMessage(), 0, $e); - } - } - } - - /** - * @throws ImagickException - */ - private static function convertToBmpImageMagick(string $pngPath, string $bmpPath): void - { - try { - $imagick = new Imagick($pngPath); - $imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE); - $imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false); - $imagick->setImageDepth(1); - $imagick->stripImage(); - $imagick->setFormat('BMP3'); - $imagick->writeImage($bmpPath); - $imagick->clear(); - } catch (ImagickException $e) { - Log::error('ImageMagick conversion failed for PNG: '.$pngPath.' - '.$e->getMessage()); - throw $e; - } - } - - /** - * @throws ImagickException - */ - private static function convertToPngImageMagick(string $pngPath, ?int $width, ?int $height, ?int $rotate, $quantize = true): void - { - try { - $imagick = new Imagick($pngPath); - if ($width !== 800 || $height !== 480) { - $imagick->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1, true); - } - if ($rotate !== null && $rotate !== 0) { - $imagick->rotateImage(new ImagickPixel('black'), $rotate); - } - - $imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE); - $imagick->setOption('dither', 'FloydSteinberg'); - - if ($quantize) { - $imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false); - } - $imagick->setImageDepth(8); - $imagick->stripImage(); - - $imagick->setFormat('png'); - $imagick->writeImage($pngPath); - $imagick->clear(); - } catch (ImagickException $e) { - Log::error('ImageMagick conversion failed for PNG: '.$pngPath.' - '.$e->getMessage()); - throw $e; - } + return match ($imageFormat) { + ImageFormat::BMP3_1BIT_SRGB->value, + ImageFormat::PNG_8BIT_GRAYSCALE->value => 1, + ImageFormat::PNG_8BIT_256C->value => 8, + ImageFormat::PNG_2BIT_4C->value => 2, + ImageFormat::AUTO->value => 1, // Default for AUTO + default => 1, + }; } public static function cleanupFolder(): void diff --git a/tests/Unit/Services/ImageGenerationServiceTest.php b/tests/Unit/Services/ImageGenerationServiceTest.php index 03f08d1..37ed4e2 100644 --- a/tests/Unit/Services/ImageGenerationServiceTest.php +++ b/tests/Unit/Services/ImageGenerationServiceTest.php @@ -99,8 +99,8 @@ it('get_image_settings uses defaults for missing device properties', function () expect($settings['mime_type'])->toBe('image/png'); expect($settings['offset_x'])->toBe(0); expect($settings['offset_y'])->toBe(0); - // image_format will be null if the device doesn't have it set, which is the expected behavior - expect($settings['image_format'])->toBeNull(); + // image_format defaults to 'auto' when not set + expect($settings['image_format'])->toBe('auto'); })->skipOnCi(); it('determine_image_format_from_model returns correct formats', function (): void {