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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user