feat: implement preview manager
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Preview;
|
||||
|
||||
use KTXF\Resource\BinaryResource;
|
||||
|
||||
final readonly class Preview
|
||||
{
|
||||
public function __construct(
|
||||
public BinaryResource $resource,
|
||||
public int $size,
|
||||
public string $etag,
|
||||
public ?int $width = null,
|
||||
public ?int $height = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Preview;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\SystemStore\SystemStoreManager;
|
||||
use KTXF\Preview\MimeType;
|
||||
use KTXF\Preview\PreviewGenerationException;
|
||||
use KTXF\Preview\PreviewRequest;
|
||||
use KTXF\Preview\PreviewResult;
|
||||
use KTXF\Preview\PreviewSource;
|
||||
use KTXF\Preview\Provider\PreviewProviderInterface;
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
use KTXF\SystemStore\BlobInfo;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
final readonly class PreviewManager
|
||||
{
|
||||
public function __construct(
|
||||
private TenantService $tenants,
|
||||
private ProviderManager $providers,
|
||||
private SystemStoreManager $stores,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function fetch(
|
||||
string $tenantId,
|
||||
PreviewSource $source,
|
||||
string $variant = 'popover',
|
||||
): ?Preview {
|
||||
$tenant = $this->tenants->fetchById($tenantId);
|
||||
if ($tenant === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$configuration = $tenant->getConfiguration()->preview();
|
||||
$variantConfiguration = $configuration->variants()->variant($variant);
|
||||
if (
|
||||
!$configuration->enabled()
|
||||
|| $variantConfiguration === null
|
||||
|| ($source->size !== null && $source->size > $configuration->maxSourceSize())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$request = new PreviewRequest(
|
||||
maxWidth: $variantConfiguration->width,
|
||||
maxHeight: $variantConfiguration->height,
|
||||
preferredMimeType: $variantConfiguration->format,
|
||||
quality: $variantConfiguration->quality,
|
||||
maxSourceSize: $configuration->maxSourceSize(),
|
||||
);
|
||||
|
||||
try {
|
||||
$cached = $this->readCache(
|
||||
$tenantId,
|
||||
$configuration->store(),
|
||||
$source,
|
||||
$request,
|
||||
);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$provider = $this->selectProvider($source->mimeType, $request);
|
||||
$result = $provider->generate($source, $request);
|
||||
$blob = $this->writeCache(
|
||||
$tenantId,
|
||||
$configuration->store(),
|
||||
$source,
|
||||
$request,
|
||||
$result,
|
||||
);
|
||||
|
||||
return $this->readStoredPreview(
|
||||
$tenantId,
|
||||
$configuration->store(),
|
||||
$blob,
|
||||
$request,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->logger->warning('Preview unavailable', [
|
||||
'tenantId' => $tenantId,
|
||||
'sourceType' => $source->sourceType,
|
||||
'variant' => $variant,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function readCache(
|
||||
string $tenantId,
|
||||
string $store,
|
||||
PreviewSource $source,
|
||||
PreviewRequest $request,
|
||||
): ?Preview {
|
||||
$key = $this->cacheKey($tenantId, $source, $request);
|
||||
$blob = $this->stores->stat($tenantId, $store, $key);
|
||||
if (!$this->validBlob($blob, $key, $request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->readStoredPreview($tenantId, $store, $blob, $request);
|
||||
}
|
||||
|
||||
private function readStoredPreview(
|
||||
string $tenantId,
|
||||
string $store,
|
||||
BlobInfo $blob,
|
||||
PreviewRequest $request,
|
||||
): ?Preview {
|
||||
$resource = $this->stores->read($tenantId, $store, $blob->key);
|
||||
if ($resource === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (MimeType::normalize($resource->mimeType()) !== $request->preferredMimeType) {
|
||||
return null;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Preview(
|
||||
resource: $resource,
|
||||
size: $blob->size,
|
||||
etag: $blob->etag,
|
||||
width: $blob->attributes['width'] ?? null,
|
||||
height: $blob->attributes['height'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
private function writeCache(
|
||||
string $tenantId,
|
||||
string $store,
|
||||
PreviewSource $source,
|
||||
PreviewRequest $request,
|
||||
PreviewResult $result,
|
||||
): BlobInfo {
|
||||
$outputMimeType = MimeType::normalize($result->resource->mimeType());
|
||||
if ($outputMimeType !== $request->preferredMimeType) {
|
||||
throw new PreviewGenerationException('Generated preview MIME does not match the requested output MIME');
|
||||
}
|
||||
if (
|
||||
($result->width !== null && $result->width > $request->maxWidth)
|
||||
|| ($result->height !== null && $result->height > $request->maxHeight)
|
||||
) {
|
||||
throw new PreviewGenerationException('Generated preview dimensions exceed the requested limits');
|
||||
}
|
||||
|
||||
return $this->stores->write(
|
||||
$tenantId,
|
||||
$store,
|
||||
$this->cacheKey($tenantId, $source, $request),
|
||||
$result->resource,
|
||||
['width' => $result->width, 'height' => $result->height],
|
||||
);
|
||||
}
|
||||
|
||||
private function cacheKey(
|
||||
string $tenantId,
|
||||
PreviewSource $source,
|
||||
PreviewRequest $request,
|
||||
): string {
|
||||
if (trim($tenantId) === '') {
|
||||
throw new InvalidArgumentException('Preview cache keys require a tenant identifier');
|
||||
}
|
||||
|
||||
$canonical = json_encode([
|
||||
'tenantId' => $tenantId,
|
||||
'source' => [
|
||||
'type' => $source->sourceType,
|
||||
'identity' => $source->identity,
|
||||
'signature' => $source->signature,
|
||||
'mimeType' => $source->mimeType,
|
||||
],
|
||||
'request' => [
|
||||
'maxWidth' => $request->maxWidth,
|
||||
'maxHeight' => $request->maxHeight,
|
||||
'preferredMimeType' => $request->preferredMimeType,
|
||||
'quality' => $request->quality,
|
||||
],
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION);
|
||||
$digest = hash('sha256', $canonical);
|
||||
|
||||
return "generated/{$source->sourceType}/" . substr($digest, 0, 2) . "/{$digest}";
|
||||
}
|
||||
|
||||
private function validBlob(?BlobInfo $blob, string $key, PreviewRequest $request): bool
|
||||
{
|
||||
if ($blob === null || $blob->key !== $key || $blob->size < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$width = $blob->attributes['width'] ?? null;
|
||||
$height = $blob->attributes['height'] ?? null;
|
||||
if (
|
||||
($width !== null && (!is_int($width) || $width < 1 || $width > $request->maxWidth))
|
||||
|| ($height !== null && (!is_int($height) || $height < 1 || $height > $request->maxHeight))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return MimeType::normalize($blob->mimeType) === $request->preferredMimeType;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function selectProvider(
|
||||
string $sourceMimeType,
|
||||
PreviewRequest $request,
|
||||
): PreviewProviderInterface {
|
||||
$sourceMimeType = MimeType::normalize($sourceMimeType);
|
||||
$candidates = [];
|
||||
|
||||
foreach ($this->providers->providers(ProviderInterface::TYPE_PREVIEW) as $identifier => $provider) {
|
||||
if (
|
||||
!$provider instanceof PreviewProviderInterface
|
||||
|| !$provider->supports($sourceMimeType, $request)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = ['identifier' => $identifier, 'provider' => $provider];
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
throw new PreviewGenerationException(
|
||||
"No preview provider supports {$sourceMimeType} to {$request->preferredMimeType}"
|
||||
);
|
||||
}
|
||||
|
||||
usort($candidates, static function (array $left, array $right): int {
|
||||
return ($right['provider']->priority() <=> $left['provider']->priority())
|
||||
?: strcmp($left['identifier'], $right['identifier']);
|
||||
});
|
||||
|
||||
return $candidates[0]['provider'];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user