* * Overwrites the device's V2 portrait .bin, bumps rendered_at and busts * the cached preview PNG. */ #[AsCommand( name: 'app:render-compare', description: 'Render half-A (baseline) + half-B (current experiment) stacked on the panel', )] final class RenderCompareCommand extends Command { /** Frozen baseline palette — render-baseline-2026-05-14. NEVER touch. */ private const BASELINE_PALETTE = [ 0x0 => [26, 26, 26 ], 0x1 => [245, 245, 240], 0x2 => [240, 208, 0 ], 0x3 => [192, 48, 32 ], 0x5 => [24, 64, 192], 0x6 => [16, 160, 64 ], ]; private const W = 1200; private const HALF_H = 800; private const FULL_H = 1600; /** Baseline tunables — frozen reference, never tracks the live pipeline. */ private const BASELINE = [ 'saturation' => 130, 'gamma' => 1.0, 'sharpen' => 0.8, 'blur' => 0.0, 'blue_mul' => 1.0, 'palette' => self::BASELINE_PALETTE, 'dither' => \Imagick::DITHERMETHOD_FLOYDSTEINBERG, ]; public function __construct( private readonly EntityManagerInterface $em, private readonly RenderedAssetRepository $assetRepo, #[Autowire('%kernel.project_dir%')] private readonly string $projectDir, ) { parent::__construct(); } protected function configure(): void { $this->addArgument('imageId', InputArgument::REQUIRED, 'Image to render'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $imageId = (int) $input->getArgument('imageId'); $compositedPath = $this->projectDir . '/var/storage/images/' . $imageId . '/composited.jpg'; $originalGlob = glob($this->projectDir . '/var/storage/images/' . $imageId . '/original.*'); $source = file_exists($compositedPath) ? $compositedPath : ($originalGlob[0] ?? null); if (!$source) { $io->error("No source file for image $imageId"); return Command::FAILURE; } // Pull the live pipeline's current settings for half-B. $experiment = [ 'saturation' => RenderImageMessageHandler::SATURATION_PCT, 'gamma' => RenderImageMessageHandler::GAMMA, 'sharpen' => RenderImageMessageHandler::SHARPEN_SIGMA, 'blur' => RenderImageMessageHandler::BLUR_SIGMA, 'blue_mul' => RenderImageMessageHandler::BLUE_CHANNEL_MUL, 'palette' => RenderImageMessageHandler::PALETTE, 'dither' => RenderImageMessageHandler::DITHER_METHOD, ]; $io->writeln('TOP (baseline): ' . $this->summarize(self::BASELINE)); $io->writeln('BOTTOM (experiment): ' . $this->summarize($experiment)); $topBytes = $this->renderTopHalf($source, self::BASELINE); $bottomBytes = $this->renderTopHalf($source, $experiment); $combined = $topBytes . $bottomBytes; $expected = (int) (self::W * self::FULL_H / 2); if (strlen($combined) !== $expected) { $io->error(sprintf('Size mismatch: got %d bytes, expected %d', strlen($combined), $expected)); return Command::FAILURE; } $asset = $this->assetRepo->findOneBy([ 'image' => $imageId, 'deviceModel' => DeviceModel::V2, 'orientation' => Orientation::Portrait, ]); if (!$asset?->getFilePath()) { $io->error("No V2 portrait asset for image $imageId — render normally first."); return Command::FAILURE; } $absPath = $this->projectDir . '/' . $asset->getFilePath(); file_put_contents($absPath, $combined); $asset->setRenderedAt(new \DateTimeImmutable()); $this->em->flush(); $pngPath = preg_replace('/\.bin$/', '.png', $absPath); if (file_exists($pngPath)) { @unlink($pngPath); } $io->success(sprintf('Wrote %s. Next device poll will fetch.', $absPath)); return Command::SUCCESS; } private function summarize(array $p): string { $dither = $p['dither'] === \Imagick::DITHERMETHOD_FLOYDSTEINBERG ? 'FloydSteinberg' : ($p['dither'] === \Imagick::DITHERMETHOD_RIEMERSMA ? 'Riemersma' : 'No'); return sprintf( 'sat=%d gamma=%.2f sharpen=%.2f blur=%.2f blue_mul=%.2f dither=%s', $p['saturation'], $p['gamma'], $p['sharpen'], $p['blur'], $p['blue_mul'], $dither, ); } private function renderTopHalf(string $source, array $params): string { $im = new \Imagick($source); $im->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE); $im->setBackgroundColor('white'); $im->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN); $im->autoOrient(); $im->thumbnailImage(self::W, self::FULL_H, true); $im->setImageColorspace(\Imagick::COLORSPACE_SRGB); $im->normalizeImage(); if ($params['gamma'] !== 1.0) { $im->gammaImage($params['gamma']); } $im->modulateImage(100, $params['saturation'], 100); $im->sharpenImage(0, $params['sharpen']); if ($params['blur'] > 0) { $im->blurImage(0, $params['blur']); } if ($params['blue_mul'] !== 1.0) { $im->evaluateImage(\Imagick::EVALUATE_MULTIPLY, $params['blue_mul'], \Imagick::CHANNEL_BLUE); } if ($im->getImageWidth() !== self::W || $im->getImageHeight() !== self::FULL_H) { $canvas = new \Imagick(); $canvas->newImage(self::W, self::FULL_H, new \ImagickPixel('white')); $canvas->setImageFormat('png'); $offsetX = (int) ((self::W - $im->getImageWidth()) / 2); $offsetY = (int) ((self::FULL_H - $im->getImageHeight()) / 2); $canvas->compositeImage($im, \Imagick::COMPOSITE_OVER, $offsetX, $offsetY); $im->destroy(); $im = $canvas; } // Crop top half so both halves show the same content with the only // variable being the tunables. $im->cropImage(self::W, self::HALF_H, 0, 0); $im->setImagePage(self::W, self::HALF_H, 0, 0); $palStrip = $this->buildPaletteStrip($params['palette']); $im->remapImage($palStrip, $params['dither']); $palStrip->destroy(); $im->setImageDepth(8); $im->setFormat('RGB'); $blob = $im->getImageBlob(); $im->destroy(); $output = ''; $total = self::W * self::HALF_H; for ($i = 0; $i < $total; $i += 2) { $base0 = $i * 3; $base1 = $base0 + 3; $n0 = $this->nearestPalette($params['palette'], ord($blob[$base0]), ord($blob[$base0 + 1]), ord($blob[$base0 + 2])); $n1 = $this->nearestPalette($params['palette'], ord($blob[$base1]), ord($blob[$base1 + 1]), ord($blob[$base1 + 2])); $output .= chr(($n0 << 4) | $n1); } return $output; } private function buildPaletteStrip(array $palette): \Imagick { $palImagick = new \Imagick(); foreach ($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(); $strip = $palImagick->appendImages(false); $palImagick->destroy(); return $strip; } private function nearestPalette(array $palette, int $r, int $g, int $b): int { $best = 0x1; $bestDist = PHP_INT_MAX; foreach ($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; } }