Admin/Public: generate catalog thumbnails on file updates

This commit is contained in:
Alexander Andreev 2026-02-21 13:45:09 +03:00
parent a6d5ab4c57
commit def543f813
4 changed files with 193 additions and 1 deletions

View File

@ -149,6 +149,7 @@ BRANCH=master bash scripts/deploy.sh
- плоские комментарии к фото,
- удаление комментариев админом,
- watermark на выдаче версии "после".
- превью каталога (`thumbs/`) генерируются при загрузке/замене файла и после поворота.
Админка использует тот же `token`, что и `deploy.php`, из файла `deploy-config.php`.
@ -200,6 +201,7 @@ https://<домен>/deploy.php?token=<твой_секрет>
## Примечания
- Превью генерируются в формате JPEG с качеством ~82.
- Для разового backfill превью можно запустить: `php scripts/generate_thumbs.php`.
- При первом заходе на большую папку возможно небольшое ожидание (генерация превью).
- CSS/JS и favicon подключаются с cache-busting параметром `?v=<filemtime>`, чтобы после деплоя пользователю не приходилось чистить кеш вручную.
- В футере публичной страницы есть ненавязчивое авторство со ссылкой: `https://t.me/andr33vru`.

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
require_once __DIR__ . '/lib/db_gallery.php';
require_once __DIR__ . '/lib/thumbs.php';
const MAX_UPLOAD_BYTES = 3 * 1024 * 1024;
@ -171,8 +172,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_FILES['after']) && (int)($_FILES['after']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$p = photoById($photoId);
if (!$p) throw new RuntimeException('Фото не найдено');
$oldAfterPath = (string)($p['after_path'] ?? '');
$up = saveSingleImage($_FILES['after'], $code . 'р', (int)$p['section_id']);
photoFileUpsert($photoId, 'after', $up['path'], $up['mime'], $up['size']);
if ($oldAfterPath !== '' && $oldAfterPath !== $up['path']) {
deleteThumbBySourcePath(__DIR__, $oldAfterPath);
$oldAbs = __DIR__ . '/' . ltrim($oldAfterPath, '/');
if (is_file($oldAbs)) {
@unlink($oldAbs);
}
}
}
$message = 'Фото обновлено';
@ -196,6 +206,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
photoFileUpsert($photoId, 'after', $up['path'], $up['mime'], $up['size']);
if ($oldAfterPath !== '' && $oldAfterPath !== $up['path']) {
deleteThumbBySourcePath(__DIR__, $oldAfterPath);
$oldAbs = __DIR__ . '/' . ltrim($oldAfterPath, '/');
if (is_file($oldAbs)) {
@unlink($oldAbs);
@ -266,6 +277,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($p) {
foreach (['before_path', 'after_path'] as $k) {
if (!empty($p[$k])) {
deleteThumbBySourcePath(__DIR__, (string)$p[$k]);
$abs = __DIR__ . '/' . ltrim((string)$p[$k], '/');
if (is_file($abs)) @unlink($abs);
}
@ -296,6 +308,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$degrees = $direction === 'left' ? -90 : 90;
rotateImageOnDisk($absPath, $degrees);
ensureThumbForSource(__DIR__, $relPath);
$st = db()->prepare('UPDATE photo_files SET updated_at=CURRENT_TIMESTAMP WHERE photo_id=:pid AND kind=:kind');
$st->execute(['pid' => $photoId, 'kind' => $kind]);
@ -541,8 +554,11 @@ function saveSingleImage(array $file, string $baseName, int $sectionId): array
if (!move_uploaded_file($tmp, $dest)) throw new RuntimeException('Не удалось сохранить файл');
$storedRelPath = 'photos/section_' . $sectionId . '/' . $name;
ensureThumbForSource(__DIR__, $storedRelPath);
return [
'path' => 'photos/section_' . $sectionId . '/' . $name,
'path' => $storedRelPath,
'mime' => $mime,
'size' => $size,
];
@ -613,6 +629,7 @@ function removeSectionImageFiles(int $sectionId): void
if (is_file($abs)) {
@unlink($abs);
}
deleteThumbBySourcePath(__DIR__, $path);
}
}

131
lib/thumbs.php Normal file
View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
function thumbRelativePathForSource(string $sourceRelPath): string
{
$normalized = ltrim(str_replace('\\', '/', $sourceRelPath), '/');
$hash = sha1($normalized);
$base = (string)pathinfo($normalized, PATHINFO_FILENAME);
$safeBase = preg_replace('/[^A-Za-z0-9._-]+/', '_', $base) ?? 'photo';
$safeBase = trim($safeBase, '._-');
if ($safeBase === '') {
$safeBase = 'photo';
}
return 'thumbs/' . substr($hash, 0, 2) . '/' . substr($hash, 2, 2) . '/' . $safeBase . '_' . $hash . '.jpg';
}
function thumbAbsolutePathForSource(string $projectRoot, string $sourceRelPath): string
{
return rtrim($projectRoot, '/') . '/' . ltrim(thumbRelativePathForSource($sourceRelPath), '/');
}
function ensureThumbForSource(string $projectRoot, string $sourceRelPath, int $maxWidth = 520, int $maxHeight = 360, int $quality = 82): ?string
{
$normalized = ltrim(str_replace('\\', '/', $sourceRelPath), '/');
if ($normalized === '') {
return null;
}
$sourceAbs = rtrim($projectRoot, '/') . '/' . $normalized;
if (!is_file($sourceAbs)) {
return null;
}
$thumbRel = thumbRelativePathForSource($normalized);
$thumbAbs = rtrim($projectRoot, '/') . '/' . ltrim($thumbRel, '/');
$srcMtime = (int)(filemtime($sourceAbs) ?: 0);
$thumbMtime = (int)(filemtime($thumbAbs) ?: 0);
if ($thumbMtime > 0 && $thumbMtime >= $srcMtime) {
return $thumbRel;
}
$dir = dirname($thumbAbs);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
return null;
}
if (extension_loaded('imagick') && createThumbWithImagick($sourceAbs, $thumbAbs, $maxWidth, $maxHeight, $quality)) {
return $thumbRel;
}
if (createThumbWithGd($sourceAbs, $thumbAbs, $maxWidth, $maxHeight, $quality)) {
return $thumbRel;
}
return null;
}
function deleteThumbBySourcePath(string $projectRoot, string $sourceRelPath): void
{
$normalized = ltrim(str_replace('\\', '/', $sourceRelPath), '/');
if ($normalized === '') {
return;
}
$abs = thumbAbsolutePathForSource($projectRoot, $normalized);
if (is_file($abs)) {
@unlink($abs);
}
}
function createThumbWithImagick(string $sourceAbs, string $thumbAbs, int $maxWidth, int $maxHeight, int $quality): bool
{
try {
$img = new Imagick($sourceAbs);
if (method_exists($img, 'autoOrient')) {
$img->autoOrient();
} else {
$img->setImageOrientation(Imagick::ORIENTATION_TOPLEFT);
}
$img->thumbnailImage(max(1, $maxWidth), max(1, $maxHeight), true, true);
$img->setImageFormat('jpeg');
$img->setImageCompressionQuality(max(30, min(95, $quality)));
$img->stripImage();
$ok = $img->writeImage($thumbAbs);
$img->clear();
$img->destroy();
return (bool)$ok;
} catch (Throwable) {
return false;
}
}
function createThumbWithGd(string $sourceAbs, string $thumbAbs, int $maxWidth, int $maxHeight, int $quality): bool
{
[$srcW, $srcH, $type] = @getimagesize($sourceAbs) ?: [0, 0, 0];
if ($srcW < 1 || $srcH < 1) {
return false;
}
$src = match ($type) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($sourceAbs),
IMAGETYPE_PNG => @imagecreatefrompng($sourceAbs),
IMAGETYPE_GIF => @imagecreatefromgif($sourceAbs),
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($sourceAbs) : false,
default => false,
};
if (!$src) {
return false;
}
$scale = min($maxWidth / $srcW, $maxHeight / $srcH, 1);
$dstW = max(1, (int)round($srcW * $scale));
$dstH = max(1, (int)round($srcH * $scale));
$dst = imagecreatetruecolor($dstW, $dstH);
if ($dst === false) {
imagedestroy($src);
return false;
}
$bg = imagecolorallocate($dst, 255, 255, 255);
imagefill($dst, 0, 0, $bg);
$okCopy = imagecopyresampled($dst, $src, 0, 0, 0, 0, $dstW, $dstH, $srcW, $srcH);
$okSave = $okCopy && imagejpeg($dst, $thumbAbs, max(30, min(95, $quality)));
imagedestroy($src);
imagedestroy($dst);
return (bool)$okSave;
}

View File

@ -0,0 +1,42 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require __DIR__ . '/../lib/db.php';
require __DIR__ . '/../lib/thumbs.php';
try {
$pdo = db();
} catch (Throwable $e) {
fwrite(STDERR, "DB connection failed: " . $e->getMessage() . PHP_EOL);
exit(1);
}
$st = $pdo->query('SELECT file_path FROM photo_files ORDER BY id');
$paths = $st ? $st->fetchAll(PDO::FETCH_COLUMN) : [];
if (!is_array($paths)) {
$paths = [];
}
$total = 0;
$ok = 0;
$missing = 0;
foreach ($paths as $path) {
if (!is_string($path) || $path === '') {
continue;
}
$total++;
$thumb = ensureThumbForSource(dirname(__DIR__), $path);
if ($thumb !== null) {
$ok++;
continue;
}
$missing++;
}
echo "checked: {$total}" . PHP_EOL;
echo "generated_or_fresh: {$ok}" . PHP_EOL;
echo "missing_or_failed: {$missing}" . PHP_EOL;