<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">图片批量减暗工具 · 降低曝光</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: #111;
color: #eee;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
}
.card {
max-width: 960px;
width: 100%;
background: #1e1e1e;
border-radius: 28px;
padding: 24px 24px 32px;
box-shadow: 0 12px 30px rgba(0,0,0,0.8);
transition: all 0.15s;
border: 1px solid #333;
}
h1 {
font-size: 1.8rem;
font-weight: 500;
letter-spacing: -0.02em;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
h1 small {
font-size: 0.9rem;
font-weight: 400;
color: #aaa;
margin-left: 8px;
}
.sub {
color: #aaa;
margin-bottom: 24px;
border-left: 3px solid #f0b34b;
padding-left: 14px;
font-size: 0.95rem;
}
.control-panel {
background: #252525;
border-radius: 20px;
padding: 20px 20px 18px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 18px 24px;
margin-bottom: 24px;
border: 1px solid #3a3a3a;
}
.slider-group {
flex: 2;
min-width: 180px;
}
.slider-group label {
display: flex;
justify-content: space-between;
font-weight: 500;
font-size: 0.95rem;
letter-spacing: 0.3px;
color: #ccc;
margin-bottom: 6px;
}
.slider-group label span:last-child {
background: #2a2a2a;
padding: 0 12px;
border-radius: 40px;
border: 1px solid #444;
min-width: 60px;
text-align: center;
color: #f0b34b;
}
input[type=range] {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: #3a3a3a;
border-radius: 10px;
outline: none;
transition: background 0.2s;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: #f0b34b;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 8px rgba(240, 179, 75, 0.3);
border: 2px solid #1e1e1e;
}
input[type=range]::-moz-range-thumb {
width: 18px;
height: 18px;
background: #f0b34b;
border-radius: 50%;
border: none;
cursor: pointer;
}
.file-zone {
flex: 1.5;
min-width: 160px;
display: flex;
gap: 12px;
align-items: center;
justify-content: flex-end;
}
.file-zone label {
background: #2e2e2e;
padding: 10px 18px;
border-radius: 60px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #444;
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.file-zone label:hover {
background: #3d3d3d;
border-color: #f0b34b;
}
.file-zone label:active {
transform: scale(0.96);
}
#fileInput {
display: none;
}
.badge {
background: #2a2a2a;
border-radius: 60px;
padding: 6px 14px;
font-size: 0.85rem;
color: #b0b0b0;
border: 1px solid #3d3d3d;
white-space: nowrap;
}
.action-bar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 12px 16px;
}
.btn {
background: #2e2e2e;
border: 1px solid #444;
padding: 8px 18px;
border-radius: 60px;
font-weight: 500;
font-size: 0.9rem;
color: #ddd;
cursor: pointer;
transition: 0.15s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-primary {
background: #f0b34b;
border-color: #f0b34b;
color: #111;
font-weight: 600;
}
.btn-primary:hover {
background: #f5c56a;
border-color: #f5c56a;
}
.btn-primary:active {
transform: scale(0.96);
}
.btn:not(.btn-primary):hover {
background: #3d3d3d;
}
.btn:disabled {
opacity: 0.45;
pointer-events: none;
filter: grayscale(0.6);
}
.btn-danger {
border-color: #8a4a4a;
color: #e08383;
}
.btn-danger:hover {
background: #442a2a;
}
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
margin: 20px 0 16px;
min-height: 100px;
}
.gallery-item {
background: #262626;
border-radius: 16px;
overflow: hidden;
border: 1px solid #3a3a3a;
transition: all 0.1s;
position: relative;
aspect-ratio: 1 / 1;
display: flex;
flex-direction: column;
}
.gallery-item canvas, .gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
background: #1a1a1a;
}
.gallery-item .overlay-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.75));
padding: 16px 8px 6px 8px;
font-size: 0.7rem;
color: #eee;
display: flex;
justify-content: space-between;
pointer-events: none;
opacity: 0.85;
}
.gallery-item .overlay-info span {
background: rgba(0,0,0,0.5);
padding: 1px 8px;
border-radius: 30px;
backdrop-filter: blur(2px);
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 48px 16px;
color: #666;
font-size: 0.95rem;
border: 1px dashed #333;
border-radius: 24px;
background: #1a1a1a;
}
.empty-state svg {
opacity: 0.3;
margin-bottom: 12px;
}
.footer-info {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
border-top: 1px solid #2a2a2a;
padding-top: 18px;
font-size: 0.85rem;
color: #777;
}
.progress-text {
color: #b0b0b0;
}
@media (max-width: 600px) {
.card { padding: 16px; }
.control-panel { flex-direction: column; align-items: stretch; }
.file-zone { justify-content: center; }
.gallery { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; }
}
</style>
</head>
<body>
<div class="card">
<h1>
🌙 批量减暗
<small>曝光降低器</small>
</h1>
<div class="sub">拖入图片或点击选择,滑动滑块调整变暗程度,一键批量导出</div>
<div class="control-panel">
<div class="slider-group">
<label>
<span>⬇️ 降低曝光</span>
<span id="valueDisplay">-40%</span>
</label>
<input type="range" id="darknessSlider" min="0" max="85" value="40" step="1">
</div>
<div class="file-zone">
<label for="fileInput">
<span>📁 选择图片</span>
</label>
<span class="badge" id="fileCount">0 张</span>
</div>
<input type="file" id="fileInput" accept="image/png,image/jpeg,image/webp,image/bmp,image/gif,image/avif" multiple>
</div>
<div class="action-bar">
<div>
<button class="btn btn-primary" id="exportBtn" disabled>⬇ 批量导出 (ZIP)</button>
<button class="btn btn-danger" id="clearBtn">✕ 清空</button>
</div>
<span class="progress-text" id="statusMsg">就绪</span>
</div>
<!-- 图片网格 -->
<div class="gallery" id="galleryContainer">
<div class="empty-state" id="emptyState">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#555" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="3" />
<circle cx="8.5" cy="8.5" r="1.5" fill="#555" stroke="none"/>
<path d="M21 15l-5-5L5 21" stroke="#555" stroke-linecap="round"/>
</svg>
<p>拖放图片到这里,或点击「选择图片」</p>
<p style="font-size:0.8rem; margin-top:6px;">支持 JPG / PNG / WebP / BMP</p>
</div>
</div>
<div class="footer-info">
<span>🌑 每个图片独立处理 · 实时预览</span>
<span id="imageCountInfo">0 张已加载</span>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script>
(function() {
// DOM 元素
const gallery = document.getElementById('galleryContainer');
const emptyState = document.getElementById('emptyState');
const fileInput = document.getElementById('fileInput');
const fileCount = document.getElementById('fileCount');
const slider = document.getElementById('darknessSlider');
const valueDisplay = document.getElementById('valueDisplay');
const exportBtn = document.getElementById('exportBtn');
const clearBtn = document.getElementById('clearBtn');
const statusMsg = document.getElementById('statusMsg');
const imageCountInfo = document.getElementById('imageCountInfo');
// 状态管理
let imageDataList = []; // 存储 { id, file, originalImg, canvas, ctx, dataUrl }
let currentDarkness = 0.4; // 0-0.85
// ---- 核心功能 ----
// 更新所有预览 (重新绘制)
function renderAllPreviews(darkness) {
if (!imageDataList.length) return;
const progress = darkness; // 0-0.85
for (let item of imageDataList) {
applyDarknessToCanvas(item, progress);
}
}
// 对单个 item 应用减暗
function applyDarknessToCanvas(item, darkness) {
const canvas = item.canvas;
const ctx = item.ctx;
const img = item.originalImg;
// 设置画布尺寸
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
// 清空并绘制原图
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
// 应用减暗 (降低曝光/亮度)
if (darkness > 0.001) {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const factor = 1 - darkness; // 1为原始, 0.15 最暗 (因为darkness最大0.85)
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.min(255, Math.max(0, Math.round(data[i] * factor)));
data[i+1] = Math.min(255, Math.max(0, Math.round(data[i+1] * factor)));
data[i+2] = Math.min(255, Math.max(0, Math.round(data[i+2] * factor)));
// alpha 不变
}
ctx.putImageData(imageData, 0, 0);
}
// 更新预览图 (canvas 已经改好)
}
// 添加新图片
function addImages(files) {
if (!files || files.length === 0) return;
let loadedCount = 0;
const total = files.length;
for (let file of files) {
if (!file.type.startsWith('image/')) continue;
const reader = new FileReader();
reader.fileRef = file;
reader.onload = (e) => {
const img = new Image();
img.fileRef = file;
img.onload = () => {
// 创建 canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const item = {
id: Date.now() + Math.random() + '_' + (loadedCount),
file: file,
originalImg: img,
canvas: canvas,
ctx: ctx,
};
// 应用当前减暗值
applyDarknessToCanvas(item, currentDarkness);
imageDataList.push(item);
// 更新 UI
renderGalleryItem(item);
updateStats();
loadedCount++;
if (loadedCount === total) {
statusMsg.innerText = `✅ 已加载 ${total} 张`;
}
// 启用导出
exportBtn.disabled = (imageDataList.length === 0);
// 隐藏空状态
if (imageDataList.length > 0) {
emptyState.style.display = 'none';
}
};
img.onerror = () => {
loadedCount++;
if (loadedCount === total) statusMsg.innerText = '⚠️ 部分图片加载失败';
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
}
// 渲染单个图片卡片
function renderGalleryItem(item) {
// 移除空状态
if (emptyState.style.display !== 'none') {
emptyState.style.display = 'none';
}
const div = document.createElement('div');
div.className = 'gallery-item';
div.dataset.id = item.id;
// 用 canvas 显示
const canvasClone = document.createElement('canvas');
canvasClone.width = item.canvas.width;
canvasClone.height = item.canvas.height;
const cloneCtx = canvasClone.getContext('2d');
cloneCtx.drawImage(item.canvas, 0, 0);
// 使用 canvas 元素 (也可以用 img, 但 canvas 更方便更新)
// 注意 canvas 内已经应用减暗
const canvasNode = item.canvas;
canvasNode.style.width = '100%';
canvasNode.style.height = '100%';
canvasNode.style.objectFit = 'cover';
// 添加覆盖信息 (文件名、尺寸)
const overlay = document.createElement('div');
overlay.className = 'overlay-info';
const nameSpan = document.createElement('span');
let fileName = item.file.name;
if (fileName.length > 12) fileName = fileName.slice(0, 10) + '…';
nameSpan.innerText = fileName;
const dimSpan = document.createElement('span');
dimSpan.innerText = `${item.canvas.width}×${item.canvas.height}`;
overlay.appendChild(nameSpan);
overlay.appendChild(dimSpan);
// 清空并追加
div.appendChild(canvasNode);
div.appendChild(overlay);
gallery.appendChild(div);
}
// 刷新所有卡片 (全部重新绘制,并替换 canvas 节点)
function refreshGallery() {
// 保留所有 item,移除现有卡片 (除了 emptyState)
const items = gallery.querySelectorAll('.gallery-item');
items.forEach(el => el.remove());
if (imageDataList.length === 0) {
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
for (let item of imageDataList) {
// 重新应用当前减暗 (确保一致)
applyDarknessToCanvas(item, currentDarkness);
// 创建卡片
const div = document.createElement('div');
div.className = 'gallery-item';
div.dataset.id = item.id;
const canvasNode = item.canvas;
canvasNode.style.width = '100%';
canvasNode.style.height = '100%';
canvasNode.style.objectFit = 'cover';
const overlay = document.createElement('div');
overlay.className = 'overlay-info';
let fileName = item.file.name;
if (fileName.length > 12) fileName = fileName.slice(0, 10) + '…';
overlay.innerHTML = `<span>${fileName}</span><span>${item.canvas.width}×${item.canvas.height}</span>`;
div.appendChild(canvasNode);
div.appendChild(overlay);
gallery.appendChild(div);
}
}
// 更新统计信息
function updateStats() {
const len = imageDataList.length;
fileCount.innerText = len + ' 张';
imageCountInfo.innerText = len + ' 张已加载';
exportBtn.disabled = (len === 0);
}
// ---- 清空 ----
function clearAll() {
if (imageDataList.length === 0) return;
imageDataList = [];
// 移除所有 gallery-item
const items = gallery.querySelectorAll('.gallery-item');
items.forEach(el => el.remove());
emptyState.style.display = 'block';
updateStats();
exportBtn.disabled = true;
statusMsg.innerText = '🗑️ 已清空';
}
// ---- 批量导出 ZIP ----
async function exportAsZip() {
if (imageDataList.length === 0) {
statusMsg.innerText = '⚠️ 没有图片可导出';
return;
}
const zip = new JSZip();
const folder = zip.folder('减暗图片');
let processed = 0;
const total = imageDataList.length;
statusMsg.innerText = `⏳ 正在打包 0 / ${total} ...`;
for (let item of imageDataList) {
// 确保 canvas 是最新状态 (已经是)
const canvas = item.canvas;
// 获取blob (用原始文件名,但统一后缀为png,避免格式问题)
let baseName = item.file.name.replace(/\.[^/.]+$/, '') || 'image';
// 若文件名重复,加索引
const ext = '.png'; // 输出png保证透明度兼容
const fileName = `${baseName}_darkened${ext}`;
try {
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
if (blob) {
folder.file(fileName, blob);
} else {
// 降级:用dataURL
const dataUrl = canvas.toDataURL('image/png');
const blobFromUrl = await (await fetch(dataUrl)).blob();
folder.file(fileName, blobFromUrl);
}
} catch (err) {
console.warn('导出失败:', item.file.name, err);
// 尝试dataURL
const dataUrl = canvas.toDataURL('image/png');
const blobFromUrl = await (await fetch(dataUrl)).blob();
folder.file(fileName, blobFromUrl);
}
processed++;
statusMsg.innerText = `⏳ 正在打包 ${processed} / ${total} ...`;
// 给UI呼吸时间
await new Promise(r => setTimeout(r, 10));
}
try {
const content = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } });
const link = document.createElement('a');
link.href = URL.createObjectURL(content);
link.download = `减暗图片_${new Date().toISOString().slice(0,10)}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
statusMsg.innerText = `✅ 成功导出 ${total} 张 (ZIP)`;
} catch (err) {
statusMsg.innerText = '❌ 导出失败: ' + err.message;
console.error(err);
}
}
// ---- 事件绑定 ----
// 滑块
slider.addEventListener('input', function() {
const val = parseInt(this.value);
const displayVal = Math.round(val);
valueDisplay.innerText = `-${displayVal}%`;
currentDarkness = val / 100;
// 实时更新所有预览
renderAllPreviews(currentDarkness);
// 由于canvas直接渲染在卡片内,需要刷新显示 (canvas已更新,但可能缩放问题,直接替换)
// 更稳健:重新绘制卡片中的canvas (直接替换节点)
const items = gallery.querySelectorAll('.gallery-item');
if (imageDataList.length === 0) return;
// 利用已有canvas节点已经更新,但若是引用同一个canvas,无需替换
// 但有时canvas未触发重绘,强制通知:可以重新设置canvas的大小,但会闪烁,不如直接更新数据源后调用refreshGallery?
// 为了视觉实时,轻量重新绘制所有canvas(已经绘制到canvas上,但显示可能不变,所以使用refreshGallery会重建节点,但会闪烁)
// 更好的做法:只更新canvas内容,不重建节点,因为 canvas 引用不变,内容已改,显示应自动更新。
// 但有些浏览器可能不会重绘,我们通过一个微小的resize来触发? 或者用 requestAnimationFrame.
// 这里就用现有canvas内容,它已经改变,但如果要确保,可以调用 refreshGallery,但会有闪烁。
// 优化:不对所有卡片重建,只确保每个item的canvas被重新绘制 (已经绘制),requestAnimationFrame 强制刷新。
// 实际测试,直接更新有效,保留备用:使用 refreshGallery 防抖。
// 使用简单防抖,避免频繁重建。
if (window._refreshTimer) clearTimeout(window._refreshTimer);
window._refreshTimer = setTimeout(() => {
// 只更新 grid 视图,不重建节点,但canvas内容已经改变,所以理论上显示会更新。
// 为了保险,我们强制设置每个canvas的display style
// 但若用户滑得快,可能会出现闪烁,选择不做重建。
// 测试发现内容已更新,但为了更稳健,这里调用一个轻量重绘:遍历item,重新设置canvas尺寸触发重绘。
for (let item of imageDataList) {
const cvs = item.canvas;
const w = cvs.width;
const h = cvs.height;
cvs.width = w;
cvs.height = h;
// 重新绘制
const ctx = cvs.getContext('2d');
ctx.drawImage(item.originalImg, 0, 0);
if (currentDarkness > 0.001) {
const imageData = ctx.getImageData(0, 0, cvs.width, cvs.height);
const data = imageData.data;
const factor = 1 - currentDarkness;
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.min(255, Math.max(0, Math.round(data[i] * factor)));
data[i+1] = Math.min(255, Math.max(0, Math.round(data[i+1] * factor)));
data[i+2] = Math.min(255, Math.max(0, Math.round(data[i+2] * factor)));
}
ctx.putImageData(imageData, 0, 0);
}
}
}, 20);
});
// 文件输入
fileInput.addEventListener('change', function(e) {
if (this.files && this.files.length) {
addImages(this.files);
}
this.value = ''; // 允许重复选择相同文件
});
// 拖拽支持 (整个卡片区域)
const card = document.querySelector('.card');
card.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
card.style.borderColor = '#f0b34b';
});
card.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
card.style.borderColor = '#333';
});
card.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
card.style.borderColor = '#333';
const files = e.dataTransfer.files;
if (files && files.length) {
addImages(files);
}
});
// 导出
exportBtn.addEventListener('click', exportAsZip);
// 清空
clearBtn.addEventListener('click', clearAll);
// 初始化
updateStats();
valueDisplay.innerText = `-${slider.value}%`;
currentDarkness = parseInt(slider.value) / 100;
// 添加一些演示图片?不自动添加。
// 但可以展示空状态
emptyState.style.display = 'block';
// 如果用户点击空状态区域也可以触发文件选择 (提升体验)
emptyState.addEventListener('click', () => {
fileInput.click();
});
// 额外小优化:选择图片后清空状态消息
console.log('🖼️ 批量减暗工具已启动');
})();
</script>
</body>
</html>
功能与操作说明
您可以通过简单的拖拽或点击,批量调整图片的曝光度,其核心操作流程如下:

- 添加图片:点击“选择图片”按钮或直接将图片拖拽到工具区域,即可批量导入 JPG、PNG 等格式的图片。
- 调整曝光:拖动“降低曝光”滑块,可以实时预览所有图片变暗的效果,数值越大,图片越暗。
- 导出结果:调整满意后,点击“批量导出 (ZIP)”按钮,所有处理后的图片将会被打包成一个 ZIP 文件下载到您的电脑。
标签: 曝光调整
版权声明:除非特别标注,否则均为本站原创文章,转载时请以链接形式注明文章出处。