图片批量减暗工具降低曝光

联启 电脑工具 15
<!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>

功能与操作说明

您可以通过简单的拖拽或点击,批量调整图片的曝光度,其核心操作流程如下:

图片批量减暗工具降低曝光-第1张图片-电脑手机工具软件下载 - 免费实用工具合集 | 联启科技

  1. 添加图片:点击“选择图片”按钮或直接将图片拖拽到工具区域,即可批量导入 JPG、PNG 等格式的图片。
  2. 调整曝光:拖动“降低曝光”滑块,可以实时预览所有图片变暗的效果,数值越大,图片越暗。
  3. 导出结果:调整满意后,点击“批量导出 (ZIP)”按钮,所有处理后的图片将会被打包成一个 ZIP 文件下载到您的电脑。

标签: 曝光调整

抱歉,评论功能暂时关闭!