Decode the device's rendered 4bpp Spectra-6 .bin into a PNG (cached
next to the .bin) so the home-screen preview matches the dithered
6-color output the e-ink actually displays.
- New endpoint: GET /api/devices/{id}/preview
- Expose currentImageId on device JSON
- HomeView passes preview URL to FrameCard for both single and compact layouts
- Drive-by: fix vite.config.ts to import defineConfig from vitest/config
so the build no longer fails on the unknown `test` property; remove
unused useUploadStore import in HomeView test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,13 +6,18 @@ namespace App\Controller;
|
||||
|
||||
use App\Entity\Device;
|
||||
use App\Entity\Image;
|
||||
use App\Entity\RenderedAsset;
|
||||
use App\Entity\User;
|
||||
use App\Enum\Orientation;
|
||||
use App\Enum\RenderStatus;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
@@ -20,6 +25,23 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted('ROLE_USER')]
|
||||
class DeviceApiController extends AbstractController
|
||||
{
|
||||
// Spectra 6 palette — must match RenderImageMessageHandler::PALETTE.
|
||||
// Used to decode the device's 4bpp .bin into an RGB preview that mirrors
|
||||
// exactly what the e-ink displays.
|
||||
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(
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
#[Route('', name: 'api_devices_list', methods: ['GET'])]
|
||||
public function list(EntityManagerInterface $em): JsonResponse
|
||||
{
|
||||
@@ -149,6 +171,83 @@ class DeviceApiController extends AbstractController
|
||||
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
|
||||
'lastSeenAt' => $d->getLastSeenAt()?->format(\DateTimeInterface::ATOM),
|
||||
'lockedImageId' => $d->getLockedImage()?->getId(),
|
||||
'currentImageId' => $d->getCurrentImage()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a PNG preview of the image currently shown on the frame, decoded
|
||||
* from the device's rendered 4bpp Spectra-6 .bin so the colors match what
|
||||
* the e-ink actually displays. The PNG is cached on disk next to the .bin.
|
||||
*/
|
||||
#[Route('/{id}/preview', name: 'api_device_preview', methods: ['GET'])]
|
||||
public function preview(int $id, EntityManagerInterface $em): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$device = $em->getRepository(Device::class)->findOneBy(['id' => $id, 'user' => $user]);
|
||||
if (!$device) {
|
||||
return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$image = $device->getLockedImage() ?? $device->getCurrentImage();
|
||||
if (!$image || $image->isDeleted()) {
|
||||
return $this->json(['error' => 'No current image'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$asset = $em->getRepository(RenderedAsset::class)->findOneBy([
|
||||
'image' => $image,
|
||||
'deviceModel' => $device->getModel(),
|
||||
'orientation' => $device->getOrientation(),
|
||||
'status' => RenderStatus::Ready,
|
||||
]);
|
||||
if (!$asset?->getFilePath()) {
|
||||
return $this->json(['error' => 'Render not ready'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$binPath = $this->projectDir . '/' . $asset->getFilePath();
|
||||
if (!file_exists($binPath)) {
|
||||
return $this->json(['error' => 'Render file missing'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$pngPath = preg_replace('/\.bin$/', '.png', $binPath);
|
||||
if (!file_exists($pngPath) || filemtime($pngPath) < filemtime($binPath)) {
|
||||
$this->renderBinToPng(
|
||||
$binPath,
|
||||
$pngPath,
|
||||
$device->getModel()->width($device->getOrientation()),
|
||||
$device->getModel()->height($device->getOrientation()),
|
||||
);
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($pngPath);
|
||||
$response->headers->set('Content-Type', 'image/png');
|
||||
$response->setContentDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_INLINE,
|
||||
'frame-' . $device->getId() . '.png',
|
||||
);
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function renderBinToPng(string $binPath, string $pngPath, int $width, int $height): void
|
||||
{
|
||||
$bin = (string) file_get_contents($binPath);
|
||||
$len = strlen($bin);
|
||||
$rgb = '';
|
||||
$white = self::PALETTE[0x1];
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$byte = ord($bin[$i]);
|
||||
foreach ([($byte >> 4) & 0xF, $byte & 0xF] as $idx) {
|
||||
$c = self::PALETTE[$idx] ?? $white;
|
||||
$rgb .= chr($c[0]) . chr($c[1]) . chr($c[2]);
|
||||
}
|
||||
}
|
||||
|
||||
$im = new \Imagick();
|
||||
$im->newImage($width, $height, new \ImagickPixel('white'));
|
||||
$im->importImagePixels(0, 0, $width, $height, 'RGB', \Imagick::PIXEL_CHAR, $rgb);
|
||||
$im->setImageFormat('png');
|
||||
$im->writeImage($pngPath);
|
||||
$im->destroy();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user