压缩图片命令

This commit is contained in:
2025-06-17 10:19:06 +08:00
parent 5d90f70f96
commit ffa0248dae
3 changed files with 1025 additions and 1 deletions

View File

@@ -0,0 +1,150 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use App\Service\ImageService;
class OptimizeImageCompression extends Command
{
protected $signature = 'images:optimize-compression
{--max-size=1024 : 目标最大文件大小 (KB)}
{--quality=85 : 初始压缩质量}
{--min-quality=40 : 最低压缩质量}
{--max-iterations=10 : 最大压缩次数}';
protected $description = '优化压缩目录下的图片,压缩到指定大小以下';
public function handle()
{
$disk = Storage::disk('public');
$directory = 'images/compressed';
$maxSizeKB = (int)$this->option('max-size');
$maxSizeBytes = $maxSizeKB * 1024;
$this->info("正在优化 {$directory} 目录下的图片...");
$this->info("目标文件大小: {$maxSizeKB}KB");
$images = $disk->allFiles($directory);
$total = count($images);
$count = 0;
$progressBar = $this->output->createProgressBar($total);
$progressBar->start();
foreach ($images as $imagePath) {
$originalSize = $disk->size($imagePath);
$fileExtension = pathinfo($imagePath, PATHINFO_EXTENSION);
// 只处理WEBP格式文件
if (strtolower($fileExtension) !== 'webp') {
$this->warn("跳过非WEBP文件: $imagePath");
$progressBar->advance();
continue;
}
// 如果已经小于目标大小,则跳过
if ($originalSize <= $maxSizeBytes) {
$progressBar->advance();
continue;
}
// 获取文件的绝对路径
$filePath = $disk->path($imagePath);
// 创建压缩实例
$imageService = app(ImageService::class);
$tempPath = storage_path('app/temp/'.basename($imagePath));
// 创建目标目录
if (!file_exists(dirname($tempPath))) {
mkdir(dirname($tempPath), 0755, true);
}
// 多轮压缩策略
$currentSize = $originalSize;
$iterations = 0;
$currentQuality = (int)$this->option('quality');
$minQuality = (int)$this->option('min-quality');
$maxIterations = (int)$this->option('max-iterations');
while ($currentSize > $maxSizeBytes && $iterations < $maxIterations && $currentQuality >= $minQuality) {
// 使用服务类进行压缩
$optimizedContent = $this->recompressImage(
$filePath,
$currentQuality
);
file_put_contents($tempPath, $optimizedContent);
$newSize = filesize($tempPath);
// 如果新文件大小没有减小,则停止优化
if ($newSize >= $currentSize) {
$this->warn("无法进一步压缩: $imagePath (质量: $currentQuality)");
break;
}
$currentSize = $newSize;
$iterations++;
// 降低质量进行下一轮尝试
if ($currentSize > $maxSizeBytes) {
$currentQuality -= 5;
}
}
// 替换原始文件
if ($currentSize < $originalSize) {
rename($tempPath, $filePath);
$this->info("优化成功: $imagePath{$this->formatBytes($originalSize)} 压缩到 {$this->formatBytes($currentSize)}");
} else {
@unlink($tempPath);
$this->warn("压缩失败: $imagePath 无法达到目标大小");
}
$progressBar->advance();
$count++;
}
$progressBar->finish();
$this->info("\n{$count}/{$total} 个图片已优化完成");
}
/**
* 重新压缩图片
*/
protected function recompressImage($filePath, $quality)
{
// 读取原始图片
$image = imagecreatefromwebp($filePath);
// 捕捉错误
if (!$image) {
throw new \Exception("无法读取图片: $filePath");
}
// 捕捉输出
ob_start();
$success = imagewebp($image, null, $quality);
if (!$success) {
throw new \Exception("压缩失败: $filePath");
}
$compressedContent = ob_get_clean();
imagedestroy($image);
return $compressedContent;
}
/**
* 格式化字节大小
*/
protected function formatBytes($bytes)
{
if ($bytes < 1024) return $bytes . 'B';
if ($bytes < 1048576) return round($bytes / 1024, 1) . 'KB';
return round($bytes / 1048576, 1) . 'MB';
}
}

View File

@@ -0,0 +1,872 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>图片压缩工具</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary-color: #3498db;
--secondary-color: #2ecc71;
--warning-color: #e74c3c;
--dark-color: #2c3e50;
--light-color: #ecf0f1;
--gray-color: #bdc3c7;
--accent-color: #9b59b6;
--transition: all 0.3s ease;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
--card-radius: 10px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a2980, #26d0ce);
color: #333;
min-height: 100vh;
padding: 20px;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
header {
background: var(--dark-color);
color: white;
padding: 25px 30px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.logo {
display: flex;
align-items: center;
gap: 15px;
}
.logo i {
font-size: 2.2rem;
color: var(--secondary-color);
}
.logo h1 {
font-weight: 600;
font-size: 1.8rem;
}
.logo span {
color: var(--secondary-color);
}
main {
display: grid;
grid-template-columns: 300px 1fr;
min-height: 80vh;
}
.sidebar {
background: var(--dark-color);
color: var(--light-color);
padding: 25px;
overflow-y: auto;
}
.file-browser {
background: rgba(255, 255, 255, 0.1);
border-radius: var(--card-radius);
padding: 20px;
margin-bottom: 20px;
}
.directory-path {
font-size: 0.9rem;
color: var(--gray-color);
margin-bottom: 10px;
padding: 8px;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
word-break: break-all;
}
.file-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--card-radius);
margin-bottom: 15px;
}
.file-item {
padding: 10px 15px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: var(--transition);
}
.file-item:hover {
background: rgba(44, 62, 80, 0.8);
}
.file-item.selected {
background: rgba(52, 152, 219, 0.7);
}
.file-item i {
color: var(--secondary-color);
}
.file-item span {
flex: 1;
font-size: 0.9rem;
}
.file-size {
color: var(--gray-color);
font-size: 0.8rem;
}
.controls {
margin-top: 25px;
}
.control-group {
margin-bottom: 25px;
}
.control-group h3 {
margin-bottom: 12px;
color: var(--secondary-color);
font-size: 1.1rem;
}
.quality-slider {
width: 100%;
height: 10px;
-webkit-appearance: none;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
outline: none;
}
.quality-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
border: 2px solid white;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.quality-value {
text-align: center;
font-size: 1.1rem;
font-weight: bold;
margin-top: 5px;
color: white;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 25px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 50px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
transition: var(--transition);
width: 100%;
margin-top: 10px;
box-shadow: var(--shadow);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
.btn i {
font-size: 1rem;
}
.btn-compress {
background: var(--secondary-color);
}
.btn-compress-all {
background: var(--accent-color);
}
.content {
padding: 30px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
gap: 30px;
}
.preview-card {
background: white;
border-radius: var(--card-radius);
box-shadow: var(--shadow);
overflow: hidden;
display: flex;
flex-direction: column;
}
.card-header {
background: var(--dark-color);
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
}
.card-header h2 {
font-size: 1.2rem;
font-weight: 500;
}
.card-body {
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
min-height: 300px;
}
.preview-container {
width: 100%;
height: 250px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--gray-color);
border-radius: 8px;
overflow: hidden;
margin-bottom: 20px;
position: relative;
}
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.file-info {
display: flex;
width: 100%;
justify-content: space-between;
background: rgba(189, 195, 199, 0.2);
padding: 15px;
border-radius: 8px;
font-size: 0.9rem;
}
.info-item {
display: flex;
flex-direction: column;
align-items: center;
}
.info-label {
color: #7f8c8d;
font-size: 0.85rem;
margin-bottom: 5px;
}
.info-value {
font-weight: 600;
font-size: 1.1rem;
}
.before .info-value {
color: var(--dark-color);
}
.after .info-value {
color: var(--secondary-color);
}
.comparison {
display: flex;
width: 100%;
gap: 10px;
}
.savings {
font-weight: bold;
color: var(--secondary-color);
text-align: center;
padding: 15px;
font-size: 1.1rem;
background: rgba(46, 204, 113, 0.1);
border-radius: var(--card-radius);
width: 100%;
}
.no-image {
color: #7f8c8d;
font-size: 1.1rem;
text-align: center;
}
.no-image i {
font-size: 3rem;
margin-bottom: 15px;
display: block;
color: var(--gray-color);
}
.progress-container {
margin-top: 20px;
width: 100%;
}
.progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.progress-bar {
height: 12px;
background: #e0e0e0;
border-radius: 10px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--secondary-color);
width: 0%;
transition: width 0.5s ease;
}
.spinner {
width: 50px;
height: 50px;
border: 5px solid rgba(52, 152, 219, 0.2);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.completed {
display: none;
color: var(--secondary-color);
font-size: 1.3rem;
text-align: center;
}
.completed i {
font-size: 2rem;
margin-bottom: 15px;
}
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: var(--transition);
}
.overlay.active {
opacity: 1;
visibility: visible;
}
.overlay-content {
background: white;
border-radius: 15px;
width: 90%;
max-width: 600px;
padding: 30px;
text-align: center;
transform: translateY(-30px);
transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.overlay.active .overlay-content {
transform: translateY(0);
}
.overlay-title {
font-size: 1.8rem;
margin-bottom: 20px;
color: var(--dark-color);
}
.overlay-text {
font-size: 1.1rem;
margin-bottom: 30px;
line-height: 1.7;
}
.confirm-buttons {
display: flex;
gap: 15px;
justify-content: center;
}
.overlay-btn {
min-width: 120px;
padding: 12px 20px;
border-radius: 50px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
border: 2px solid transparent;
transition: var(--transition);
}
.btn-confirm {
background: var(--secondary-color);
color: white;
}
.btn-confirm:hover {
background: #27ae60;
}
.btn-cancel {
background: transparent;
color: var(--dark-color);
border-color: var(--gray-color);
}
.btn-cancel:hover {
background: #f5f5f5;
}
@media (max-width: 900px) {
main {
grid-template-columns: 1fr;
}
.sidebar {
padding: 20px 15px;
}
}
@media (max-width: 600px) {
.content {
grid-template-columns: 1fr;
}
header {
flex-direction: column;
text-align: center;
gap: 15px;
}
.logo {
justify-content: center;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="logo">
<i class="fas fa-compress-alt"></i>
<h1>图片<span>压缩</span>工具</h1>
</div>
<div>
<p>批量压缩并替换图片文件</p>
</div>
</header>
<main>
<aside class="sidebar">
<div class="file-browser">
<h3>选择目录</h3>
<div class="directory-path">
storage/app/public/images/compressed
</div>
<div class="file-list">
<!-- File items will be added dynamically -->
</div>
<button class="btn" id="refreshBtn">
<i class="fas fa-sync-alt"></i> 刷新文件列表
</button>
</div>
<div class="controls">
<div class="control-group">
<h3>压缩质量设置</h3>
<input type="range" min="40" max="95" value="75" class="quality-slider" id="qualitySlider">
<div class="quality-value" id="qualityValue">75%</div>
</div>
<div class="control-group">
<h3>操作</h3>
<button class="btn btn-compress" id="compressBtn">
<i class="fas fa-file-image"></i> 压缩当前图片
</button>
<button class="btn btn-compress-all" id="compressAllBtn">
<i class="fas fa-layer-group"></i> 批量压缩目录
</button>
</div>
</div>
</aside>
<div class="content">
<div class="preview-card">
<div class="card-header">
<h2>原始图片</h2>
</div>
<div class="card-body">
<div class="preview-container" id="originalPreview">
<div class="no-image">
<i class="fas fa-image"></i>
<p>请从左侧选择一张图片</p>
</div>
</div>
<div class="file-info before">
<div class="info-item">
<div class="info-label">文件大小</div>
<div class="info-value" id="originalSize">0 KB</div>
</div>
<div class="info-item">
<div class="info-label">分辨率</div>
<div class="info-value" id="originalDims">0×0</div>
</div>
</div>
</div>
</div>
<div class="preview-card">
<div class="card-header">
<h2>压缩后图片</h2>
</div>
<div class="card-body">
<div class="preview-container" id="compressedPreview">
<div class="no-image">
<i class="fas fa-compress-arrows-alt"></i>
<p>压缩后图片将显示在这里</p>
</div>
</div>
<div class="file-info after">
<div class="info-item">
<div class="info-label">文件大小</div>
<div class="info-value" id="compressedSize">0 KB</div>
</div>
<div class="info-item">
<div class="info-label">分辨率</div>
<div class="info-value" id="compressedDims">0×0</div>
</div>
</div>
<div class="savings" id="savingsText">
尺寸减少0%
</div>
</div>
</div>
<!-- Progress card for batch processing -->
<div class="preview-card">
<div class="card-header">
<h2>批量压缩进度</h2>
</div>
<div class="card-body" style="align-items: center;">
<div class="no-image" id="batchPlaceholder">
<i class="fas fa-layer-group"></i>
<p>批量压缩状态将显示在这里</p>
</div>
<div class="progress-container" id="progressContainer" style="display: none;">
<div class="progress-header">
<span>处理中...</span>
<span id="progressText">0/0</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
<div class="completed" id="completedMsg">
<i class="fas fa-check-circle"></i>
<p>压缩完成!所有文件已更新</p>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Confirmation overlay -->
<div class="overlay" id="confirmOverlay">
<div class="overlay-content">
<h2 class="overlay-title">确认批量操作</h2>
<p class="overlay-text">
您将压缩 <strong id="fileCount">0</strong> 个文件,覆盖其原始文件。<br>
此操作不可逆,请确保您已备份重要文件。
</p>
<div class="confirm-buttons">
<button class="overlay-btn btn-confirm" id="confirmBatch">开始压缩</button>
<button class="overlay-btn btn-cancel" id="cancelBatch">取消</button>
</div>
</div>
</div>
<script>
// Mock data for demonstration (in a real app this would come from server)
const directoryData = [
{ name: 'engagement1.jpg', size: '2.8MB', path: 'engagement1.jpg', type: 'image/jpeg' },
{ name: 'wedding_prep.png', size: '4.2MB', path: 'wedding_prep.png', type: 'image/png' },
{ name: 'ceremony_moment.jpg', size: '3.1MB', path: 'ceremony_moment.jpg', type: 'image/jpeg' },
{ name: 'family_group.jpg', size: '5.4MB', path: 'family_group.jpg', type: 'image/jpeg' },
{ name: 'reception_dance.jpg', size: '3.7MB', path: 'reception_dance.jpg', type: 'image/jpeg' },
{ name: 'bridal_portrait.jpg', size: '4.8MB', path: 'bridal_portrait.jpg', type: 'image/jpeg' },
{ name: 'groom_portrait.png', size: '3.9MB', path: 'groom_portrait.png', type: 'image/png' },
{ name: 'venue_overview.jpg', size: '6.2MB', path: 'venue_overview.jpg', type: 'image/jpeg' },
];
// DOM Elements
const fileList = document.querySelector('.file-list');
const qualitySlider = document.getElementById('qualitySlider');
const qualityValue = document.getElementById('qualityValue');
const refreshBtn = document.getElementById('refreshBtn');
const compressBtn = document.getElementById('compressBtn');
const compressAllBtn = document.getElementById('compressAllBtn');
const originalPreview = document.getElementById('originalPreview');
const compressedPreview = document.getElementById('compressedPreview');
const originalSize = document.getElementById('originalSize');
const compressedSize = document.getElementById('compressedSize');
const originalDims = document.getElementById('originalDims');
const compressedDims = document.getElementById('compressedDims');
const savingsText = document.getElementById('savingsText');
const batchPlaceholder = document.getElementById('batchPlaceholder');
const progressContainer = document.getElementById('progressContainer');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
const completedMsg = document.getElementById('completedMsg');
const confirmOverlay = document.getElementById('confirmOverlay');
const fileCount = document.getElementById('fileCount');
const confirmBatch = document.getElementById('confirmBatch');
const cancelBatch = document.getElementById('cancelBatch');
// Current state
let currentFile = null;
let compressedImage = null;
let currentQuality = 75;
// Initialize file browser
function initFileBrowser() {
fileList.innerHTML = '';
directoryData.forEach(file => {
const fileItem = document.createElement('div');
fileItem.className = 'file-item';
fileItem.dataset.path = file.path;
fileItem.dataset.size = file.size;
fileItem.dataset.type = file.type;
fileItem.innerHTML = `
<i class="fas fa-file-image"></i>
<span>${file.name}</span>
<span class="file-size">${file.size}</span>
`;
fileItem.addEventListener('click', () => selectFile(fileItem, file));
fileList.appendChild(fileItem);
});
}
// Select file handler
function selectFile(item, file) {
// Clear previous selection
document.querySelectorAll('.file-item').forEach(el => {
el.classList.remove('selected');
});
// Select current
item.classList.add('selected');
currentFile = file;
// Preview the image
previewImage(file);
}
// Preview image function
function previewImage(file) {
// For demonstration, we'll use placeholder images
const placeholderImage = 'https://picsum.photos/600/400';
// Clear previous previews
originalPreview.innerHTML = '';
compressedPreview.innerHTML = '<div class="no-image"><i class="fas fa-compress-arrows-alt"></i><p>压缩后图片将显示在这里</p></div>';
// Create image element
const img = document.createElement('img');
img.className = 'preview-image';
img.src = placeholderImage;
originalPreview.appendChild(img);
// Simulate file info
const sizeParts = file.size.match(/(\d+\.?\d*)(\w+)/);
if (sizeParts) {
const sizeNum = parseFloat(sizeParts[1]);
originalSize.textContent = file.size;
originalDims.textContent = '1920×1080'; // Fixed for demo
// Simulate compression for demo
setTimeout(() => {
simulateCompression(sizeNum, sizeParts[2]);
}, 1000);
}
}
// Simulate compression (in a real app, real compression would happen)
function simulateCompression(size, unit) {
// Calculate compression for demo
const compressionRatio = 1 - (currentQuality / 100);
const compressedSize = (size * compressionRatio * 0.9).toFixed(1);
const savings = Math.round((1 - (compressedSize / size)) * 100);
// Update compressed panel
compressedSize.textContent = compressedSize + unit;
compressedDims.textContent = '1920×1080';
savingsText.innerHTML = `尺寸减少:<strong>${savings}%</strong>`;
// Show compressed image (same for demo)
compressedPreview.innerHTML = '';
const img = document.createElement('img');
img.className = 'preview-image';
img.src = 'https://picsum.photos/600/400';
compressedPreview.appendChild(img);
}
// Compress current image
function compressImage() {
if (!currentFile) {
alert('请先选择一张图片');
return;
}
// In a real implementation, this would send the image to the server for processing
// Here we just simulate the compression
const sizeParts = currentFile.size.match(/(\d+\.?\d*)(\w+)/);
if (sizeParts) {
const sizeNum = parseFloat(sizeParts[1]);
simulateCompression(sizeNum, sizeParts[2]);
// Show success message
savingsText.innerHTML = `压缩成功!保存为原始文件`;
savingsText.style.backgroundColor = 'rgba(46, 204, 113, 0.15)';
setTimeout(() => {
savingsText.style.backgroundColor = 'rgba(46, 204, 113, 0.1)';
}, 3000);
}
}
// Show batch compression confirmation
function showBatchConfirm() {
fileCount.textContent = directoryData.length;
confirmOverlay.classList.add('active');
}
// Execute batch compression
function executeBatchCompression() {
confirmOverlay.classList.remove('active');
// Hide placeholder and show progress
batchPlaceholder.style.display = 'none';
progressContainer.style.display = 'block';
completedMsg.style.display = 'none';
const totalFiles = directoryData.length;
let processed = 0;
const interval = setInterval(() => {
processed++;
const progress = (processed / totalFiles) * 100;
progressFill.style.width = `${progress}%`;
progressText.textContent = `${processed}/${totalFiles}`;
if (processed >= totalFiles) {
clearInterval(interval);
// Show completion message
setTimeout(() => {
progressContainer.style.display = 'none';
completedMsg.style.display = 'block';
}, 1000);
}
}, 800);
}
// Event listeners
qualitySlider.addEventListener('input', () => {
currentQuality = parseInt(qualitySlider.value);
qualityValue.textContent = currentQuality + '%';
if (currentFile) {
const sizeParts = currentFile.size.match(/(\d+\.?\d*)(\w+)/);
if (sizeParts) {
const sizeNum = parseFloat(sizeParts[1]);
simulateCompression(sizeNum, sizeParts[2]);
}
}
});
refreshBtn.addEventListener('click', initFileBrowser);
compressBtn.addEventListener('click', compressImage);
compressAllBtn.addEventListener('click', showBatchConfirm);
confirmBatch.addEventListener('click', executeBatchCompression);
cancelBatch.addEventListener('click', () => {
confirmOverlay.classList.remove('active');
});
// Initialize the app
initFileBrowser();
</script>
</body>
</html>

View File

@@ -18,5 +18,7 @@ Route::get('/', function () {
'photo_album' => $photoAlbum,
]);
});
Route::get('/images/compress', function() {
return view('compress');
});
Route::post('/upload', [\App\Http\Controllers\ImageController::class, 'upload']);