mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 23:18:10 +00:00
refactor: image render pipeline
This commit is contained in:
parent
97e6beaee4
commit
29d1838690
2 changed files with 109 additions and 249 deletions
|
|
@ -6,79 +6,93 @@ use App\Enums\ImageFormat;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\DeviceModel;
|
use App\Models\DeviceModel;
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
|
use Bnussbau\TrmnlPipeline\Stages\BrowserStage;
|
||||||
|
use Bnussbau\TrmnlPipeline\Stages\ImageStage;
|
||||||
|
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Imagick;
|
|
||||||
use ImagickException;
|
|
||||||
use ImagickPixel;
|
|
||||||
use Ramsey\Uuid\Uuid;
|
use Ramsey\Uuid\Uuid;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Spatie\Browsershot\Browsershot;
|
|
||||||
use Wnx\SidecarBrowsershot\BrowsershotLambda;
|
use Wnx\SidecarBrowsershot\BrowsershotLambda;
|
||||||
|
|
||||||
|
use function config;
|
||||||
|
use function file_exists;
|
||||||
|
use function filesize;
|
||||||
|
|
||||||
class ImageGenerationService
|
class ImageGenerationService
|
||||||
{
|
{
|
||||||
public static function generateImage(string $markup, $deviceId): string
|
public static function generateImage(string $markup, $deviceId): string
|
||||||
{
|
{
|
||||||
$device = Device::with('deviceModel')->find($deviceId);
|
$device = Device::with('deviceModel')->find($deviceId);
|
||||||
$uuid = Uuid::uuid4()->toString();
|
$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
|
try {
|
||||||
$imageSettings = self::getImageSettings($device);
|
// Get image generation settings from DeviceModel if available, otherwise use device settings
|
||||||
|
$imageSettings = self::getImageSettings($device);
|
||||||
|
|
||||||
// Generate PNG
|
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
|
||||||
if (config('app.puppeteer_mode') === 'sidecar-aws') {
|
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
||||||
try {
|
|
||||||
$browsershot = BrowsershotLambda::html($markup)
|
|
||||||
->windowSize(800, 480);
|
|
||||||
|
|
||||||
if (config('app.puppeteer_wait_for_network_idle')) {
|
// Create custom Browsershot instance if using AWS Lambda
|
||||||
$browsershot->waitUntilNetworkIdle();
|
$browsershotInstance = null;
|
||||||
}
|
if (config('app.puppeteer_mode') === 'sidecar-aws') {
|
||||||
|
$browsershotInstance = new BrowsershotLambda();
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
try {
|
$browserStage = new BrowserStage($browsershotInstance);
|
||||||
$browsershot = Browsershot::html($markup)
|
$browserStage->html($markup);
|
||||||
->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : []);
|
|
||||||
if (config('app.puppeteer_wait_for_network_idle')) {
|
if (config('app.puppeteer_window_size_strategy') === 'v1') {
|
||||||
$browsershot->waitUntilNetworkIdle();
|
$browserStage
|
||||||
}
|
->width($imageSettings['width'])
|
||||||
if (config('app.puppeteer_window_size_strategy') === 'v2') {
|
->height($imageSettings['height']);
|
||||||
$browsershot->windowSize($imageSettings['width'], $imageSettings['height']);
|
} else {
|
||||||
} else {
|
$browserStage
|
||||||
$browsershot->windowSize(800, 480);
|
->width(800)
|
||||||
}
|
->height(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Fallback to device settings
|
||||||
|
$imageFormat = $device->image_format ?? ImageFormat::AUTO->value;
|
||||||
|
$mimeType = self::getMimeTypeFromImageFormat($imageFormat);
|
||||||
|
$colors = self::getColorsFromImageFormat($imageFormat);
|
||||||
|
$bitDepth = self::getBitDepthFromImageFormat($imageFormat);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'width' => $device->width ?? 800,
|
'width' => $device->width ?? 800,
|
||||||
'height' => $device->height ?? 480,
|
'height' => $device->height ?? 480,
|
||||||
'colors' => 2,
|
'colors' => $colors,
|
||||||
'bit_depth' => 1,
|
'bit_depth' => $bitDepth,
|
||||||
'scale_factor' => 1.0,
|
'scale_factor' => 1.0,
|
||||||
'rotation' => $device->rotate ?? 0,
|
'rotation' => $device->rotate ?? 0,
|
||||||
'mime_type' => 'image/png',
|
'mime_type' => $mimeType,
|
||||||
'offset_x' => 0,
|
'offset_x' => 0,
|
||||||
'offset_y' => 0,
|
'offset_y' => 0,
|
||||||
'image_format' => $device->image_format,
|
'image_format' => $imageFormat,
|
||||||
'use_model_settings' => false,
|
'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'];
|
return match ($imageFormat) {
|
||||||
$useModelSettings = $settings['use_model_settings'] ?? false;
|
ImageFormat::BMP3_1BIT_SRGB->value => 'image/bmp',
|
||||||
|
ImageFormat::PNG_8BIT_GRAYSCALE->value,
|
||||||
if ($useModelSettings) {
|
ImageFormat::PNG_8BIT_256C->value,
|
||||||
// Use DeviceModel-specific conversion
|
ImageFormat::PNG_2BIT_4C->value => 'image/png',
|
||||||
self::convertUsingModelSettings($pngPath, $bmpPath, $settings);
|
ImageFormat::AUTO->value => 'image/png', // Default for AUTO
|
||||||
} else {
|
default => 'image/png',
|
||||||
// Use legacy device-specific conversion
|
};
|
||||||
self::convertUsingLegacySettings($pngPath, $bmpPath, $imageFormat, $settings);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 {
|
return match ($imageFormat) {
|
||||||
$imagick = new Imagick($pngPath);
|
ImageFormat::BMP3_1BIT_SRGB->value,
|
||||||
|
ImageFormat::PNG_8BIT_GRAYSCALE->value => 2,
|
||||||
// Apply scale factor if needed
|
ImageFormat::PNG_8BIT_256C->value => 256,
|
||||||
if ($settings['scale_factor'] !== 1.0) {
|
ImageFormat::PNG_2BIT_4C->value => 4,
|
||||||
$newWidth = (int) ($settings['width'] * $settings['scale_factor']);
|
ImageFormat::AUTO->value => 2, // Default for AUTO
|
||||||
$newHeight = (int) ($settings['height'] * $settings['scale_factor']);
|
default => 2,
|
||||||
$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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
return match ($imageFormat) {
|
||||||
$colors = ['#000000', '#555555', '#aaaaaa', '#ffffff'];
|
ImageFormat::BMP3_1BIT_SRGB->value,
|
||||||
$colormap = new Imagick();
|
ImageFormat::PNG_8BIT_GRAYSCALE->value => 1,
|
||||||
|
ImageFormat::PNG_8BIT_256C->value => 8,
|
||||||
foreach ($colors as $color) {
|
ImageFormat::PNG_2BIT_4C->value => 2,
|
||||||
$swatch = new Imagick();
|
ImageFormat::AUTO->value => 1, // Default for AUTO
|
||||||
$swatch->newImage(1, 1, new ImagickPixel($color));
|
default => 1,
|
||||||
$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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function cleanupFolder(): void
|
public static function cleanupFolder(): void
|
||||||
|
|
|
||||||
|
|
@ -99,8 +99,8 @@ it('get_image_settings uses defaults for missing device properties', function ()
|
||||||
expect($settings['mime_type'])->toBe('image/png');
|
expect($settings['mime_type'])->toBe('image/png');
|
||||||
expect($settings['offset_x'])->toBe(0);
|
expect($settings['offset_x'])->toBe(0);
|
||||||
expect($settings['offset_y'])->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
|
// image_format defaults to 'auto' when not set
|
||||||
expect($settings['image_format'])->toBeNull();
|
expect($settings['image_format'])->toBe('auto');
|
||||||
})->skipOnCi();
|
})->skipOnCi();
|
||||||
|
|
||||||
it('determine_image_format_from_model returns correct formats', function (): void {
|
it('determine_image_format_from_model returns correct formats', function (): void {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue