Files
pictureFrame-webApp/src/Controller/ImageApiController.php
T
football2801 d31698e7b3
CI / test (push) Has been cancelled
fix: thread cropOrientation into StickerCanvas (was using device orientation)
StickerCanvas was being passed contextOrientation (the target device's
orientation), so the final composited.jpg was always sized to the device's
aspect — even when the user toggled the crop tool to a different orientation.
A landscape crop on a portrait device would produce a 1600x960 cropped
blob, then the StickerCanvas would re-render it into a 960x1600 frame,
visibly stretching the image into portrait dimensions and saving it that
way.

UploadView now derives an effectiveOrientation that prefers the user's
chosen crop orientation (uploadStore.cropOrientation) and falls back to
the device's orientation only before the crop step has run. The
StickerCanvas honors that.

Also adds a temporary debug log in the upload controller to verify the
cropOrientation form field is arriving and being persisted — recent
uploads have NULL cropOrientation despite the frontend sending it, and
this log will make the next upload's payload visible. Will remove once
diagnosed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:05:31 -04:00

367 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Device;
use App\Entity\Image;
use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Enum\TokenType;
use App\Message\RenderImageMessage;
use App\Service\ShareService;
use App\Service\TokenService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/images')]
#[IsGranted('ROLE_USER')]
class ImageApiController extends AbstractController
{
private const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
private const MAX_BYTES = 30 * 1024 * 1024; // 30 MB
public function __construct(
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {}
#[Route('', name: 'api_images_list', methods: ['GET'])]
public function list(EntityManagerInterface $em): JsonResponse
{
$user = $this->getUser();
$images = $em->getRepository(Image::class)->findBy(
['user' => $user, 'deletedAt' => null],
['uploadedAt' => 'DESC'],
);
return $this->json(array_map($this->serialize(...), $images));
}
#[Route('', name: 'api_images_upload', methods: ['POST'])]
public function upload(
Request $request,
EntityManagerInterface $em,
MessageBusInterface $bus,
LoggerInterface $logger,
): JsonResponse {
$file = $request->files->get('file');
if (!$file) {
return $this->json(['error' => 'No file uploaded'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if ($file->getSize() > self::MAX_BYTES) {
return $this->json(['error' => 'File too large (max 30 MB)'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if (!in_array($file->getMimeType(), self::ALLOWED_MIME, true)) {
return $this->json(['error' => 'Unsupported file type'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
/** @var \App\Entity\User $user */
$user = $this->getUser();
// Create Image entity first with a placeholder path so we have the ID
$image = (new Image())
->setUser($user)
->setOriginalFilename($file->getClientOriginalName());
$em->persist($image);
$em->flush(); // get ID
// Build storage directory
$storageDir = $this->projectDir . '/var/storage/images/' . $image->getId();
if (!is_dir($storageDir)) {
mkdir($storageDir, 0755, true);
}
// If a separate pre-crop original was sent, save it; otherwise the uploaded file IS the original
$originalFile = $request->files->get('original') ?? $file;
$ext = strtolower($originalFile->guessExtension() ?? 'jpg');
$origRelPath = 'var/storage/images/' . $image->getId() . '/original.' . $ext;
$originalFile->move($storageDir, 'original.' . $ext);
// If a composited (cropped+stickered) version was also sent, save it separately
if ($request->files->get('original')) {
// $file is the composited; move to composited.jpg for the renderer
$file->move($storageDir, 'composited.jpg');
}
$image->setStoragePath($origRelPath);
if ($request->request->has('cropParams')) {
$image->setCropParams($request->request->get('cropParams'));
}
if ($request->request->has('stickerState')) {
$image->setStickerState($request->request->get('stickerState'));
}
if ($request->request->has('cropOrientation')) {
$image->setCropOrientation(
Orientation::tryFrom((string) $request->request->get('cropOrientation'))
);
}
// TEMP debug: log what arrived in the upload form so we can diagnose
// why crop_orientation is landing as NULL despite the frontend
// claiming to send it. Remove once stable.
$logger->info('image.upload.fields', [
'image_id' => $image->getId(),
'has_cropParams' => $request->request->has('cropParams'),
'has_stickerState' => $request->request->has('stickerState'),
'has_cropOrient' => $request->request->has('cropOrientation'),
'cropOrient_raw' => $request->request->get('cropOrientation'),
'all_keys' => $request->request->keys(),
'persisted_orient' => $image->getCropOrientation()?->value,
]);
// Generate thumbnail from composited if available, otherwise from original
$thumbSrc = file_exists($storageDir . '/composited.jpg')
? $storageDir . '/composited.jpg'
: $storageDir . '/original.' . $ext;
$this->generateThumbnail($thumbSrc, $storageDir . '/thumbnail.jpg');
$em->flush();
// Dispatch rendering for all model × orientation combos
foreach (DeviceModel::cases() as $model) {
foreach (Orientation::cases() as $orientation) {
$asset = (new RenderedAsset())
->setImage($image)
->setDeviceModel($model)
->setOrientation($orientation);
$em->persist($asset);
$bus->dispatch(new RenderImageMessage($image->getId(), $model->value, $orientation->value));
}
}
$em->flush();
return $this->json($this->serialize($image), Response::HTTP_CREATED);
}
#[Route('/{id}', name: 'api_images_delete', methods: ['DELETE'])]
public function delete(int $id, EntityManagerInterface $em): JsonResponse
{
$image = $this->findOwnedImage($id, $em);
if (!$image) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$image->setDeletedAt(new \DateTimeImmutable());
$em->flush();
return $this->json(null, Response::HTTP_NO_CONTENT);
}
#[Route('/{id}/thumbnail', name: 'api_images_thumbnail', methods: ['GET'])]
public function thumbnail(int $id, EntityManagerInterface $em): Response
{
$image = $this->findOwnedImage($id, $em);
if (!$image) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$thumbPath = $this->projectDir . '/var/storage/images/' . $id . '/thumbnail.jpg';
if (!file_exists($thumbPath)) {
return $this->json(['error' => 'Thumbnail not ready'], Response::HTTP_NOT_FOUND);
}
return new BinaryFileResponse($thumbPath);
}
#[Route('/{id}/original', name: 'api_images_original', methods: ['GET'])]
public function original(int $id, EntityManagerInterface $em): Response
{
$image = $this->findOwnedImage($id, $em);
if (!$image) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$storageDir = $this->projectDir . '/var/storage/images/' . $id;
foreach (['original.jpg', 'original.png', 'original.webp', 'original.gif'] as $candidate) {
$path = $storageDir . '/' . $candidate;
if (file_exists($path)) {
return new BinaryFileResponse($path);
}
}
return $this->json(['error' => 'Original not found'], Response::HTTP_NOT_FOUND);
}
#[Route('/{id}/reprocess', name: 'api_images_reprocess', methods: ['POST'])]
public function reprocess(
int $id,
Request $request,
EntityManagerInterface $em,
MessageBusInterface $bus,
): JsonResponse {
$image = $this->findOwnedImage($id, $em);
if (!$image) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$file = $request->files->get('file');
if (!$file) {
return $this->json(['error' => 'No file uploaded'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$storageDir = $this->projectDir . '/var/storage/images/' . $id;
// Overwrite composited with the new version
$file->move($storageDir, 'composited.jpg');
// Regenerate thumbnail from new composited
$this->generateThumbnail($storageDir . '/composited.jpg', $storageDir . '/thumbnail.jpg');
// Persist updated crop/sticker metadata if provided
if ($request->request->has('cropParams')) {
$image->setCropParams($request->request->get('cropParams'));
}
if ($request->request->has('stickerState')) {
$image->setStickerState($request->request->get('stickerState'));
}
if ($request->request->has('cropOrientation')) {
$image->setCropOrientation(
Orientation::tryFrom((string) $request->request->get('cropOrientation'))
);
}
// Reset all rendered assets so they re-render from the new composited
foreach ($image->getRenderedAssets() as $asset) {
$asset->setStatus(RenderStatus::Pending)->setFilePath(null);
$bus->dispatch(new RenderImageMessage($id, $asset->getDeviceModel()->value, $asset->getOrientation()->value));
}
$em->flush();
return $this->json($this->serialize($image));
}
#[Route('/{id}/share', name: 'api_images_share', methods: ['POST'])]
public function share(int $id, Request $request, EntityManagerInterface $em, ShareService $shareService): JsonResponse
{
$image = $this->findOwnedImage($id, $em);
if (!$image) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$body = json_decode($request->getContent(), true) ?? [];
$email = trim((string) ($body['recipientEmail'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->json(['error' => 'Invalid email address'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
try {
/** @var \App\Entity\User $user */
$user = $this->getUser();
$shareService->share($image, $user, $email);
} catch (\InvalidArgumentException $e) {
return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->json(null, Response::HTTP_NO_CONTENT);
}
#[Route('/{id}/hard-delete-request', name: 'api_images_hard_delete_request', methods: ['POST'])]
public function hardDeleteRequest(int $id, EntityManagerInterface $em, TokenService $tokenService): JsonResponse
{
$image = $this->findOwnedImage($id, $em);
if (!$image) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
/** @var \App\Entity\User $user */
$user = $this->getUser();
$ttl = (int) ($_ENV['HARD_DELETE_TOKEN_TTL_DAYS'] ?? 30);
$tokenService->issue(TokenType::HardDeleteConfirm, $image, $user, $user->getEmail(), $ttl);
$em->flush();
return $this->json(null, Response::HTTP_NO_CONTENT);
}
#[Route('/{id}/approve/{deviceId}', name: 'api_images_approve', methods: ['POST'])]
public function approve(int $id, int $deviceId, EntityManagerInterface $em): JsonResponse
{
return $this->toggleApproval($id, $deviceId, $em, true);
}
#[Route('/{id}/approve/{deviceId}', name: 'api_images_revoke', methods: ['DELETE'])]
public function revoke(int $id, int $deviceId, EntityManagerInterface $em): JsonResponse
{
return $this->toggleApproval($id, $deviceId, $em, false);
}
private function toggleApproval(int $imageId, int $deviceId, EntityManagerInterface $em, bool $approve): JsonResponse
{
/** @var \App\Entity\User $user */
$user = $this->getUser();
$image = $this->findOwnedImage($imageId, $em);
if (!$image) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$device = $em->getRepository(Device::class)->findOneBy(['id' => $deviceId, 'user' => $user]);
if (!$device) {
return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
}
if ($approve) {
$image->approveForDevice($device);
} else {
$image->revokeForDevice($device);
}
$em->flush();
return $this->json($this->serialize($image));
}
private function findOwnedImage(int $id, EntityManagerInterface $em): ?Image
{
$image = $em->getRepository(Image::class)->findOneBy([
'id' => $id,
'user' => $this->getUser(),
'deletedAt' => null,
]);
return $image;
}
private function serialize(Image $image): array
{
$id = $image->getId();
return [
'id' => $id,
'originalFilename' => $image->getOriginalFilename(),
'thumbnailUrl' => '/api/images/' . $id . '/thumbnail',
'originalUrl' => '/api/images/' . $id . '/original',
'uploadedAt' => $image->getUploadedAt()->format(\DateTimeInterface::ATOM),
'approvedDeviceIds' => array_values($image->getApprovedDevices()->map(fn($d) => $d->getId())->toArray()),
'cropParams' => $image->getCropParams() ? json_decode($image->getCropParams(), true) : null,
'stickerState' => $image->getStickerState() ? json_decode($image->getStickerState(), true) : null,
'cropOrientation' => $image->getCropOrientation()?->value,
];
}
private function generateThumbnail(string $srcPath, string $destPath): void
{
$imagick = new \Imagick($srcPath);
$imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
$imagick->setBackgroundColor('white');
$imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
$imagick->autoOrient();
$imagick->thumbnailImage(800, 600, true);
$imagick->setImageFormat('jpeg');
$imagick->setImageCompressionQuality(80);
$imagick->writeImage($destPath);
$imagick->destroy();
}
}