Admin/Public: generate catalog thumbnails on file updates
This commit is contained in:
parent
a6d5ab4c57
commit
def543f813
|
|
@ -149,6 +149,7 @@ BRANCH=master bash scripts/deploy.sh
|
||||||
- плоские комментарии к фото,
|
- плоские комментарии к фото,
|
||||||
- удаление комментариев админом,
|
- удаление комментариев админом,
|
||||||
- watermark на выдаче версии "после".
|
- watermark на выдаче версии "после".
|
||||||
|
- превью каталога (`thumbs/`) генерируются при загрузке/замене файла и после поворота.
|
||||||
|
|
||||||
|
|
||||||
Админка использует тот же `token`, что и `deploy.php`, из файла `deploy-config.php`.
|
Админка использует тот же `token`, что и `deploy.php`, из файла `deploy-config.php`.
|
||||||
|
|
@ -200,6 +201,7 @@ https://<домен>/deploy.php?token=<твой_секрет>
|
||||||
## Примечания
|
## Примечания
|
||||||
|
|
||||||
- Превью генерируются в формате JPEG с качеством ~82.
|
- Превью генерируются в формате JPEG с качеством ~82.
|
||||||
|
- Для разового backfill превью можно запустить: `php scripts/generate_thumbs.php`.
|
||||||
- При первом заходе на большую папку возможно небольшое ожидание (генерация превью).
|
- При первом заходе на большую папку возможно небольшое ожидание (генерация превью).
|
||||||
- CSS/JS и favicon подключаются с cache-busting параметром `?v=<filemtime>`, чтобы после деплоя пользователю не приходилось чистить кеш вручную.
|
- CSS/JS и favicon подключаются с cache-busting параметром `?v=<filemtime>`, чтобы после деплоя пользователю не приходилось чистить кеш вручную.
|
||||||
- В футере публичной страницы есть ненавязчивое авторство со ссылкой: `https://t.me/andr33vru`.
|
- В футере публичной страницы есть ненавязчивое авторство со ссылкой: `https://t.me/andr33vru`.
|
||||||
|
|
|
||||||
19
admin.php
19
admin.php
|
|
@ -3,6 +3,7 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/lib/db_gallery.php';
|
require_once __DIR__ . '/lib/db_gallery.php';
|
||||||
|
require_once __DIR__ . '/lib/thumbs.php';
|
||||||
|
|
||||||
const MAX_UPLOAD_BYTES = 3 * 1024 * 1024;
|
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) {
|
if (isset($_FILES['after']) && (int)($_FILES['after']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
||||||
$p = photoById($photoId);
|
$p = photoById($photoId);
|
||||||
if (!$p) throw new RuntimeException('Фото не найдено');
|
if (!$p) throw new RuntimeException('Фото не найдено');
|
||||||
|
$oldAfterPath = (string)($p['after_path'] ?? '');
|
||||||
$up = saveSingleImage($_FILES['after'], $code . 'р', (int)$p['section_id']);
|
$up = saveSingleImage($_FILES['after'], $code . 'р', (int)$p['section_id']);
|
||||||
photoFileUpsert($photoId, 'after', $up['path'], $up['mime'], $up['size']);
|
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 = 'Фото обновлено';
|
$message = 'Фото обновлено';
|
||||||
|
|
@ -196,6 +206,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
photoFileUpsert($photoId, 'after', $up['path'], $up['mime'], $up['size']);
|
photoFileUpsert($photoId, 'after', $up['path'], $up['mime'], $up['size']);
|
||||||
|
|
||||||
if ($oldAfterPath !== '' && $oldAfterPath !== $up['path']) {
|
if ($oldAfterPath !== '' && $oldAfterPath !== $up['path']) {
|
||||||
|
deleteThumbBySourcePath(__DIR__, $oldAfterPath);
|
||||||
$oldAbs = __DIR__ . '/' . ltrim($oldAfterPath, '/');
|
$oldAbs = __DIR__ . '/' . ltrim($oldAfterPath, '/');
|
||||||
if (is_file($oldAbs)) {
|
if (is_file($oldAbs)) {
|
||||||
@unlink($oldAbs);
|
@unlink($oldAbs);
|
||||||
|
|
@ -266,6 +277,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
if ($p) {
|
if ($p) {
|
||||||
foreach (['before_path', 'after_path'] as $k) {
|
foreach (['before_path', 'after_path'] as $k) {
|
||||||
if (!empty($p[$k])) {
|
if (!empty($p[$k])) {
|
||||||
|
deleteThumbBySourcePath(__DIR__, (string)$p[$k]);
|
||||||
$abs = __DIR__ . '/' . ltrim((string)$p[$k], '/');
|
$abs = __DIR__ . '/' . ltrim((string)$p[$k], '/');
|
||||||
if (is_file($abs)) @unlink($abs);
|
if (is_file($abs)) @unlink($abs);
|
||||||
}
|
}
|
||||||
|
|
@ -296,6 +308,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
|
||||||
$degrees = $direction === 'left' ? -90 : 90;
|
$degrees = $direction === 'left' ? -90 : 90;
|
||||||
rotateImageOnDisk($absPath, $degrees);
|
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 = db()->prepare('UPDATE photo_files SET updated_at=CURRENT_TIMESTAMP WHERE photo_id=:pid AND kind=:kind');
|
||||||
$st->execute(['pid' => $photoId, '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('Не удалось сохранить файл');
|
if (!move_uploaded_file($tmp, $dest)) throw new RuntimeException('Не удалось сохранить файл');
|
||||||
|
|
||||||
|
$storedRelPath = 'photos/section_' . $sectionId . '/' . $name;
|
||||||
|
ensureThumbForSource(__DIR__, $storedRelPath);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'path' => 'photos/section_' . $sectionId . '/' . $name,
|
'path' => $storedRelPath,
|
||||||
'mime' => $mime,
|
'mime' => $mime,
|
||||||
'size' => $size,
|
'size' => $size,
|
||||||
];
|
];
|
||||||
|
|
@ -613,6 +629,7 @@ function removeSectionImageFiles(int $sectionId): void
|
||||||
if (is_file($abs)) {
|
if (is_file($abs)) {
|
||||||
@unlink($abs);
|
@unlink($abs);
|
||||||
}
|
}
|
||||||
|
deleteThumbBySourcePath(__DIR__, $path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
131
lib/thumbs.php
Normal file
131
lib/thumbs.php
Normal 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;
|
||||||
|
}
|
||||||
42
scripts/generate_thumbs.php
Normal file
42
scripts/generate_thumbs.php
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user