chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled

Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:11:31 -04:00
parent 062c52eec7
commit 12245759ac
149 changed files with 14846 additions and 92 deletions
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Message\RenderImageMessage;
use App\Repository\ImageRepository;
use App\Repository\RenderedAssetRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class RenderImageMessageHandler
{
// Waveshare Spectra 6 palette — must match gen_screens.py
private const PALETTE = [
0x0 => [26, 26, 26 ], // BLACK
0x1 => [245, 245, 240], // WHITE
0x2 => [240, 208, 0 ], // YELLOW
0x3 => [192, 48, 32 ], // RED
0x5 => [24, 64, 192], // BLUE
0x6 => [16, 160, 64 ], // GREEN
];
public function __construct(
private readonly ImageRepository $imageRepo,
private readonly RenderedAssetRepository $assetRepo,
private readonly EntityManagerInterface $em,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {}
public function __invoke(RenderImageMessage $msg): void
{
$image = $this->imageRepo->find($msg->imageId);
if (!$image || $image->isDeleted()) {
return;
}
$model = DeviceModel::from($msg->deviceModel);
$orientation = Orientation::from($msg->orientation);
$asset = $this->assetRepo->findOneBy([
'image' => $image,
'deviceModel' => $model,
'orientation' => $orientation,
]);
if (!$asset) {
$asset = (new RenderedAsset())
->setImage($image)
->setDeviceModel($model)
->setOrientation($orientation);
$this->em->persist($asset);
}
$asset->setStatus(RenderStatus::Processing);
$this->em->flush();
try {
// Prefer composited.jpg (cropped+stickered) over the raw original
$compositedPath = $this->projectDir . '/var/storage/images/' . $image->getId() . '/composited.jpg';
$originalPath = file_exists($compositedPath)
? $compositedPath
: $this->projectDir . '/' . $image->getStoragePath();
$width = $model->width($orientation);
$height = $model->height($orientation);
$bin = $this->renderToBin($originalPath, $width, $height);
$relPath = 'var/storage/images/' . $image->getId()
. '/' . $model->value . '_' . $orientation->value . '.bin';
$absPath = $this->projectDir . '/' . $relPath;
file_put_contents($absPath, $bin);
$asset->setFilePath($relPath)
->setStatus(RenderStatus::Ready)
->setRenderedAt(new \DateTimeImmutable());
} catch (\Throwable) {
$asset->setStatus(RenderStatus::Failed);
}
$this->em->flush();
}
private function renderToBin(string $path, int $width, int $height): string
{
$imagick = new \Imagick($path);
$imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
$imagick->setBackgroundColor('white');
$imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
$imagick->autoOrient();
$imagick->cropThumbnailImage($width, $height);
$imagick->setImageColorspace(\Imagick::COLORSPACE_SRGB);
// Auto-levels: stretch the tonal range, clipping 1% at each end.
// Fixes underexposed/dark photos so the full palette range is used.
$pixels = $width * $height;
$imagick->contrastStretchImage((int) ($pixels * 0.01), (int) ($pixels * 0.01));
// Boost saturation 130%. Dark desaturated photos otherwise map almost
// entirely to BLACK with scattered noise dots from error diffusion.
$imagick->modulateImage(100, 130, 100);
// Light sharpen so edges survive the dithering scatter.
$imagick->sharpenImage(0, 0.8);
// Build a strip of 6 palette pixels for remapImage
$palImagick = new \Imagick();
foreach (self::PALETTE as $rgb) {
$hex = sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
$tmp = new \Imagick();
$tmp->newImage(1, 1, new \ImagickPixel($hex));
$tmp->setImageFormat('png');
$palImagick->addImage($tmp);
$tmp->destroy();
}
$palImagick->resetIterator();
$palStrip = $palImagick->appendImages(false);
$imagick->remapImage($palStrip, \Imagick::DITHERMETHOD_FLOYDSTEINBERG);
$palStrip->destroy();
$palImagick->destroy();
// Export as raw RGB bytes
$imagick->setImageDepth(8);
$imagick->setFormat('RGB');
$blob = $imagick->getImageBlob();
$imagick->destroy();
// Pack into 4bpp: high nibble = left pixel, low nibble = right pixel
$output = '';
$total = $width * $height;
for ($i = 0; $i < $total; $i += 2) {
$base0 = $i * 3;
$base1 = $base0 + 3;
$n0 = $this->nearestPalette(ord($blob[$base0]), ord($blob[$base0 + 1]), ord($blob[$base0 + 2]));
$n1 = $this->nearestPalette(ord($blob[$base1]), ord($blob[$base1 + 1]), ord($blob[$base1 + 2]));
$output .= chr(($n0 << 4) | $n1);
}
return $output;
}
private function nearestPalette(int $r, int $g, int $b): int
{
$best = 0x1;
$bestDist = PHP_INT_MAX;
foreach (self::PALETTE as $index => $rgb) {
$dist = ($r - $rgb[0]) ** 2 + ($g - $rgb[1]) ** 2 + ($b - $rgb[2]) ** 2;
if ($dist < $bestDist) {
$bestDist = $dist;
$best = $index;
}
}
return $best;
}
}