Skip to content

Instantly share code, notes, and snippets.

@derkalle4
Last active July 21, 2025 17:17
Show Gist options
  • Select an option

  • Save derkalle4/feee588db85aa3c7c0fc2d13eb642fb9 to your computer and use it in GitHub Desktop.

Select an option

Save derkalle4/feee588db85aa3c7c0fc2d13eb642fb9 to your computer and use it in GitHub Desktop.
TRMNL - BYOS_Laravel -> Display Random Immich Album Photo
@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>
{
"albumIds": ["fff99ec6-1d02-4117-94a1-XXXX"]
}
x-api-key: IMMICH-API-KEY
Content-Type: application/json
Accept: application/json
https://IMMICH-URL/api/search/metadata
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment