find($deviceId); $uuid = Uuid::uuid4()->toString(); try { // Get image generation settings from DeviceModel if available, otherwise use device settings $imageSettings = self::getImageSettings($device); $fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png'; $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); // Create custom Browsershot instance if using AWS Lambda $browsershotInstance = null; if (config('app.puppeteer_mode') === 'sidecar-aws') { $browsershotInstance = new BrowsershotLambda(); } $browserStage = new BrowserStage($browsershotInstance); $browserStage->html($markup); if (config('app.puppeteer_window_size_strategy') === 'v2') { $browserStage ->width($imageSettings['width']) ->height($imageSettings['height']); } else { // default behaviour for Framework v1 $browserStage->useDefaultDimensions(); } 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); // Apply dithering if requested by markup $shouldDither = self::markupContainsDitherImage($markup); if ($shouldDither) { $imageStage->dither(); } (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); } } /** * Get image generation settings from DeviceModel if available, otherwise use device settings */ private static function getImageSettings(Device $device): array { // If device has a DeviceModel, use its settings if ($device->deviceModel) { /** @var DeviceModel $model */ $model = $device->deviceModel; return [ 'width' => $model->width, 'height' => $model->height, 'colors' => $model->colors, 'bit_depth' => $model->bit_depth, 'scale_factor' => $model->scale_factor, 'rotation' => $model->rotation, 'mime_type' => $model->mime_type, 'offset_x' => $model->offset_x, 'offset_y' => $model->offset_y, 'image_format' => self::determineImageFormatFromModel($model), 'use_model_settings' => true, ]; } // 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' => $colors, 'bit_depth' => $bitDepth, 'scale_factor' => 1.0, 'rotation' => $device->rotate ?? 0, 'mime_type' => $mimeType, 'offset_x' => 0, 'offset_y' => 0, 'image_format' => $imageFormat, 'use_model_settings' => false, ]; } /** * Determine the appropriate ImageFormat based on DeviceModel settings */ private static function determineImageFormatFromModel(DeviceModel $model): string { // Map DeviceModel settings to ImageFormat if ($model->mime_type === 'image/bmp' && $model->bit_depth === 1) { return ImageFormat::BMP3_1BIT_SRGB->value; } if ($model->mime_type === 'image/png' && $model->bit_depth === 8 && $model->colors === 2) { return ImageFormat::PNG_8BIT_GRAYSCALE->value; } if ($model->mime_type === 'image/png' && $model->bit_depth === 8 && $model->colors === 256) { return ImageFormat::PNG_8BIT_256C->value; } if ($model->mime_type === 'image/png' && $model->bit_depth === 2 && $model->colors === 4) { return ImageFormat::PNG_2BIT_4C->value; } // Default to AUTO for unknown combinations return ImageFormat::AUTO->value; } /** * Get MIME type from ImageFormat */ private static function getMimeTypeFromImageFormat(string $imageFormat): string { 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', }; } /** * Get colors from ImageFormat */ private static function getColorsFromImageFormat(string $imageFormat): int { 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, }; } /** * Get bit depth from ImageFormat */ private static function getBitDepthFromImageFormat(string $imageFormat): int { 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, }; } /** * Detect whether the provided HTML markup contains an tag with class "image-dither". */ private static function markupContainsDitherImage(string $markup): bool { if (mb_trim($markup) === '') { return false; } // Find (or with single quotes) and inspect class tokens $imgWithClassPattern = '/]*\bclass\s*=\s*(["\'])(.*?)\1[^>]*>/i'; if (! preg_match_all($imgWithClassPattern, $markup, $matches)) { return false; } foreach ($matches[2] as $classValue) { // Look for class token 'image-dither' or 'image--dither' if (preg_match('/(?:^|\s)image--?dither(?:\s|$)/', $classValue)) { return true; } } return false; } public static function cleanupFolder(): void { $activeDeviceImageUuids = Device::pluck('current_screen_image')->filter()->toArray(); $activePluginImageUuids = Plugin::pluck('current_image')->filter()->toArray(); $activeImageUuids = array_merge($activeDeviceImageUuids, $activePluginImageUuids); $files = Storage::disk('public')->files('/images/generated/'); foreach ($files as $file) { if (basename($file) === '.gitignore') { continue; } // Get filename without path and extension $fileUuid = pathinfo($file, PATHINFO_FILENAME); // If the UUID is not in use by any device, move it to archive if (! in_array($fileUuid, $activeImageUuids)) { Storage::disk('public')->delete($file); } } } public static function resetIfNotCacheable(?Plugin $plugin): void { if ($plugin?->id) { // Check if any devices have custom dimensions or use non-standard DeviceModels $hasCustomDimensions = Device::query() ->where(function ($query): void { $query->where('width', '!=', 800) ->orWhere('height', '!=', 480) ->orWhere('rotate', '!=', 0); }) ->orWhereHas('deviceModel', function ($query): void { // Only allow caching if all device models have standard dimensions (800x480, rotation=0) $query->where(function ($subQuery): void { $subQuery->where('width', '!=', 800) ->orWhere('height', '!=', 480) ->orWhere('rotation', '!=', 0); }); }) ->exists(); if ($hasCustomDimensions) { // TODO cache image per device $plugin->update(['current_image' => null]); Log::debug('Skip cache as devices with custom dimensions or non-standard DeviceModels exist'); } } } /** * Get device-specific default image path for setup or sleep mode */ public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string { // Validate image type if (! in_array($imageType, ['setup-logo', 'sleep'])) { return null; } // If device has a DeviceModel, try to find device-specific image if ($device->deviceModel) { $model = $device->deviceModel; $extension = $model->mime_type === 'image/bmp' ? 'bmp' : 'png'; $filename = "{$model->width}_{$model->height}_{$model->bit_depth}_{$model->rotation}.{$extension}"; $deviceSpecificPath = "images/default-screens/{$imageType}_{$filename}"; if (Storage::disk('public')->exists($deviceSpecificPath)) { return $deviceSpecificPath; } } // Fallback to original hardcoded images $fallbackPath = "images/{$imageType}.bmp"; if (Storage::disk('public')->exists($fallbackPath)) { return $fallbackPath; } // Try PNG fallback $fallbackPathPng = "images/{$imageType}.png"; if (Storage::disk('public')->exists($fallbackPathPng)) { return $fallbackPathPng; } return null; } /** * Generate a default screen image from Blade template */ public static function generateDefaultScreenImage(Device $device, string $imageType): string { // Validate image type if (! in_array($imageType, ['setup-logo', 'sleep'])) { throw new InvalidArgumentException("Invalid image type: {$imageType}"); } $uuid = Uuid::uuid4()->toString(); try { // Get image generation settings from DeviceModel if available, otherwise use device settings $imageSettings = self::getImageSettings($device); $fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png'; $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); // Generate HTML from Blade template $html = self::generateDefaultScreenHtml($device, $imageType); // Create custom Browsershot instance if using AWS Lambda $browsershotInstance = null; if (config('app.puppeteer_mode') === 'sidecar-aws') { $browsershotInstance = new BrowsershotLambda(); } $browserStage = new BrowserStage($browsershotInstance); $browserStage->html($html); if (config('app.puppeteer_window_size_strategy') === 'v2') { $browserStage ->width($imageSettings['width']) ->height($imageSettings['height']); } else { $browserStage->useDefaultDimensions(); } 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); } Log::info("Device $device->id: generated default screen image: $uuid for type: $imageType"); return $uuid; } catch (Exception $e) { Log::error('Failed to generate default screen image: '.$e->getMessage()); throw new RuntimeException('Failed to generate default screen image: '.$e->getMessage(), 0, $e); } } /** * Generate HTML from Blade template for default screens */ private static function generateDefaultScreenHtml(Device $device, string $imageType): string { // Map image type to template name $templateName = match ($imageType) { 'setup-logo' => 'default-screens.setup', 'sleep' => 'default-screens.sleep', default => throw new InvalidArgumentException("Invalid image type: {$imageType}") }; // Determine device properties from DeviceModel or device settings $deviceVariant = $device->deviceVariant(); $deviceOrientation = $device->rotate > 0 ? 'portrait' : 'landscape'; $colorDepth = $device->colorDepth() ?? '1bit'; $scaleLevel = $device->scaleLevel(); $darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode // Render the Blade template return view($templateName, [ 'noBleed' => false, 'darkMode' => $darkMode, 'deviceVariant' => $deviceVariant, 'deviceOrientation' => $deviceOrientation, 'colorDepth' => $colorDepth, 'scaleLevel' => $scaleLevel, ])->render(); } }