Last active
July 21, 2025 17:17
-
-
Save derkalle4/feee588db85aa3c7c0fc2d13eb642fb9 to your computer and use it in GitHub Desktop.
TRMNL - BYOS_Laravel -> Display Random Immich Album Photo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @php | |
| // --- User Configurable Settings --- | |
| $immichServerUrl = 'https://IMMICH-URL'; | |
| $apiKey = 'IMMICH-API-KEY'; | |
| $imageSize = 'thumbnail'; | |
| // E-Ink Processing Settings | |
| $einkMode = 'dither'; // 'dither', 'grayscale', or 'none' | |
| $brightness = 30; | |
| $contrast = 50; | |
| $gamma = 1.2; | |
| $downsampleFactor = 0.8; // Process at 80% resolution to minimize artifacts | |
| // Display Settings | |
| $showOverlay = true; | |
| $showPeople = true; | |
| $surnamesOnly = true; | |
| $descriptionTemplate = "{city}, {country} - {people}"; | |
| // --- Data Processing --- | |
| $base64ImageData = null; | |
| $infoText = ''; | |
| $assets = $data['assets']['items'] ?? $data[array_key_first($data ?? [])]['assets']['items'] ?? null; | |
| if ($assets && !empty($assets)) { | |
| $randomAsset = $assets[array_rand($assets)]; | |
| $randomAssetId = $randomAsset['id']; | |
| $imageResponse = Http::withHeaders(['x-api-key' => $apiKey]) | |
| ->get("{$immichServerUrl}/api/assets/{$randomAssetId}/{$imageSize}"); | |
| if ($imageResponse->successful()) { | |
| $base64ImageData = 'data:' . ($imageResponse->header('Content-Type') ?? 'image/jpeg') . | |
| ';base64,' . base64_encode($imageResponse->body()); | |
| } | |
| if ($showOverlay) { | |
| $infoResponse = Http::withHeaders(['x-api-key' => $apiKey]) | |
| ->get("{$immichServerUrl}/api/assets/{$randomAssetId}"); | |
| if ($infoResponse->successful()) { | |
| $imageInfo = $infoResponse->json(); | |
| $infoText = $descriptionTemplate; | |
| if ($showPeople && !empty($imageInfo['people'])) { | |
| $peopleNames = array_map(function($person) use ($surnamesOnly) { | |
| $name = $person['name'] ?? 'Unknown'; | |
| return ($surnamesOnly && $name !== 'Unknown') ? explode(' ', trim($name))[0] : $name; | |
| }, $imageInfo['people']); | |
| $infoText = str_replace('{people}', implode(', ', $peopleNames), $infoText); | |
| } | |
| foreach (($imageInfo['exifInfo'] ?? []) as $key => $value) { | |
| if (is_scalar($value)) { | |
| $infoText = str_replace('{' . strtolower($key) . '}', (string)$value, $infoText); | |
| } | |
| } | |
| $infoText = trim(preg_replace(['/\{[a-z0-9_]+\}/i', '/(\s*,\s*)+/'], ['', ', '], $infoText), " \t\n\r\0\x0B,-"); | |
| } | |
| } | |
| } | |
| $config = [ | |
| 'mode' => $einkMode, | |
| 'brightness' => $brightness, | |
| 'contrast' => $contrast, | |
| 'gamma' => $gamma, | |
| 'downsampleFactor' => $downsampleFactor | |
| ]; | |
| @endphp | |
| @props(['size' => 'full']) | |
| <x-trmnl::view size="{{$size}}"> | |
| <div id="eink-container" style="width: 100%; height: 100%; background-color: #ffffff; position: relative;"> | |
| @if($base64ImageData) | |
| <canvas id="eink-canvas" style="width: 100%; height: 100%; object-fit: cover;"></canvas> | |
| @if($showOverlay && $infoText) | |
| <div style="position: absolute; bottom: 0; left: 0; right: 0;"> | |
| <x-trmnl::title-bar title="{{$infoText}}"/> | |
| </div> | |
| @endif | |
| @else | |
| <div class="item"> | |
| <div class="content"> | |
| <span class="value value--medium">No Image</span> | |
| <span class="label">Could not load image asset.</span> | |
| </div> | |
| </div> | |
| @endif | |
| </div> | |
| <script> | |
| (function() { | |
| const imageDataUrl = @json($base64ImageData); | |
| if (!imageDataUrl) return; | |
| const canvas = document.getElementById('eink-canvas'); | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| const config = @json($config); | |
| const img = new Image(); | |
| img.onload = () => { | |
| // Resize image to exactly 800x480 first | |
| const targetWidth = 800; | |
| const targetHeight = 480; | |
| canvas.width = targetWidth; | |
| canvas.height = targetHeight; | |
| // Calculate scaling to fit image into 800x480 while maintaining aspect ratio | |
| const imgAspect = img.width / img.height; | |
| const targetAspect = targetWidth / targetHeight; | |
| let drawWidth, drawHeight, offsetX, offsetY; | |
| if (imgAspect > targetAspect) { | |
| // Image is wider - fit to width | |
| drawWidth = targetWidth; | |
| drawHeight = targetWidth / imgAspect; | |
| offsetX = 0; | |
| offsetY = (targetHeight - drawHeight) / 2; | |
| } else { | |
| // Image is taller - fit to height | |
| drawHeight = targetHeight; | |
| drawWidth = targetHeight * imgAspect; | |
| offsetX = (targetWidth - drawWidth) / 2; | |
| offsetY = 0; | |
| } | |
| // Clear canvas with white background | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillRect(0, 0, targetWidth, targetHeight); | |
| // Draw image centered with high quality scaling | |
| ctx.imageSmoothingEnabled = true; | |
| ctx.imageSmoothingQuality = 'high'; | |
| ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight); | |
| if (config.mode === 'dither') { | |
| processImageMultiRes(canvas, ctx, config); | |
| } else if (config.mode === 'grayscale') { | |
| processGrayscale(canvas, ctx, config); | |
| } | |
| // 'none' mode does nothing - displays original image | |
| }; | |
| img.onerror = () => { | |
| canvas.parentElement.innerHTML = '<div class="item"><div class="content"><span class="value value--medium">Image Error</span></div></div>'; | |
| }; | |
| img.src = imageDataUrl; | |
| function processGrayscale(canvas, ctx, config) { | |
| const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| const { data } = imageData; | |
| const contrastFactor = (259 * (config.contrast + 255)) / (255 * (259 - config.contrast)); | |
| const invGamma = 1 / config.gamma; | |
| for (let i = 0; i < data.length; i += 4) { | |
| let gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; | |
| gray = 255 * Math.pow(gray / 255, invGamma) + config.brightness; | |
| gray = Math.max(0, Math.min(255, (contrastFactor * (gray - 128)) + 128)); | |
| data[i] = data[i + 1] = data[i + 2] = gray; | |
| } | |
| ctx.putImageData(imageData, 0, 0); | |
| } | |
| function processImageMultiRes(canvas, ctx, config) { | |
| const originalWidth = canvas.width; | |
| const originalHeight = canvas.height; | |
| const processWidth = Math.floor(originalWidth * config.downsampleFactor); | |
| const processHeight = Math.floor(originalHeight * config.downsampleFactor); | |
| const tempCanvas = document.createElement('canvas'); | |
| const tempCtx = tempCanvas.getContext('2d'); | |
| tempCanvas.width = processWidth; | |
| tempCanvas.height = processHeight; | |
| tempCtx.imageSmoothingEnabled = true; | |
| tempCtx.imageSmoothingQuality = 'high'; | |
| tempCtx.drawImage(canvas, 0, 0, processWidth, processHeight); | |
| const imageData = tempCtx.getImageData(0, 0, processWidth, processHeight); | |
| processImage(imageData, config); | |
| tempCtx.putImageData(imageData, 0, 0); | |
| ctx.imageSmoothingEnabled = true; | |
| ctx.imageSmoothingQuality = 'high'; | |
| ctx.clearRect(0, 0, originalWidth, originalHeight); | |
| ctx.drawImage(tempCanvas, 0, 0, originalWidth, originalHeight); | |
| } | |
| function processImage(imageData, config) { | |
| const { width, height, data } = imageData; | |
| const pixels = new Float32Array(width * height); | |
| const contrastFactor = (259 * (config.contrast + 255)) / (255 * (259 - config.contrast)); | |
| const invGamma = 1 / config.gamma; | |
| for (let i = 0, j = 0; i < data.length; i += 4, j++) { | |
| let gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; | |
| gray = 255 * Math.pow(gray / 255, invGamma) + config.brightness; | |
| gray = Math.max(0, Math.min(255, (contrastFactor * (gray - 128)) + 128)); | |
| pixels[j] = gray; | |
| } | |
| const path = generateHilbertPath(width, height); | |
| for (const idx of path) { | |
| const oldPixel = pixels[idx]; | |
| const newPixel = oldPixel < 128 ? 0 : 255; | |
| const error = oldPixel - newPixel; | |
| const i4 = idx * 4; | |
| data[i4] = data[i4 + 1] = data[i4 + 2] = newPixel; | |
| const x = idx % width; | |
| const y = Math.floor(idx / width); | |
| if (x + 1 < width) pixels[idx + 1] = Math.max(0, Math.min(255, pixels[idx + 1] + error * 0.4375)); | |
| if (y + 1 < height) { | |
| if (x > 0) pixels[idx + width - 1] = Math.max(0, Math.min(255, pixels[idx + width - 1] + error * 0.1875)); | |
| pixels[idx + width] = Math.max(0, Math.min(255, pixels[idx + width] + error * 0.3125)); | |
| if (x + 1 < width) pixels[idx + width + 1] = Math.max(0, Math.min(255, pixels[idx + width + 1] + error * 0.0625)); | |
| } | |
| } | |
| } | |
| function generateHilbertPath(width, height) { | |
| const order = Math.ceil(Math.log2(Math.max(width, height))); | |
| const n = 1 << order; | |
| const path = []; | |
| for (let d = 0; d < n * n; d++) { | |
| const [x, y] = hilbertD2XY(n, d); | |
| if (x < width && y < height) { | |
| path.push(y * width + x); | |
| } | |
| } | |
| return path; | |
| } | |
| function hilbertD2XY(n, d) { | |
| let t = d, x = 0, y = 0; | |
| for (let s = 1; s < n; s <<= 1) { | |
| const rx = 1 & (t >> 1); | |
| const ry = 1 & (t ^ rx); | |
| if (!ry) { | |
| if (rx) { | |
| x = s - 1 - x; | |
| y = s - 1 - y; | |
| } | |
| [x, y] = [y, x]; | |
| } | |
| x += s * rx; | |
| y += s * ry; | |
| t >>= 2; | |
| } | |
| return [x, y]; | |
| } | |
| })(); | |
| </script> | |
| </x-trmnl::view> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "albumIds": ["fff99ec6-1d02-4117-94a1-XXXX"] | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| x-api-key: IMMICH-API-KEY | |
| Content-Type: application/json | |
| Accept: application/json |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| https://IMMICH-URL/api/search/metadata |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment