diff --git a/README.md b/README.md index b57099d..25691ef 100644 --- a/README.md +++ b/README.md @@ -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=`, чтобы после деплоя пользователю не приходилось чистить кеш вручную. - В футере публичной страницы есть ненавязчивое авторство со ссылкой: `https://t.me/andr33vru`. diff --git a/admin.php b/admin.php index d21deee..79bdff4 100644 --- a/admin.php +++ b/admin.php @@ -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); } } diff --git a/lib/thumbs.php b/lib/thumbs.php new file mode 100644 index 0000000..4320e04 --- /dev/null +++ b/lib/thumbs.php @@ -0,0 +1,131 @@ + 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; +} diff --git a/scripts/generate_thumbs.php b/scripts/generate_thumbs.php new file mode 100644 index 0000000..b9487d3 --- /dev/null +++ b/scripts/generate_thumbs.php @@ -0,0 +1,42 @@ +#!/usr/bin/env php +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;