fix: letterbox renders instead of brutally center-cropping mismatches
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
cropThumbnailImage was filling the target box by aspect-cover — for a portrait crop served to a landscape device (or vice versa), that meant slicing a thin band through the photo, which the user correctly called out as unacceptable on the screen. Switching to thumbnailImage(... bestfit=true) + composite onto a white canvas of exact target dims means the photo always shows up upright and recognizable: matching aspect renders byte-identically to before (no padding, no zoom change), mismatched aspect shows the photo fit-to-box with white bars instead of a cropped slice. Adds an app:rerender-assets console command to reset every Ready asset to Pending and re-dispatch its render message — needed once after this deploy so existing bins (rendered with the old cropThumbnail logic) get regenerated with the letterbox pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\RenderedAsset;
|
||||||
|
use App\Enum\RenderStatus;
|
||||||
|
use App\Message\RenderImageMessage;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets every Ready RenderedAsset to Pending and re-dispatches a render
|
||||||
|
* message for it. Use after a renderer change so existing on-disk bins get
|
||||||
|
* regenerated with the new pipeline.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:rerender-assets',
|
||||||
|
description: 'Reset all rendered assets and re-dispatch render messages',
|
||||||
|
)]
|
||||||
|
final class RerenderAssetsCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly MessageBusInterface $bus,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$assets = $this->em->getRepository(RenderedAsset::class)->findAll();
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach ($assets as $asset) {
|
||||||
|
$asset->setStatus(RenderStatus::Pending)->setFilePath(null);
|
||||||
|
$this->bus->dispatch(new RenderImageMessage(
|
||||||
|
$asset->getImage()->getId(),
|
||||||
|
$asset->getDeviceModel()->value,
|
||||||
|
$asset->getOrientation()->value,
|
||||||
|
));
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$io->success("Reset and re-dispatched $count rendered assets.");
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,9 +94,26 @@ final class RenderImageMessageHandler
|
|||||||
$imagick->setBackgroundColor('white');
|
$imagick->setBackgroundColor('white');
|
||||||
$imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
|
$imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
|
||||||
$imagick->autoOrient();
|
$imagick->autoOrient();
|
||||||
$imagick->cropThumbnailImage($width, $height);
|
|
||||||
|
|
||||||
// Portrait: rotate the cropped photo 90° CCW so the packed .bin's row
|
// Fit the source into the target box without cropping aggressively;
|
||||||
|
// composite onto a white canvas of exact target dims so an aspect
|
||||||
|
// mismatch (e.g. portrait crop served to a landscape device) shows
|
||||||
|
// up as letterbox bars instead of a brutally center-cropped slice.
|
||||||
|
// For correctly-cropped photos the source already matches the target
|
||||||
|
// aspect and `thumbnailImage` produces target dims with no padding.
|
||||||
|
$imagick->thumbnailImage($width, $height, true);
|
||||||
|
if ($imagick->getImageWidth() !== $width || $imagick->getImageHeight() !== $height) {
|
||||||
|
$canvas = new \Imagick();
|
||||||
|
$canvas->newImage($width, $height, new \ImagickPixel('white'));
|
||||||
|
$canvas->setImageFormat('png');
|
||||||
|
$offsetX = (int) (($width - $imagick->getImageWidth()) / 2);
|
||||||
|
$offsetY = (int) (($height - $imagick->getImageHeight()) / 2);
|
||||||
|
$canvas->compositeImage($imagick, \Imagick::COMPOSITE_OVER, $offsetX, $offsetY);
|
||||||
|
$imagick->destroy();
|
||||||
|
$imagick = $canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portrait: rotate the fitted photo 90° CCW so the packed .bin's row
|
||||||
// layout matches the EPD's native 800-pixel scan order. The frame is
|
// layout matches the EPD's native 800-pixel scan order. The frame is
|
||||||
// physically rotated 90° CW for portrait (ribbon on right from EPD's
|
// physically rotated 90° CW for portrait (ribbon on right from EPD's
|
||||||
// POV → on left from user's view), so the photo's top edge maps to the
|
// POV → on left from user's view), so the photo's top edge maps to the
|
||||||
|
|||||||
Reference in New Issue
Block a user