Implement creator studio and upload updates
This commit is contained in:
347
app/Services/Images/ArtworkSquareThumbnailBackfillService.php
Normal file
347
app/Services/Images/ArtworkSquareThumbnailBackfillService.php
Normal file
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Repositories\Uploads\ArtworkFileRepository;
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\ThumbnailService;
|
||||
use App\Services\Uploads\UploadDerivativesService;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use RuntimeException;
|
||||
|
||||
final class ArtworkSquareThumbnailBackfillService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UploadDerivativesService $derivatives,
|
||||
private readonly UploadStorageService $storage,
|
||||
private readonly ArtworkFileRepository $artworkFiles,
|
||||
private readonly ArtworkCdnPurgeService $cdnPurge,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function ensureSquareThumbnail(Artwork $artwork, bool $force = false, bool $dryRun = false): array
|
||||
{
|
||||
$hash = strtolower((string) ($artwork->hash ?? ''));
|
||||
if ($hash === '') {
|
||||
throw new RuntimeException('Artwork hash is required to generate a square thumbnail.');
|
||||
}
|
||||
|
||||
$existing = DB::table('artwork_files')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('variant', 'sq')
|
||||
->first(['path']);
|
||||
|
||||
if ($existing !== null && ! $force) {
|
||||
return [
|
||||
'status' => 'skipped',
|
||||
'reason' => 'already_exists',
|
||||
'artwork_id' => $artwork->id,
|
||||
'path' => (string) ($existing->path ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$resolved = $this->resolveBestSource($artwork);
|
||||
if ($dryRun) {
|
||||
return [
|
||||
'status' => 'dry_run',
|
||||
'artwork_id' => $artwork->id,
|
||||
'source_variant' => $resolved['variant'],
|
||||
'source_path' => $resolved['source_path'],
|
||||
'object_path' => $this->storage->objectPathForVariant('sq', $hash, $hash . '.webp'),
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$asset = $this->derivatives->generateSquareDerivative($resolved['source_path'], $hash, [
|
||||
'context' => ['artwork' => $artwork],
|
||||
]);
|
||||
|
||||
$this->artworkFiles->upsert($artwork->id, 'sq', $asset['path'], $asset['mime'], $asset['size']);
|
||||
|
||||
$this->cdnPurge->purgeArtworkObjectPaths([$asset['path']], [
|
||||
'artwork_id' => $artwork->id,
|
||||
'reason' => 'square_thumbnail_regenerated',
|
||||
]);
|
||||
|
||||
if (! is_string($artwork->thumb_ext) || trim($artwork->thumb_ext) === '') {
|
||||
$artwork->forceFill(['thumb_ext' => 'webp'])->saveQuietly();
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'generated',
|
||||
'artwork_id' => $artwork->id,
|
||||
'path' => $asset['path'],
|
||||
'source_variant' => $resolved['variant'],
|
||||
'crop_mode' => $asset['result']?->cropMode,
|
||||
];
|
||||
} finally {
|
||||
if (($resolved['cleanup'] ?? false) === true) {
|
||||
File::delete($resolved['source_path']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{variant: string, source_path: string, cleanup: bool}
|
||||
*/
|
||||
private function resolveBestSource(Artwork $artwork): array
|
||||
{
|
||||
$hash = strtolower((string) ($artwork->hash ?? ''));
|
||||
$files = DB::table('artwork_files')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->pluck('path', 'variant')
|
||||
->all();
|
||||
|
||||
$variants = ['orig_image', 'orig', 'xl', 'lg', 'md', 'sm', 'xs'];
|
||||
|
||||
foreach ($variants as $variant) {
|
||||
$path = $files[$variant] ?? null;
|
||||
if (! is_string($path) || trim($path) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($variant === 'orig_image' || $variant === 'orig') {
|
||||
$filename = basename($path);
|
||||
$localPath = $this->storage->localOriginalPath($hash, $filename);
|
||||
if (is_file($localPath)) {
|
||||
return [
|
||||
'variant' => $variant,
|
||||
'source_path' => $localPath,
|
||||
'cleanup' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$temporary = $this->downloadToTempFile($path, pathinfo($path, PATHINFO_EXTENSION) ?: 'webp');
|
||||
if ($temporary !== null) {
|
||||
return [
|
||||
'variant' => $variant,
|
||||
'source_path' => $temporary,
|
||||
'cleanup' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$directSource = $this->resolveArtworkFilePathSource($artwork);
|
||||
if ($directSource !== null) {
|
||||
return $directSource;
|
||||
}
|
||||
|
||||
$canonicalDerivativeSource = $this->resolveCanonicalDerivativeSource($artwork);
|
||||
if ($canonicalDerivativeSource !== null) {
|
||||
return $canonicalDerivativeSource;
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf('No usable source image was found for artwork %d.', (int) $artwork->id));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{variant: string, source_path: string, cleanup: bool}|null
|
||||
*/
|
||||
private function resolveArtworkFilePathSource(Artwork $artwork): ?array
|
||||
{
|
||||
$relativePath = trim((string) ($artwork->file_path ?? ''), '/');
|
||||
if ($relativePath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->localFilePathCandidates($relativePath) as $candidate) {
|
||||
if (is_file($candidate)) {
|
||||
return [
|
||||
'variant' => 'file_path',
|
||||
'source_path' => $candidate,
|
||||
'cleanup' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$downloaded = $this->downloadUrlToTempFile($this->cdnUrlForPath($relativePath), pathinfo($relativePath, PATHINFO_EXTENSION));
|
||||
|
||||
if ($downloaded === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'variant' => 'file_path',
|
||||
'source_path' => $downloaded,
|
||||
'cleanup' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{variant: string, source_path: string, cleanup: bool}|null
|
||||
*/
|
||||
private function resolveCanonicalDerivativeSource(Artwork $artwork): ?array
|
||||
{
|
||||
$hash = strtolower((string) ($artwork->hash ?? ''));
|
||||
$thumbExt = strtolower(ltrim((string) ($artwork->thumb_ext ?? ''), '.'));
|
||||
|
||||
if ($hash === '' || $thumbExt === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['xl', 'lg', 'md', 'sm', 'xs'] as $variant) {
|
||||
$url = ThumbnailService::fromHash($hash, $thumbExt, $variant);
|
||||
if (! is_string($url) || $url === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$downloaded = $this->downloadUrlToTempFile($url, $thumbExt);
|
||||
if ($downloaded === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
'variant' => $variant,
|
||||
'source_path' => $downloaded,
|
||||
'cleanup' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function localFilePathCandidates(string $relativePath): array
|
||||
{
|
||||
$normalizedPath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relativePath);
|
||||
|
||||
return array_values(array_unique([
|
||||
$normalizedPath,
|
||||
base_path($normalizedPath),
|
||||
public_path($normalizedPath),
|
||||
storage_path('app/public' . DIRECTORY_SEPARATOR . $normalizedPath),
|
||||
storage_path('app/private' . DIRECTORY_SEPARATOR . $normalizedPath),
|
||||
]));
|
||||
}
|
||||
|
||||
private function cdnUrlForPath(string $relativePath): string
|
||||
{
|
||||
return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($relativePath, '/');
|
||||
}
|
||||
|
||||
private function downloadUrlToTempFile(string $url, string $extension = ''): ?string
|
||||
{
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => 30,
|
||||
'ignore_errors' => true,
|
||||
'header' => implode("\r\n", [
|
||||
'User-Agent: Skinbase Nova square-thumb backfill',
|
||||
'Accept: image/*,*/*;q=0.8',
|
||||
'Accept-Encoding: identity',
|
||||
'Connection: close',
|
||||
]) . "\r\n",
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$contents = @file_get_contents($url, false, $context);
|
||||
$headers = $http_response_header ?? [];
|
||||
|
||||
if (! is_string($contents) || $contents === '' || ! $this->isSuccessfulHttpResponse($url, $headers)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resolvedExtension = trim($extension) !== ''
|
||||
? trim($extension)
|
||||
: $this->extensionFromContentType($this->contentTypeFromHeaders($headers));
|
||||
|
||||
return $this->writeTemporaryFile($contents, $resolvedExtension);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $headers
|
||||
*/
|
||||
private function isSuccessfulHttpResponse(string $url, array $headers): bool
|
||||
{
|
||||
if ($headers === [] && parse_url($url, PHP_URL_SCHEME) === 'file') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$statusLine = $headers[0] ?? '';
|
||||
if (! is_string($statusLine) || ! preg_match('/\s(\d{3})\s/', $statusLine, $matches)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$statusCode = (int) ($matches[1] ?? 0);
|
||||
|
||||
return $statusCode >= 200 && $statusCode < 300;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $headers
|
||||
*/
|
||||
private function contentTypeFromHeaders(array $headers): string
|
||||
{
|
||||
foreach ($headers as $header) {
|
||||
if (! is_string($header) || stripos($header, 'Content-Type:') !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return trim(substr($header, strlen('Content-Type:')));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function writeTemporaryFile(string $contents, string $extension = ''): string
|
||||
{
|
||||
$temp = tempnam(sys_get_temp_dir(), 'sq-thumb-');
|
||||
if ($temp === false) {
|
||||
throw new RuntimeException('Unable to allocate a temporary file for square thumbnail generation.');
|
||||
}
|
||||
|
||||
$normalizedExtension = trim((string) $extension);
|
||||
$path = $normalizedExtension !== '' ? $temp . '.' . $normalizedExtension : $temp;
|
||||
|
||||
if ($normalizedExtension !== '') {
|
||||
rename($temp, $path);
|
||||
}
|
||||
|
||||
File::put($path, $contents);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function extensionFromContentType(string $contentType): string
|
||||
{
|
||||
$normalized = strtolower(trim(strtok($contentType, ';') ?: ''));
|
||||
|
||||
return match ($normalized) {
|
||||
'image/jpeg', 'image/jpg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'image/gif' => 'gif',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function downloadToTempFile(string $objectPath, string $extension): ?string
|
||||
{
|
||||
$contents = $this->storage->readObject($objectPath);
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->writeTemporaryFile($contents, $extension);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user