<!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 文件下载到您的电脑。
标签: 曝光调整
版权声明:NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播