update: 2026-03-28 20:59

This commit is contained in:
2026-03-28 20:59:52 +08:00
parent e21d58e603
commit 1c81d4e6ea
611 changed files with 27847 additions and 65061 deletions

View File

@@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>帧拆 - GIF & 图片拆帧工具</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/gif-frames@1.0.1?main=bundled"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&amp;display=swap');
body {
font-family: 'Noto Sans SC', system-ui, sans-serif;
}
.upload-zone {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.upload-zone:hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgb(16 185 129 / 0.1), 0 8px 10px -6px rgb(16 185 129 / 0.1);
}
.frame-card {
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
}
.frame-card:hover {
transform: scale(1.03);
box-shadow: 0 10px 15px -3px rgb(16 185 129 / 0.15);
}
.tail-container {
max-width: 1280px;
margin: 0 auto;
}
</style>
</head>
<body class="bg-gradient-to-br from-emerald-50 via-white to-teal-50 min-h-screen">
<div class="tail-container p-4 md:p-8">
<!-- 头部 -->
<header class="flex items-center justify-between mb-10">
<div class="flex items-center gap-3">
<div class="w-11 h-11 bg-emerald-500 rounded-2xl flex items-center justify-center text-white text-3xl shadow-lg">
🖼️
</div>
<div>
<h1 class="text-3xl font-bold text-emerald-900 tracking-tight">帧拆</h1>
<p class="text-emerald-600 text-sm -mt-1">GIF & 图片拆帧工具</p>
</div>
</div>
<div class="flex items-center gap-4 text-sm">
<div onclick="resetAll()"
class="flex items-center gap-2 px-5 py-2.5 bg-white hover:bg-emerald-50 border border-emerald-200 rounded-3xl text-emerald-700 font-medium cursor-pointer transition-colors">
<i class="fa-solid fa-rotate"></i>
<span>重新开始</span>
</div>
</div>
</header>
<div class="max-w-5xl mx-auto">
<!-- 上传区域 -->
<div id="upload-section"
class="upload-zone bg-white border-4 border-dashed border-emerald-200 rounded-3xl p-12 text-center cursor-pointer hover:border-emerald-400">
<div class="mx-auto w-20 h-20 bg-emerald-100 rounded-2xl flex items-center justify-center mb-6">
<i class="fa-solid fa-cloud-arrow-up text-5xl text-emerald-500"></i>
</div>
<h2 class="text-2xl font-semibold text-emerald-900 mb-2">拖拽图片或 GIF 到此处</h2>
<p class="text-emerald-600 mb-6">或点击上传 • 支持 JPG、PNG、GIF、WebP</p>
<button onclick="document.getElementById('file-input').click()"
class="px-8 py-4 bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-3xl shadow-md flex items-center gap-3 mx-auto transition-all">
<i class="fa-solid fa-upload"></i>
选择文件
</button>
<input type="file" id="file-input" accept="image/*" class="hidden" onchange="handleFileSelect(event)">
</div>
<!-- 主内容区 -->
<div id="main-section" class="hidden">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- 左侧:原始预览 -->
<div class="lg:col-span-5 bg-white rounded-3xl shadow-sm p-6 border border-emerald-100">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<i class="fa-solid fa-image text-emerald-500"></i>
<h3 class="font-semibold text-emerald-900">原始图片</h3>
</div>
<span id="file-info" class="text-xs text-emerald-500 font-mono"></span>
</div>
<div class="aspect-video bg-emerald-50 rounded-2xl overflow-hidden flex items-center justify-center border border-emerald-100">
<img id="original-preview"
class="max-h-full max-w-full object-contain"
alt="原始图片">
</div>
<div class="mt-6 text-center">
<button onclick="startExtract()"
id="extract-btn"
class="w-full py-4 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white font-semibold rounded-3xl shadow-lg flex items-center justify-center gap-3 transition-all">
<i class="fa-solid fa-scissors"></i>
开始拆解成普通图片帧
</button>
</div>
</div>
<!-- 右侧:帧结果 -->
<div class="lg:col-span-7 bg-white rounded-3xl shadow-sm p-6 border border-emerald-100">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<i class="fa-solid fa-layer-group text-emerald-500"></i>
<h3 class="font-semibold text-emerald-900">拆解结果</h3>
<span id="frame-count-badge"
class="px-3 py-1 bg-emerald-100 text-emerald-700 text-xs font-medium rounded-3xl">0 帧</span>
</div>
<button onclick="downloadAllZip()"
id="download-all-btn"
class="hidden px-6 py-2 bg-white border border-emerald-300 hover:border-emerald-400 text-emerald-700 rounded-3xl text-sm font-medium flex items-center gap-2">
<i class="fa-solid fa-download"></i>
下载全部 (ZIP)
</button>
</div>
<!-- 加载中 -->
<div id="loading" class="hidden py-20 flex flex-col items-center justify-center">
<div class="w-14 h-14 border-4 border-emerald-200 border-t-emerald-500 rounded-full animate-spin"></div>
<p class="mt-6 text-emerald-600 font-medium">正在拆解 GIF 帧...</p>
<p class="text-xs text-emerald-400 mt-1">大文件可能需要几秒</p>
</div>
<!-- 帧网格 -->
<div id="frames-grid"
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 min-h-[400px]">
<!-- JS 动态插入 -->
</div>
</div>
</div>
</div>
</div>
<!-- 底部说明 -->
<div class="mt-16 text-center text-emerald-400 text-xs flex items-center justify-center gap-6">
<div class="flex items-center gap-1">
<i class="fa-solid fa-shield-halved"></i>
<span>完全本地处理 • 隐私安全</span>
</div>
<div>适配电脑 & 手机</div>
<div class="flex items-center gap-1">
<i class="fa-solid fa-leaf"></i>
<span>简洁清新设计</span>
</div>
</div>
</div>
<script>
// Tailwind 配置(可选美化)
function initTailwind() {
// 已通过 CDN 处理
}
let currentFile = null;
let currentPreviewUrl = null;
let frameDataUrls = [];
const uploadSection = document.getElementById('upload-section');
const mainSection = document.getElementById('main-section');
const originalPreview = document.getElementById('original-preview');
const fileInfo = document.getElementById('file-info');
const framesGrid = document.getElementById('frames-grid');
const frameCountBadge = document.getElementById('frame-count-badge');
const downloadAllBtn = document.getElementById('download-all-btn');
const loadingEl = document.getElementById('loading');
const extractBtn = document.getElementById('extract-btn');
// 拖拽支持
function setupDragDrop() {
const zone = uploadSection;
zone.addEventListener('dragover', (e) => {
e.preventDefault();
zone.style.borderColor = '#10b981';
zone.style.backgroundColor = '#ecfdf5';
});
zone.addEventListener('dragleave', () => {
zone.style.borderColor = '#a7f3d0';
zone.style.backgroundColor = '';
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.style.borderColor = '#a7f3d0';
zone.style.backgroundColor = '';
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
});
}
function handleFileSelect(e) {
const file = e.target.files[0];
if (file) handleFile(file);
}
function handleFile(file) {
if (!file.type.startsWith('image/')) {
alert('请上传图片或 GIF 文件!');
return;
}
currentFile = file;
currentPreviewUrl = URL.createObjectURL(file);
// 显示主界面
uploadSection.classList.add('hidden');
mainSection.classList.remove('hidden');
// 预览原始图片
originalPreview.src = currentPreviewUrl;
// 文件信息
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
fileInfo.textContent = `${file.name}${sizeMB} MB`;
// 重置帧
resetFrames();
// 自动高亮按钮
extractBtn.classList.add('ring-4', 'ring-emerald-200');
setTimeout(() => {
extractBtn.classList.remove('ring-4', 'ring-emerald-200');
}, 1500);
}
function resetFrames() {
framesGrid.innerHTML = '';
frameDataUrls = [];
frameCountBadge.textContent = '0 帧';
downloadAllBtn.classList.add('hidden');
}
async function startExtract() {
if (!currentFile) return;
resetFrames();
loadingEl.classList.remove('hidden');
extractBtn.disabled = true;
extractBtn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> 拆解中...`;
try {
if (currentFile.type === 'image/gif') {
await extractGifFrames();
} else {
await extractSingleImage();
}
} catch (err) {
console.error(err);
alert('拆解失败,请尝试其他图片。\n错误' + err.message);
} finally {
loadingEl.classList.add('hidden');
extractBtn.disabled = false;
extractBtn.innerHTML = `<i class="fa-solid fa-scissors"></i> 重新拆解`;
}
}
// 提取单张普通图片
async function extractSingleImage() {
const img = new Image();
img.onload = function() {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const dataUrl = canvas.toDataURL('image/png', 1.0);
frameDataUrls = [{
index: 0,
url: dataUrl,
isSingle: true
}];
addFrameToUI(0, dataUrl, true);
updateUIAfterExtract(1);
};
img.src = currentPreviewUrl;
}
// 使用 gif-frames 提取 GIF 帧
async function extractGifFrames() {
const objectUrl = URL.createObjectURL(currentFile);
try {
const frames = await gifFrames({
url: objectUrl,
frames: 'all',
outputType: 'canvas',
cumulative: true // 合成完整帧,显示效果更好
});
frameDataUrls = [];
for (let i = 0; i < frames.length; i++) {
const frame = frames[i];
const canvas = frame.getImage();
const dataUrl = canvas.toDataURL('image/png', 1.0);
frameDataUrls.push({
index: i,
url: dataUrl
});
addFrameToUI(i, dataUrl, false);
}
updateUIAfterExtract(frames.length);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
function addFrameToUI(index, dataUrl, isSingle) {
const col = document.createElement('div');
col.className = `frame-card bg-white rounded-2xl overflow-hidden border border-emerald-100 shadow-sm`;
const num = (index + 1).toString().padStart(2, '0');
const label = isSingle ? '唯一帧' : `${num}`;
col.innerHTML = `
<div class="relative">
<img src="${dataUrl}"
class="w-full aspect-square object-contain bg-emerald-50 p-3">
<div class="absolute top-3 left-3 bg-white/90 backdrop-blur px-3 py-1 text-[10px] font-mono font-bold text-emerald-700 rounded-2xl shadow">
${label}
</div>
</div>
<div class="p-3 flex justify-between items-center">
<button onclick="downloadSingleFrame(${index})"
class="flex-1 py-2 text-xs font-medium text-emerald-700 hover:bg-emerald-50 rounded-2xl flex items-center justify-center gap-1">
<i class="fa-solid fa-download"></i>
下载 PNG
</button>
</div>
`;
framesGrid.appendChild(col);
}
function updateUIAfterExtract(count) {
frameCountBadge.textContent = `${count}`;
downloadAllBtn.classList.remove('hidden');
}
function downloadSingleFrame(index) {
const frame = frameDataUrls[index];
if (!frame) return;
const link = document.createElement('a');
link.href = frame.url;
link.download = `帧_${(index + 1).toString().padStart(3, '0')}.png`;
link.click();
}
async function downloadAllZip() {
if (frameDataUrls.length === 0) return;
const zip = new JSZip();
const folderName = currentFile.name.replace(/\.[^/.]+$/, "") || "frames";
for (let i = 0; i < frameDataUrls.length; i++) {
const frame = frameDataUrls[i];
const base64Data = frame.url.split(',')[1];
const fileName = `帧_${(i + 1).toString().padStart(3, '0')}.png`;
zip.file(fileName, base64Data, { base64: true });
}
const zipBlob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(zipBlob);
const a = document.createElement('a');
a.href = url;
a.download = `${folderName}_所有帧.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function resetAll() {
// 清理内存
if (currentPreviewUrl) URL.revokeObjectURL(currentPreviewUrl);
currentFile = null;
currentPreviewUrl = null;
frameDataUrls = [];
uploadSection.classList.remove('hidden');
mainSection.classList.add('hidden');
framesGrid.innerHTML = '';
frameCountBadge.textContent = '0 帧';
downloadAllBtn.classList.add('hidden');
fileInfo.textContent = '';
}
// 初始化
window.onload = function() {
setupDragDrop();
initTailwind();
// 示例提示(第一次加载)
setTimeout(() => {
console.log('%c帧拆工具已就绪 🎉\n完全本地运行无需网络除首次加载CDN', 'color:#10b981; font-size:13px');
}, 800);
};
</script>
</body>
</html>

View File

@@ -0,0 +1,265 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PNG 转 ICO</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 24px; line-height: 1.5; }
.row { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
.card { border: 1px solid #ddd; border-radius: 10px; padding: 16px; max-width: 860px; }
.sizes { display: flex; gap: 10px; flex-wrap: wrap; margin: 8px 0 12px; }
label { user-select: none; }
button { padding: 10px 14px; border-radius: 10px; border: 1px solid #ddd; cursor: pointer; }
button:disabled { opacity: .6; cursor: not-allowed; }
.hint { color: #555; font-size: 14px; }
canvas { border: 1px dashed #ccc; border-radius: 8px; }
.preview { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 12px; }
.preview-item { text-align: center; font-size: 12px; color: #555; }
</style>
</head>
<body>
<h2>PNG 转化 ICO</h2>
<div class="card">
<div class="row">
<input id="file" type="file" accept="image/png" />
<button id="convert" disabled>转换并下载 .ico</button>
</div>
<div class="hint" style="margin-top:10px;">
默认生成16/32/48/64/128/256。<br>
建议上传至少 256×256 的 PNG避免小图强行放大后发糊。
</div>
<div style="margin-top:14px;">
<div><strong>选择尺寸:</strong></div>
<div class="sizes" id="sizes"></div>
<div class="row">
<label>缩放方式:
<select id="fit">
<option value="contain" selected>Contain完整显示留透明边</option>
<option value="cover">Cover铺满裁切</option>
</select>
</label>
<label>
背景:
<select id="bg">
<option value="transparent" selected>透明</option>
<option value="white">白色</option>
<option value="black">黑色</option>
</select>
</label>
</div>
</div>
<div class="preview" id="preview"></div>
</div>
<script>
// 常用 icon 尺寸
const DEFAULT_SIZES = [16, 32, 48, 64, 128, 256];
const sizesWrap = document.getElementById('sizes');
const previewWrap = document.getElementById('preview');
const fileInput = document.getElementById('file');
const convertBtn = document.getElementById('convert');
// 生成尺寸复选框
for (const s of DEFAULT_SIZES) {
const id = `sz_${s}`;
const label = document.createElement('label');
label.innerHTML = `<input type="checkbox" id="${id}" value="${s}" checked> ${s}×${s}`;
sizesWrap.appendChild(label);
}
let currentImageBitmap = null;
fileInput.addEventListener('change', async () => {
previewWrap.innerHTML = '';
currentImageBitmap = null;
convertBtn.disabled = true;
const file = fileInput.files?.[0];
if (!file) return;
if (file.type !== 'image/png') {
alert('请上传 PNG 文件');
return;
}
const bitmap = await createImageBitmap(file);
currentImageBitmap = bitmap;
// 生成简单预览(只预览 64 和 128
for (const s of [64, 128]) {
const canvas = await renderToCanvas(bitmap, s, getFit(), getBg());
const item = document.createElement('div');
item.className = 'preview-item';
item.appendChild(canvas);
const cap = document.createElement('div');
cap.textContent = `${s}×${s}`;
item.appendChild(cap);
previewWrap.appendChild(item);
}
convertBtn.disabled = false;
});
convertBtn.addEventListener('click', async () => {
try {
if (!currentImageBitmap) return;
const sizes = getSelectedSizes();
if (sizes.length === 0) {
alert('请至少选择一个尺寸');
return;
}
convertBtn.disabled = true;
convertBtn.textContent = '转换中...';
// 1) 生成每个尺寸的 PNG ArrayBuffer
const pngBuffers = [];
for (const s of sizes) {
const canvas = await renderToCanvas(currentImageBitmap, s, getFit(), getBg());
const ab = await canvasToPngArrayBuffer(canvas);
pngBuffers.push({ size: s, buffer: ab });
}
// 2) 打包成 ICO每张图像使用 PNG payload
const icoBytes = buildIcoFromPngBuffers(pngBuffers);
// 3) 下载
const blob = new Blob([icoBytes], { type: 'image/x-icon' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'favicon.ico';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
console.error(e);
alert('转换失败:' + (e?.message || e));
} finally {
convertBtn.disabled = false;
convertBtn.textContent = '转换并下载 .ico';
}
});
function getSelectedSizes() {
const cbs = sizesWrap.querySelectorAll('input[type="checkbox"]');
const sizes = [];
cbs.forEach(cb => { if (cb.checked) sizes.push(parseInt(cb.value, 10)); });
// 从小到大排序(无硬性要求,但更直观)
sizes.sort((a,b) => a - b);
return sizes;
}
function getFit() {
return document.getElementById('fit').value;
}
function getBg() {
return document.getElementById('bg').value;
}
async function renderToCanvas(bitmap, size, fit = 'contain', bg = 'transparent') {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// 背景
if (bg !== 'transparent') {
ctx.fillStyle = bg;
ctx.fillRect(0, 0, size, size);
} else {
ctx.clearRect(0, 0, size, size);
}
const iw = bitmap.width, ih = bitmap.height;
// 计算绘制区域
let dw, dh, dx, dy;
const scaleContain = Math.min(size / iw, size / ih);
const scaleCover = Math.max(size / iw, size / ih);
const scale = (fit === 'cover') ? scaleCover : scaleContain;
dw = Math.round(iw * scale);
dh = Math.round(ih * scale);
dx = Math.round((size - dw) / 2);
dy = Math.round((size - dh) / 2);
// 更好的缩放质量
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bitmap, dx, dy, dw, dh);
// cover 模式下如果超出画布drawImage 已经裁切了
return canvas;
}
function canvasToPngArrayBuffer(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob(async (blob) => {
try {
if (!blob) return reject(new Error('canvas.toBlob() 返回空'));
const ab = await blob.arrayBuffer();
resolve(ab);
} catch (e) {
reject(e);
}
}, 'image/png');
});
}
// 按 ICO 格式打包ICONDIR(6) + ICONDIRENTRY(n*16) + PNG data...
// 目录项字段常见解释width/height(1B, 0 表示 256), colorCount(1B), reserved(1B),
// planes(2B), bitCount(2B), bytesInRes(4B), imageOffset(4B)
function buildIcoFromPngBuffers(pngs) {
const count = pngs.length;
const headerSize = 6;
const entrySize = 16;
const dirSize = headerSize + count * entrySize;
// 计算总大小
let totalSize = dirSize;
for (const p of pngs) totalSize += p.buffer.byteLength;
const out = new Uint8Array(totalSize);
const dv = new DataView(out.buffer);
// ICONDIR
dv.setUint16(0, 0, true); // reserved
dv.setUint16(2, 1, true); // type = 1 (icon)
dv.setUint16(4, count, true); // count
// ICONDIRENTRYs
let dataOffset = dirSize;
for (let i = 0; i < count; i++) {
const { size, buffer } = pngs[i];
const entryOff = headerSize + i * entrySize;
dv.setUint8(entryOff + 0, size >= 256 ? 0 : size); // width
dv.setUint8(entryOff + 1, size >= 256 ? 0 : size); // height
dv.setUint8(entryOff + 2, 0); // colorCount
dv.setUint8(entryOff + 3, 0); // reserved
dv.setUint16(entryOff + 4, 1, true); // planes (常用 1)
dv.setUint16(entryOff + 6, 32, true); // bitCount (常用 32)
dv.setUint32(entryOff + 8, buffer.byteLength, true); // bytesInRes
dv.setUint32(entryOff + 12, dataOffset, true); // imageOffset
// 拷贝 PNG 数据
out.set(new Uint8Array(buffer), dataOffset);
dataOffset += buffer.byteLength;
}
return out;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,426 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>图片像素化工具</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 30px;
max-width: 900px;
width: 100%;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
font-size: 28px;
}
.upload-area {
border: 3px dashed #667eea;
border-radius: 15px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 20px;
background: #f8f9ff;
}
.upload-area:hover {
border-color: #764ba2;
background: #f0f1ff;
}
.upload-area.dragover {
border-color: #764ba2;
background: #e8e9ff;
transform: scale(1.02);
}
.upload-icon {
font-size: 48px;
margin-bottom: 10px;
}
.upload-text {
color: #666;
font-size: 16px;
}
#fileInput {
display: none;
}
.controls {
margin: 20px 0;
display: none;
}
.control-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 600;
font-size: 14px;
}
.slider-container {
display: flex;
align-items: center;
gap: 15px;
}
input[type="range"] {
flex: 1;
height: 8px;
border-radius: 5px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
transition: all 0.2s ease;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: #764ba2;
transform: scale(1.2);
}
input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: none;
}
.value-display {
min-width: 40px;
text-align: center;
font-weight: 600;
color: #667eea;
}
.canvas-container {
display: none;
margin: 20px 0;
text-align: center;
}
canvas {
max-width: 100%;
height: auto;
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.buttons {
display: none;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
margin-top: 20px;
}
button {
padding: 12px 30px;
border: none;
border-radius: 25px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
color: white;
}
.btn-download {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.btn-download:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn-reset {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.btn-reset:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(245, 87, 108, 0.4);
}
@media (max-width: 768px) {
.container {
padding: 20px;
}
h1 {
font-size: 24px;
margin-bottom: 20px;
}
.upload-area {
padding: 30px 15px;
}
.upload-icon {
font-size: 36px;
}
.upload-text {
font-size: 14px;
}
button {
padding: 10px 20px;
font-size: 14px;
}
}
@media (max-width: 480px) {
body {
padding: 10px;
}
.container {
padding: 15px;
}
h1 {
font-size: 20px;
}
.buttons {
flex-direction: column;
}
button {
width: 100%;
}
}
</style>
</head>
<body>
<div class="container">
<h1>🎨 图片像素化工具</h1>
<div class="upload-area" id="uploadArea">
<div class="upload-icon">📁</div>
<div class="upload-text">点击或拖拽图片到这里</div>
<input type="file" id="fileInput" accept="image/*">
</div>
<div class="controls" id="controls">
<div class="control-group">
<label for="pixelSize">像素化程度</label>
<div class="slider-container">
<input type="range" id="pixelSize" min="1" max="50" value="10">
<span class="value-display" id="pixelValue">10</span>
</div>
</div>
</div>
<div class="canvas-container" id="canvasContainer">
<canvas id="canvas"></canvas>
</div>
<div class="buttons" id="buttons">
<button class="btn-download" id="downloadBtn">下载图片</button>
<button class="btn-reset" id="resetBtn">重新上传</button>
</div>
</div>
<script>
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const controls = document.getElementById('controls');
const canvasContainer = document.getElementById('canvasContainer');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const pixelSizeInput = document.getElementById('pixelSize');
const pixelValue = document.getElementById('pixelValue');
const buttons = document.getElementById('buttons');
const downloadBtn = document.getElementById('downloadBtn');
const resetBtn = document.getElementById('resetBtn');
let originalImage = null;
// 点击上传区域
uploadArea.addEventListener('click', () => {
fileInput.click();
});
// 文件选择
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file && file.type.startsWith('image/')) {
loadImage(file);
}
});
// 拖拽上传
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
loadImage(file);
}
});
// 加载图片
function loadImage(file) {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
originalImage = img;
initCanvas();
pixelateImage();
showControls();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
// 初始化画布
function initCanvas() {
const maxWidth = 800;
let width = originalImage.width;
let height = originalImage.height;
if (width > maxWidth) {
height = (maxWidth / width) * height;
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
}
// 像素化图片
function pixelateImage() {
const pixelSize = parseInt(pixelSizeInput.value);
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制原图
ctx.drawImage(originalImage, 0, 0, canvas.width, canvas.height);
// 获取图像数据
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// 像素化处理
for (let y = 0; y < canvas.height; y += pixelSize) {
for (let x = 0; x < canvas.width; x += pixelSize) {
// 计算区块的平均颜色
let r = 0, g = 0, b = 0, a = 0, count = 0;
for (let dy = 0; dy < pixelSize && y + dy < canvas.height; dy++) {
for (let dx = 0; dx < pixelSize && x + dx < canvas.width; dx++) {
const index = ((y + dy) * canvas.width + (x + dx)) * 4;
r += data[index];
g += data[index + 1];
b += data[index + 2];
a += data[index + 3];
count++;
}
}
// 计算平均值
r = Math.floor(r / count);
g = Math.floor(g / count);
b = Math.floor(b / count);
a = Math.floor(a / count);
// 填充像素块
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a / 255})`;
ctx.fillRect(x, y, pixelSize, pixelSize);
}
}
}
// 显示控制面板
function showControls() {
controls.style.display = 'block';
canvasContainer.style.display = 'block';
buttons.style.display = 'flex';
}
// 像素大小改变
pixelSizeInput.addEventListener('input', (e) => {
pixelValue.textContent = e.target.value;
pixelateImage();
});
// 下载图片
downloadBtn.addEventListener('click', () => {
const link = document.createElement('a');
link.download = `pixelated-image-${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
});
// 重置
resetBtn.addEventListener('click', () => {
originalImage = null;
fileInput.value = '';
controls.style.display = 'none';
canvasContainer.style.display = 'none';
buttons.style.display = 'none';
pixelSizeInput.value = 10;
pixelValue.textContent = '10';
});
</script>
</body>
</html>

View File

@@ -0,0 +1,380 @@
<!DOCTYPE html><html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>图片圆角处理</title>
<style>
:root{
--bg-from:#eaf8e4; /* 淡绿色 */
--bg-to:#f5ffd8; /* 淡黄绿色 */
--card:#ffffffcc; /* 卡片半透明 */
--accent:#78c67e; /* 绿色主色 */
--accent-2:#99d78c; /* 次要 */
--text:#234; /* 深色文字 */
--muted:#5b6b63; /* 次级文字 */
--shadow:0 8px 28px rgba(34, 102, 60, 0.15);
--radius:18px;
}html, body {
height: 100%;
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Noto Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft Yahei", sans-serif;
color: var(--text);
background: linear-gradient(160deg, var(--bg-from), var(--bg-to));
}
.wrap {
min-height: 100%;
display: grid;
place-items: start center;
padding: 18px 14px 28px;
}
.card {
width: 100%;
max-width: 520px; /* 适配手机竖屏 */
background: var(--card);
backdrop-filter: blur(8px);
border-radius: 22px;
box-shadow: var(--shadow);
padding: 16px;
}
header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
header .dot {
width: 10px; height: 10px; border-radius: 999px; background: var(--accent);
box-shadow: 0 0 0 6px rgba(120,198,126,0.15);
}
h1 { font-size: 18px; margin: 0; font-weight: 700; }
p.sub { margin: 4px 0 10px; color: var(--muted); font-size: 13px; }
.uploader {
border: 1.5px dashed #a9d6ab;
border-radius: var(--radius);
background: #ffffffb3;
padding: 12px;
display: flex; flex-direction: column; gap: 10px; align-items: center; justify-content: center;
}
.uploader input[type=file] {
width: 100%;
border: none; outline: none; background: transparent;
}
.controls { margin-top: 12px; display: grid; gap: 12px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.slider {
background: #ffffff;
border: 1px solid #e3f2e1;
border-radius: var(--radius);
padding: 10px;
box-shadow: 0 4px 14px rgba(0,0,0,0.05);
}
.slider label { display:flex; align-items:center; justify-content:space-between; gap:6px; font-size: 14px; font-weight:600; }
.slider output { font-variant-numeric: tabular-nums; color: var(--accent); min-width: 3ch; text-align: right; }
input[type=range] {
-webkit-appearance: none; appearance: none; width: 100%; height: 32px; background: transparent;
}
input[type=range]::-webkit-slider-runnable-track { height: 8px; background: linear-gradient(90deg, #cfeecf, #e9ffd4); border-radius: 999px; }
input[type=range]::-moz-range-track { height: 8px; background: linear-gradient(90deg, #cfeecf, #e9ffd4); border-radius: 999px; }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none; width: 22px; height: 22px; border-radius: 50%; background: var(--accent);
border: 2px solid #fff; margin-top: -7px; box-shadow: 0 2px 8px rgba(37,106,63,.3);
}
input[type=range]::-moz-range-thumb { width: 22px; height: 22px; border-radius: 50%; background: var(--accent); border: 2px solid #fff; box-shadow: 0 2px 8px rgba(37,106,63,.3); }
.row { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.row .chk { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--muted); }
.preview {
margin-top: 12px;
background: #ffffff;
border: 1px solid #e3f2e1;
border-radius: 24px;
overflow: hidden;
position: relative;
}
canvas { width: 100%; height: auto; display: block; background: repeating-conic-gradient(from 45deg, #f8fff1 0 10px, #f1ffe4 10px 20px); }
.actions { display:flex; gap:10px; margin-top: 12px; }
button, .btn {
appearance: none; border: none; cursor: pointer; font-weight: 700; letter-spacing: .2px; transition: transform .05s ease, box-shadow .2s ease, background .2s ease;
border-radius: 14px; padding: 12px 14px; box-shadow: 0 6px 18px rgba(120,198,126,.25);
}
.btn-primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color: #fff; }
.btn-ghost { background: #ffffffb5; color: #2c4432; border: 1px solid #d9efda; }
button:active { transform: translateY(1px); }
footer { text-align:center; color: #6a7; font-size: 12px; margin-top: 10px; }
.hidden { display:none; }
</style>
</head>
<body>
<div class="wrap">
<div class="card" role="region" aria-label="图片圆角处理工具">
<header>
<div class="dot" aria-hidden="true"></div>
<h1>图片圆角处理(四角独立,最高至圆形)</h1>
</header>
<p class="sub">上传图片 → 调节四个角的圆角强度0100%)→ 预览并下载透明圆角 PNG。已针对手机竖屏优化。</p><div class="uploader" aria-label="上传图片">
<input id="file" type="file" accept="image/*" />
<small style="color:var(--muted);">支持 JPG / PNG / WebP 等常见格式</small>
</div>
<div class="controls">
<div class="row">
<label class="chk"><input type="checkbox" id="linkAll" checked /> 联动四角</label>
<button id="resetBtn" class="btn btn-ghost" type="button">重置</button>
</div>
<div class="grid-2">
<div class="slider">
<label>左上角 <output id="o_tl">20%</output></label>
<input id="r_tl" type="range" min="0" max="100" step="1" value="20" />
</div>
<div class="slider">
<label>右上角 <output id="o_tr">20%</output></label>
<input id="r_tr" type="range" min="0" max="100" step="1" value="20" />
</div>
<div class="slider">
<label>右下角 <output id="o_br">20%</output></label>
<input id="r_br" type="range" min="0" max="100" step="1" value="20" />
</div>
<div class="slider">
<label>左下角 <output id="o_bl">20%</output></label>
<input id="r_bl" type="range" min="0" max="100" step="1" value="20" />
</div>
</div>
</div>
<div class="preview" aria-live="polite">
<canvas id="previewCanvas" aria-label="预览画布"></canvas>
</div>
<div class="actions">
<button id="downloadBtn" class="btn btn-primary" type="button" disabled>下载处理后的 PNG</button>
<button id="fitBtn" class="btn btn-ghost" type="button" disabled>适配预览尺寸</button>
</div>
<footer>小贴士:将四个角都拉到 <b>100%</b>,在方形图片上会得到完全圆形效果。</footer>
</div>
</div> <!-- 脚本:处理圆角、预览与下载 --> <script>
const fileInput = document.getElementById('file');
const linkAll = document.getElementById('linkAll');
const previewCanvas = document.getElementById('previewCanvas');
const downloadBtn = document.getElementById('downloadBtn');
const fitBtn = document.getElementById('fitBtn');
const resetBtn = document.getElementById('resetBtn');
const sliders = {
tl: document.getElementById('r_tl'),
tr: document.getElementById('r_tr'),
br: document.getElementById('r_br'),
bl: document.getElementById('r_bl'),
};
const outputs = {
tl: document.getElementById('o_tl'),
tr: document.getElementById('o_tr'),
br: document.getElementById('o_br'),
bl: document.getElementById('o_bl'),
};
// 工作画布(按原图尺寸绘制,导出用)
const workCanvas = document.createElement('canvas');
const workCtx = workCanvas.getContext('2d');
const prevCtx = previewCanvas.getContext('2d');
let img = new Image();
let imageLoaded = false;
const state = {
percent: { tl: 20, tr: 20, br: 20, bl: 20 },
fitToPreview: false,
};
function clamp(v, min, max){ return Math.max(min, Math.min(max, v)); }
function updateOutputs(){
for(const k of ['tl','tr','br','bl']) outputs[k].textContent = state.percent[k] + '%';
}
function setAllPercents(v){ for(const k of ['tl','tr','br','bl']) state.percent[k] = v; updateSliders(); }
function updateSliders(){ for(const k in sliders) sliders[k].value = state.percent[k]; updateOutputs(); render(); }
// 根据百分比换算到像素半径(以较短边的一半为 100%
function percentToRadiusPx(p){
const base = Math.min(img.naturalWidth, img.naturalHeight) / 2; // 100% 对应的像素半径
return (clamp(p,0,100) / 100) * base;
}
// 绘制带四角独立圆角的路径
function roundedRectPath(ctx, x, y, w, h, r){
// 约束:每个角半径不能超过对应边长度的一半
const rTL = clamp(r.tl, 0, Math.min(w, h) / 2);
const rTR = clamp(r.tr, 0, Math.min(w, h) / 2);
const rBR = clamp(r.br, 0, Math.min(w, h) / 2);
const rBL = clamp(r.bl, 0, Math.min(w, h) / 2);
ctx.beginPath();
ctx.moveTo(x + rTL, y);
ctx.lineTo(x + w - rTR, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + rTR);
ctx.lineTo(x + w, y + h - rBR);
ctx.quadraticCurveTo(x + w, y + h, x + w - rBR, y + h);
ctx.lineTo(x + rBL, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - rBL);
ctx.lineTo(x, y + rTL);
ctx.quadraticCurveTo(x, y, x + rTL, y);
ctx.closePath();
}
// 渲染到工作画布(原尺寸)
function renderWork(){
if(!imageLoaded) return;
workCanvas.width = img.naturalWidth;
workCanvas.height = img.naturalHeight;
workCtx.clearRect(0,0,workCanvas.width, workCanvas.height);
workCtx.save();
const r = {
tl: percentToRadiusPx(state.percent.tl),
tr: percentToRadiusPx(state.percent.tr),
br: percentToRadiusPx(state.percent.br),
bl: percentToRadiusPx(state.percent.bl),
};
roundedRectPath(workCtx, 0, 0, workCanvas.width, workCanvas.height, r);
workCtx.clip();
workCtx.drawImage(img, 0, 0, workCanvas.width, workCanvas.height);
workCtx.restore();
}
// 渲染到预览画布(自适应容器宽度,保持清晰度)
function renderPreview(){
const container = previewCanvas.parentElement.getBoundingClientRect();
const targetW = Math.min(container.width, 1000);
const scale = targetW / workCanvas.width;
const dpr = window.devicePixelRatio || 1;
const canvasW = Math.round(targetW * dpr);
const canvasH = Math.round(workCanvas.height * scale * dpr);
previewCanvas.width = canvasW;
previewCanvas.height = canvasH;
previewCanvas.style.height = Math.round(canvasH / dpr) + 'px';
previewCanvas.style.width = Math.round(canvasW / dpr) + 'px';
prevCtx.clearRect(0,0,canvasW,canvasH);
prevCtx.imageSmoothingEnabled = true;
prevCtx.drawImage(workCanvas, 0, 0, canvasW, canvasH);
}
function render(){
if(!imageLoaded) return;
renderWork();
renderPreview();
}
// 事件:上传图片
fileInput.addEventListener('change', (e)=>{
const file = e.target.files && e.target.files[0];
if(!file) return;
const url = URL.createObjectURL(file);
const temp = new Image();
temp.onload = ()=>{
img = temp;
imageLoaded = true;
render();
downloadBtn.disabled = false;
fitBtn.disabled = false;
};
temp.onerror = ()=>{
alert('图片加载失败,请更换文件重试');
imageLoaded = false;
downloadBtn.disabled = true;
fitBtn.disabled = true;
};
temp.src = url;
});
// 事件:滑块变更
for(const key of Object.keys(sliders)){
sliders[key].addEventListener('input', (e)=>{
const val = parseInt(e.target.value, 10) || 0;
if(linkAll.checked){
setAllPercents(val);
}else{
state.percent[key] = val;
updateOutputs();
render();
}
});
}
// 重置
resetBtn.addEventListener('click', ()=>{
linkAll.checked = true;
setAllPercents(20);
});
// 适配预览尺寸(导出较小尺寸,便于快速分享)
fitBtn.addEventListener('click', ()=>{
if(!imageLoaded) return;
const container = previewCanvas.parentElement.getBoundingClientRect();
const targetW = Math.min(container.width, 1080); // 限制到 1080 宽
const scale = targetW / img.naturalWidth;
const targetH = Math.round(img.naturalHeight * scale);
// 临时缩放导出画布
const temp = document.createElement('canvas');
temp.width = Math.round(targetW);
temp.height = Math.round(targetH);
const tctx = temp.getContext('2d');
// 先把当前工作画布绘好
renderWork();
tctx.drawImage(workCanvas, 0, 0, temp.width, temp.height);
const a = document.createElement('a');
a.href = temp.toDataURL('image/png');
a.download = 'rounded-image-fit.png';
a.click();
});
// 下载原始尺寸 PNG
downloadBtn.addEventListener('click', ()=>{
if(!imageLoaded) return;
renderWork();
const a = document.createElement('a');
a.href = workCanvas.toDataURL('image/png');
a.download = 'rounded-image.png';
a.click();
});
// 初始输出数字
updateOutputs();
// 自适应:窗口尺寸变动时重绘预览
window.addEventListener('resize', ()=>{ if(imageLoaded) renderPreview(); });
// 支持 PWA 风:阻止 iOS 双击缩放(改善滑块体验)
let lastTouch = 0;
document.addEventListener('touchend', (e)=>{
const now = Date.now();
if(now - lastTouch <= 300){ e.preventDefault(); }
lastTouch = now;
}, {passive:false});
</script></body>
</html>

View File

@@ -0,0 +1,327 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>图片转化 base64 编码</title>
<meta name="description" content="将图片快速转换为 Base64 / Data URL支持一键复制与清空适配手机竖屏。" />
<style>
:root{
--bg1:#dff7d6; /* 淡绿色 */
--bg2:#eef8c9; /* 淡黄绿色 */
--card:#ffffffcc;
--text:#0f2f1a;
--muted:#3d5f46;
--accent:#6ebf75;
--accent-2:#a6d98d;
--danger:#b55252;
--radius:18px;
--shadow:0 10px 24px rgba(0,0,0,.08), 0 2px 8px rgba(0,0,0,.04);
}
html,body{height:100%;}
body{
margin:0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
color:var(--text);
background: linear-gradient(135deg,var(--bg1),var(--bg2));
-webkit-font-smoothing:antialiased;
-moz-osx-font-smoothing:grayscale;
}
.container{
max-width: 860px;
padding: 16px clamp(14px,4vw,28px) 28px;
margin: 0 auto;
display:flex;
flex-direction:column;
gap:14px;
}
header{
text-align:center;
padding-top: 8px;
padding-bottom: 6px;
}
h1{
margin:0 0 6px;
font-size: clamp(20px, 5.5vw, 32px);
letter-spacing: .5px;
font-weight: 800;
background: linear-gradient(90deg, #3a7f43, #79c26f);
-webkit-background-clip: text;
background-clip:text;
color: transparent;
}
.subtitle{
font-size: clamp(12px, 3.6vw, 14px);
color: color-mix(in oklab, var(--muted) 80%, white);
}
.card{
background: var(--card);
border: 1px solid rgba(99, 135, 102, .15);
backdrop-filter: blur(6px);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 14px;
}
.uploader{
display:grid;
gap:12px;
}
.dropzone{
position:relative;
border:2px dashed rgba(59, 120, 74, .35);
border-radius: calc(var(--radius) - 6px);
padding: 18px;
background: linear-gradient(180deg, rgba(255,255,255,.85), rgba(255,255,255,.65));
transition: .2s ease;
cursor: pointer;
display:flex;
align-items:center;
gap:14px;
}
.dropzone:hover{ border-color: rgba(59,120,74,.6); }
.dropzone.dragover{ box-shadow: inset 0 0 0 3px rgba(110,191,117,.35); background: rgba(255,255,255,.9); }
.drop-icon{ width:40px; height:40px; flex: 0 0 40px; }
.dz-text{ display:flex; flex-direction:column; gap:4px; }
.dz-title{ font-weight:700; font-size: 15px; }
.dz-sub{ font-size:12px; color: color-mix(in oklab, var(--muted) 75%, white); }input[type="file"]{
position:absolute; inset:0; opacity:0; cursor:pointer; width:100%; height:100%;
}
.preview{
display:grid; grid-template-columns: 1fr; gap:10px; align-items:start;
}
.preview img{
width:100%; height:auto; max-height: 55vh; object-fit: contain;
border-radius: 12px;
background: #f8fff2;
border:1px solid rgba(59, 120, 74, .18);
}
.controls{
display:flex; gap:10px; flex-wrap:wrap; align-items:center; justify-content:space-between;
}
.left-controls, .right-controls{ display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.btn{
appearance:none; border:0; border-radius: 999px;
padding: 10px 14px; font-weight: 700; letter-spacing:.2px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
color:#05370f; box-shadow: var(--shadow); cursor:pointer; transition:.15s ease; display:flex; gap:8px; align-items:center;
}
.btn:active{ transform: translateY(1px); }
.btn.secondary{ background: #ffffff; color:#2a5532; border:1px solid rgba(59,120,74,.2); }
.btn.danger{ background: #ffecec; color:#7a2222; border:1px solid rgba(181,82,82,.35); }
.format{
display:flex; gap:8px; align-items:center; background:#ffffff; border:1px solid rgba(59,120,74,.15);
padding:6px 10px; border-radius:999px; box-shadow: var(--shadow);
font-size: 13px;
}
.format label{ display:flex; gap:6px; align-items:center; padding:6px 8px; border-radius: 999px; cursor:pointer; }
.format input{ accent-color:#6ebf75; }
.info{
font-size:12px; color: color-mix(in oklab, var(--muted) 70%, white);
display:flex; gap:10px; flex-wrap:wrap; align-items:center; line-height:1.4;
}
.output card{
display:block;
}
textarea{
width:100%; min-height: 36vh; resize: vertical; padding:12px 12px; line-height:1.35; border-radius: 12px;
border:1px solid rgba(59,120,74,.2); background: #fbfff6; outline: none; box-shadow: inset 0 2px 4px rgba(0,0,0,.03);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 13px;
}
footer{ text-align:center; font-size:12px; color: color-mix(in oklab, var(--muted) 64%, white); padding: 10px 0 0; }
/* 小屏优化(手机竖屏) */
@media (max-width: 480px){
.controls{ gap:8px; }
.btn{ padding: 10px 12px; }
.format{ width:100%; justify-content:center; }
.left-controls{ width:100%; justify-content:center; }
.right-controls{ width:100%; justify-content:center; }
}
/* toast */
.toast{ position: fixed; z-index: 50; left: 50%; bottom: 24px; transform: translateX(-50%) translateY(16px);
background: #103e1b; color: #e9ffe9; padding: 10px 14px; border-radius: 999px; opacity:0; pointer-events:none;
transition: .25s ease; box-shadow: var(--shadow); font-size: 13px; }
.toast.show{ opacity:1; transform: translateX(-50%) translateY(0); }
</style>
</head>
<body>
<div class="container">
<header>
<h1>图片转化 base64 编码</h1>
<div class="subtitle">上传或拖拽图片,立即生成 Base64 / Data URL支持一键复制与清空。已针对手机竖屏优化。</div>
</header><section class="card uploader">
<div class="dropzone" id="dropZone" tabindex="0" aria-label="点击或拖拽图片到此处上传">
<svg class="drop-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 16v-8M8 8l4-4 4 4" stroke="#568f5b" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="3" y="12" width="18" height="8" rx="3" stroke="#85c076" stroke-width="1.4" />
</svg>
<div class="dz-text">
<div class="dz-title">点击选择图片或直接拖拽到这里</div>
<div class="dz-sub">支持 PNG / JPG / GIF / WebP / SVG 等常见格式</div>
</div>
<input id="fileInput" type="file" accept="image/*" />
</div>
<div class="preview" id="previewWrap" hidden>
<img id="preview" alt="图片预览" />
<div class="info" id="info"></div>
<div class="controls">
<div class="left-controls format" role="radiogroup" aria-label="输出格式">
<label><input type="radio" name="format" value="dataurl" checked> Data URL含前缀</label>
<label><input type="radio" name="format" value="base64"> 仅 Base64不含前缀</label>
</div>
<div class="right-controls">
<button class="btn" id="copyBtn" type="button" title="复制到剪贴板">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M9 9h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2Z" stroke="#234b2d" stroke-width="1.6"/><path d="M7 15H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" stroke="#234b2d" stroke-width="1.6"/></svg>
复制
</button>
<button class="btn danger" id="clearBtn" type="button" title="清空当前内容">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 7h16M9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2M6 7l1.2 12.1A2 2 0 0 0 9.2 21h5.6a2 2 0 0 0 2-1.9L18 7" stroke="#8a3737" stroke-width="1.6" stroke-linecap="round"/></svg>
清空
</button>
</div>
</div>
<div class="output card">
<textarea id="output" placeholder="这里将显示转换后的内容……" readonly></textarea>
</div>
</div>
</section>
<footer>本工具在浏览器本地完成转换,不会上传图片或保存数据。</footer>
</div> <div class="toast" id="toast" role="status" aria-live="polite"></div> <script>
const fileInput = document.getElementById('fileInput');
const dropZone = document.getElementById('dropZone');
const previewWrap = document.getElementById('previewWrap');
const previewImg = document.getElementById('preview');
const infoEl = document.getElementById('info');
const output = document.getElementById('output');
const copyBtn = document.getElementById('copyBtn');
const clearBtn = document.getElementById('clearBtn');
const formatRadios = document.querySelectorAll('input[name="format"]');
const toast = document.getElementById('toast');
let originalDataUrl = '';
function showToast(text){
toast.textContent = text;
toast.classList.add('show');
setTimeout(()=> toast.classList.remove('show'), 1800);
}
function humanSize(bytes){
if(bytes < 1024) return bytes + ' B';
const units = ['KB','MB','GB'];
let i = -1; do { bytes = bytes / 1024; i++; } while(bytes >= 1024 && i < units.length-1);
return bytes.toFixed(bytes < 10 ? 2 : 1) + ' ' + units[i];
}
function setOutputByFormat(){
if(!originalDataUrl) return;
const base64 = originalDataUrl.split(',')[1] || '';
const format = document.querySelector('input[name="format"]:checked')?.value || 'dataurl';
output.value = format === 'base64' ? base64 : originalDataUrl;
}
function updateInfo(file){
const base64Len = (originalDataUrl.split(',')[1] || '').length;
const approxBytes = Math.floor(base64Len * 3/4); // 估算
const tip = base64Len > 3_000_000 ? '(较大,复制可能稍慢)' : '';
infoEl.innerHTML = `
<span><strong>文件:</strong>${file.name}</span>
<span><strong>类型:</strong>${file.type || '未知'}</span>
<span><strong>原大小:</strong>${humanSize(file.size)}</span>
<span><strong>Base64 长度:</strong>${base64Len.toLocaleString()} 字符 ≈ ${humanSize(approxBytes)}</span>
<span>${tip}</span>
`;
}
function handleFile(file){
if(!file) return;
if(!file.type.startsWith('image/')){
showToast('请选择图片文件');
return;
}
const reader = new FileReader();
reader.onload = (e)=>{
originalDataUrl = String(e.target.result || '');
previewImg.src = originalDataUrl;
previewWrap.hidden = false;
setOutputByFormat();
updateInfo(file);
};
reader.onerror = ()=> showToast('读取文件失败');
reader.readAsDataURL(file);
}
// 文件选择
fileInput.addEventListener('change', (e)=> handleFile(e.target.files?.[0]));
// 拖拽上传
['dragenter','dragover'].forEach(ev=> dropZone.addEventListener(ev, (e)=>{ e.preventDefault(); e.dataTransfer.dropEffect='copy'; dropZone.classList.add('dragover'); }));
;['dragleave','drop'].forEach(ev=> dropZone.addEventListener(ev, (e)=>{ e.preventDefault(); dropZone.classList.remove('dragover'); }));
dropZone.addEventListener('drop', (e)=>{
const file = e.dataTransfer.files?.[0];
handleFile(file);
});
// 键盘无障碍:回车打开文件选择
dropZone.addEventListener('keydown', (e)=>{
if(e.key === 'Enter' || e.key === ' '){
e.preventDefault();
fileInput.click();
}
});
// 切换输出格式
formatRadios.forEach(r => r.addEventListener('change', setOutputByFormat));
// 复制
copyBtn.addEventListener('click', async ()=>{
if(!output.value){ showToast('没有可复制的内容'); return; }
try{
await navigator.clipboard.writeText(output.value);
showToast('已复制到剪贴板');
}catch(err){
// 兼容:选中文本让用户手动复制
output.select();
const ok = document.execCommand?.('copy');
showToast(ok ? '已复制到剪贴板' : '复制失败,请手动复制');
}
});
// 清空
clearBtn.addEventListener('click', ()=>{
originalDataUrl = '';
output.value = '';
previewImg.removeAttribute('src');
previewWrap.hidden = true;
fileInput.value = '';
showToast('已清空');
});
// 粘贴图片(可选加分功能)
window.addEventListener('paste', (e)=>{
const items = e.clipboardData?.items || [];
for(const it of items){
if(it.type.startsWith('image/')){
const file = it.getAsFile();
handleFile(file);
showToast('已从剪贴板粘贴图片');
break;
}
}
});
</script></body>
</html>

View File

@@ -0,0 +1,332 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>图片转 WebP</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
body { font-family: 'Inter', system-ui, sans-serif; }
.drop-zone {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.drop-zone.dragover {
background-color: rgb(249 250 251);
border-color: rgb(59 130 246);
}
.preview-img {
max-height: 320px;
object-fit: contain;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
</style>
</head>
<body class="bg-zinc-950 text-zinc-100 min-h-screen">
<div class="max-w-5xl mx-auto px-4 py-8">
<!-- 头部 -->
<div class="flex flex-col items-center mb-10">
<div class="flex items-center gap-3 mb-2">
<div class="w-9 h-9 bg-blue-600 rounded-xl flex items-center justify-center text-white font-bold text-2xl">W</div>
<h1 class="text-4xl font-semibold tracking-tight">图片转 WebP</h1>
</div>
<p class="text-zinc-400 text-center max-w-md">
本地转换 · 零上传 · 隐私安全<br>
<span class="text-xs">支持 JPEG / PNG / GIF / BMP / AVIF 等任意格式</span>
</p>
</div>
<div id="main" class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- 左侧:上传 & 原图 -->
<div class="space-y-6">
<!-- 上传区 -->
<div id="upload-zone"
class="drop-zone border-2 border-dashed border-zinc-700 hover:border-blue-500 rounded-3xl h-80 flex flex-col items-center justify-center cursor-pointer bg-zinc-900/50">
<input type="file" id="file-input" accept="image/*" class="hidden">
<div class="text-center">
<div class="w-16 h-16 mx-auto mb-4 bg-zinc-800 rounded-2xl flex items-center justify-center">
📸
</div>
<p class="text-lg font-medium mb-1">拖拽图片到这里</p>
<p class="text-sm text-zinc-400">或点击选择文件</p>
<p class="text-[10px] text-zinc-500 mt-6">支持所有常见图片格式</p>
</div>
</div>
<!-- 原图预览 -->
<div id="original-preview" class="hidden bg-zinc-900 rounded-3xl overflow-hidden border border-zinc-800">
<div class="bg-zinc-950 px-5 py-3 flex items-center justify-between border-b border-zinc-800">
<span class="font-medium text-sm">原图</span>
<span id="original-info" class="text-xs text-zinc-400"></span>
</div>
<div class="p-5">
<img id="original-img" class="preview-img w-full mx-auto rounded-2xl" alt="原图">
</div>
</div>
</div>
<!-- 右侧:设置 & 转换结果 -->
<div class="space-y-6">
<!-- 参数设置 -->
<div id="controls" class="hidden bg-zinc-900 rounded-3xl p-6 border border-zinc-800">
<div class="flex items-center justify-between mb-4">
<span class="font-medium text-sm">转换设置</span>
</div>
<div class="space-y-6">
<!-- 质量滑块 -->
<div>
<div class="flex justify-between text-sm mb-2">
<span class="text-zinc-400">压缩质量</span>
<span id="quality-value" class="font-mono text-blue-400">82</span>
</div>
<input type="range" id="quality"
min="1" max="100" value="82"
class="w-full accent-blue-500">
<div class="flex justify-between text-[10px] text-zinc-500 mt-1">
<span>节省空间</span>
<span>最高画质</span>
</div>
</div>
<!-- 转换按钮 -->
<button id="convert-btn"
class="w-full bg-blue-600 hover:bg-blue-500 transition-colors text-white font-medium py-4 rounded-2xl text-lg shadow-lg shadow-blue-500/30 flex items-center justify-center gap-2">
<span>🚀</span>
<span>开始转换为 WebP</span>
</button>
</div>
</div>
<!-- 转换结果 -->
<div id="result" class="hidden bg-zinc-900 rounded-3xl overflow-hidden border border-zinc-800">
<div class="bg-zinc-950 px-5 py-3 flex items-center justify-between border-b border-zinc-800">
<span class="font-medium text-sm">转换结果</span>
<button id="convert-again"
class="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1">
<span></span> 转换另一张
</button>
</div>
<div class="p-5">
<img id="webp-img" class="preview-img w-full mx-auto rounded-2xl" alt="WebP">
</div>
<!-- 数据对比 -->
<div class="grid grid-cols-2 gap-px bg-zinc-800 mx-5 mb-5 rounded-2xl overflow-hidden">
<div class="bg-zinc-900 p-4">
<div class="text-xs text-zinc-400">原图大小</div>
<div id="orig-size" class="font-mono text-lg font-medium text-white"></div>
<div id="orig-dim" class="text-xs text-zinc-500 mt-1"></div>
</div>
<div class="bg-zinc-900 p-4">
<div class="text-xs text-emerald-400">WebP 大小</div>
<div id="webp-size" class="font-mono text-lg font-medium text-emerald-400"></div>
<div id="webp-dim" class="text-xs text-zinc-500 mt-1"></div>
</div>
</div>
<div class="mx-5 mb-6 bg-emerald-950 border border-emerald-900 rounded-2xl px-4 py-3 flex items-center justify-between">
<span class="text-emerald-400 text-sm">压缩率</span>
<span id="compress-rate" class="font-mono text-2xl font-semibold text-emerald-400"></span>
</div>
<!-- 下载按钮 -->
<div class="px-5 pb-5">
<a id="download-link"
class="block text-center bg-white text-zinc-950 font-medium py-4 rounded-2xl text-lg hover:bg-zinc-100 transition-colors">
⬇️ 下载 WebP 文件
</a>
</div>
</div>
</div>
</div>
<!-- 提示 -->
<div class="mt-12 text-center text-xs text-zinc-500">
全部在浏览器本地完成 · 无需联网 · 图片永不上传
</div>
</div>
<script>
// Tailwind 脚本已通过 CDN 加载,这里无需额外 script
function initTailwind() {
// 已在 head 加载
}
let originalFile = null;
let webpBlob = null;
let originalUrl = null;
let webpUrl = null;
const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const originalPreview = document.getElementById('original-preview');
const originalImg = document.getElementById('original-img');
const originalInfo = document.getElementById('original-info');
const controls = document.getElementById('controls');
const qualitySlider = document.getElementById('quality');
const qualityValue = document.getElementById('quality-value');
const convertBtn = document.getElementById('convert-btn');
const resultPanel = document.getElementById('result');
const webpImg = document.getElementById('webp-img');
const origSizeEl = document.getElementById('orig-size');
const origDimEl = document.getElementById('orig-dim');
const webpSizeEl = document.getElementById('webp-size');
const webpDimEl = document.getElementById('webp-dim');
const compressRateEl = document.getElementById('compress-rate');
const downloadLink = document.getElementById('download-link');
const convertAgain = document.getElementById('convert-again');
// 拖拽事件
function setupDragDrop() {
uploadZone.addEventListener('dragover', e => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', e => {
e.preventDefault();
uploadZone.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
});
uploadZone.addEventListener('click', () => fileInput.click());
}
// 文件选择
fileInput.addEventListener('change', e => {
if (e.target.files.length > 0) handleFiles(e.target.files);
});
function handleFiles(files) {
const file = files[0];
if (!file || !file.type.startsWith('image/')) {
alert('请选择图片文件!');
return;
}
originalFile = file;
// 释放之前的 URL
if (originalUrl) URL.revokeObjectURL(originalUrl);
originalUrl = URL.createObjectURL(file);
originalImg.src = originalUrl;
// 显示原图信息
const sizeMB = (file.size / 1024 / 1024).toFixed(2);
originalInfo.textContent = `${file.name} · ${(file.size / 1024).toFixed(1)} KB`;
originalPreview.classList.remove('hidden');
controls.classList.remove('hidden');
resultPanel.classList.add('hidden');
// 重置
webpBlob = null;
}
// 质量滑块实时显示
qualitySlider.addEventListener('input', () => {
qualityValue.textContent = qualitySlider.value;
});
// 转换核心函数
async function convertToWebP() {
if (!originalFile) return;
convertBtn.innerHTML = `
<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
`;
convertBtn.disabled = true;
const img = new Image();
img.onload = async () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const quality = parseFloat(qualitySlider.value) / 100;
// 使用 toBlob 获得更精确的 Blob
canvas.toBlob(async (blob) => {
webpBlob = blob;
if (webpUrl) URL.revokeObjectURL(webpUrl);
webpUrl = URL.createObjectURL(webpBlob);
webpImg.src = webpUrl;
// 计算数据
const origSize = originalFile.size;
const webpSize = webpBlob.size;
const rate = ((origSize - webpSize) / origSize * 100).toFixed(1);
// 填充界面
origSizeEl.textContent = formatSize(origSize);
origDimEl.textContent = `${img.width}×${img.height}`;
webpSizeEl.textContent = formatSize(webpSize);
webpDimEl.textContent = `${img.width}×${img.height}`;
compressRateEl.textContent = rate + '%';
// 下载链接
const newName = originalFile.name.replace(/\.[^/.]+$/, "") + ".webp";
downloadLink.download = newName;
downloadLink.href = webpUrl;
// 显示结果
resultPanel.classList.remove('hidden');
convertBtn.innerHTML = `✅ 已完成`;
setTimeout(() => {
convertBtn.innerHTML = `<span>🚀</span><span>重新转换</span>`;
convertBtn.disabled = false;
}, 800);
}, 'image/webp', quality);
};
img.src = originalUrl;
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
else if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
else return (bytes / 1024 / 1024).toFixed(2) + ' MB';
}
// 绑定按钮
convertBtn.addEventListener('click', convertToWebP);
convertAgain.addEventListener('click', () => {
resultPanel.classList.add('hidden');
// 清空结果但保留原图
});
// 初始化
function init() {
setupDragDrop();
// 初始质量显示
qualityValue.textContent = qualitySlider.value;
// 键盘快捷键:回车转换
document.addEventListener('keydown', e => {
if (e.key === 'Enter' && !resultPanel.classList.contains('hidden')) {
convertToWebP();
}
});
}
window.onload = init;
</script>
</body>
</html>

View File

@@ -0,0 +1,295 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>图片黑白处理</title>
<style>
:root{
--bg-1:#E9F9E7; /* 淡绿色 */
--bg-2:#F5FBE7; /* 淡黄绿色 */
--fg:#0f5132; /* 深一点的绿色文字 */
--muted:#5c7a66;
--card:#ffffffcc;
--accent:#6cc870;
--accent-2:#90d47f;
--shadow: 0 8px 24px rgba(39, 115, 72, .15);
--radius: 18px;
}
html,body{
height:100%;
}
body{
margin:0;
font-family: -apple-system,BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "Noto Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft Yahei", sans-serif;
color:var(--fg);
background: linear-gradient(160deg,var(--bg-1) 0%, var(--bg-2) 100%);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display:flex; align-items:stretch; justify-content:center;
}
.wrap{
width:min(100%, 760px);
padding:16px;
}
header{
text-align:center;
margin: 8px 0 14px;
}
h1{
margin:0 0 6px;
font-weight:800;
letter-spacing:.3px;
font-size: clamp(20px, 4.5vw, 28px);
}
.sub{
margin:0 auto;
max-width: 32em;
color:var(--muted);
font-size: clamp(12px, 3.4vw, 14px);
}.card{
background: var(--card);
backdrop-filter: blur(6px) saturate(120%);
border: 1px solid rgba(108,200,112,.18);
border-radius: var(--radius);
padding: 14px;
box-shadow: var(--shadow);
}
.controls{
display:grid;
grid-template-columns: 1fr;
gap:12px;
}
.row{
display:flex; align-items:center; gap:10px; flex-wrap:wrap;
}
label{
font-weight:600; font-size:14px;
}
input[type="file"]{
width:100%;
padding:12px;
border: 1px dashed #a8d7a3;
border-radius: 14px;
background: #ffffffb0;
}
input[type="range"]{
width:100%;
accent-color: var(--accent);
height: 28px;
}
output{ font-variant-numeric: tabular-nums; min-width:3ch; text-align:right; display:inline-block; }
.buttons{
display:grid; grid-template-columns: 1fr 1fr; gap:10px;
}
button{
appearance:none; border:0; cursor:pointer;
padding:12px 14px; border-radius: 14px; font-weight:700;
background: linear-gradient(180deg, var(--accent), var(--accent-2));
color:white; box-shadow: 0 6px 16px rgba(16,123,62,.25);
}
button.secondary{
background:#eaf8ea; color:#245b35; box-shadow:none; border:1px solid #cfe9ce;
}
button:disabled{ opacity:.5; cursor:not-allowed; }
.preview{
margin-top: 12px;
display:grid; gap:10px;
}
.canvas-wrap{
background: repeating-linear-gradient( 45deg, #f3fbf0, #f3fbf0 14px, #eef8ec 14px, #eef8ec 28px);
border:1px solid #dcefd7; border-radius: 14px; overflow:hidden;
display:flex; align-items:center; justify-content:center;
min-height: 240px;
}
canvas{ max-width:100%; height:auto; display:block; }
.placeholder{ color:#7da287; padding:20px; text-align:center; }
footer{
text-align:center; color:var(--muted); font-size:12px; margin:14px auto 6px;
}
/* 更偏向手机竖屏的合理布局 */
@media (min-width: 720px){
.controls{ grid-template-columns: 1.2fr 1fr; align-items:end; }
.buttons{ grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>图片黑白处理 · 轻柔绿意版</h1>
<p class="sub">上传一张图片,拖动滑块设置<span style="font-weight:600">黑白程度</span>0% 原图 → 100% 全黑白),一键下载处理结果。完全本地处理,无需联网。</p>
</header><section class="card">
<div class="controls">
<div>
<label for="file">选择图片</label>
<input id="file" type="file" accept="image/*" />
</div>
<div>
<div class="row" style="justify-content:space-between">
<label for="amount">黑白程度</label>
<div><output id="val">100</output>%</div>
</div>
<input id="amount" type="range" min="0" max="100" step="1" value="100" />
<div class="buttons">
<button id="download" disabled>下载处理后的图片</button>
<button id="reset" class="secondary" disabled>重置</button>
</div>
</div>
</div>
<div class="preview">
<div class="canvas-wrap">
<canvas id="canvas" aria-label="预览画布"></canvas>
<div id="ph" class="placeholder">⬆️ 请选择一张图片开始…</div>
</div>
</div>
</section>
<footer>© 黑白处理在您的设备本地完成 · 支持手机竖屏友好显示</footer>
</div><script>
(() => {
const fileInput = document.getElementById('file');
const amount = document.getElementById('amount');
const valOut = document.getElementById('val');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const downloadBtn = document.getElementById('download');
const resetBtn = document.getElementById('reset');
const placeholder = document.getElementById('ph');
let originalPixels = null; // Uint8ClampedArray当前画布的原始像素用于反复处理
let imgNatural = { w: 0, h: 0 };
let currentFileName = 'processed.png';
let rafId = null;
// 将图像绘制到画布,并根据屏幕宽度进行适度缩放,避免超大图导致内存压力
const MAX_DIM = 4096; // 上限,兼顾清晰度与移动端内存
function drawImageToCanvas(img) {
// 处理超大图片:在不改变比例的前提下收敛到 MAX_DIM 以内
let w = img.naturalWidth;
let h = img.naturalHeight;
const scale = Math.min(1, MAX_DIM / Math.max(w, h));
w = Math.round(w * scale);
h = Math.round(h * scale);
// 为了保证下载清晰度canvas 使用实际像素尺寸;显示层用 CSS 自适应
canvas.width = w;
canvas.height = h;
ctx.clearRect(0,0,w,h);
ctx.drawImage(img, 0, 0, w, h);
// 保存原始像素用于反复应用不同程度
originalPixels = ctx.getImageData(0,0,w,h);
imgNatural = { w, h };
}
function lerp(a,b,t){ return a + (b-a) * t; }
// 将原图按给定强度转为黑白(灰度)
function applyGrayscale(strength01){
if(!originalPixels) return;
const { data: src } = originalPixels;
const copy = new ImageData(new Uint8ClampedArray(src), imgNatural.w, imgNatural.h);
const out = copy.data;
// 加权灰度(符合 sRGB 感知权重)
for(let i=0; i<out.length; i+=4){
const r = src[i], g = src[i+1], b = src[i+2];
const y = 0.2126*r + 0.7152*g + 0.0722*b;
out[i] = lerp(r, y, strength01);
out[i+1] = lerp(g, y, strength01);
out[i+2] = lerp(b, y, strength01);
// 保留 alpha
}
ctx.putImageData(copy, 0, 0);
}
// 防抖 + rAF流畅响应滑杆
function scheduleRender(){
if(rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const t = Number(amount.value) / 100; // 0~1
valOut.textContent = amount.value;
applyGrayscale(t);
});
}
// 处理文件加载
fileInput.addEventListener('change', async (e) => {
const file = e.target.files && e.target.files[0];
if(!file) return;
currentFileName = (file.name ? file.name.replace(/\.[^.]+$/, '') : 'processed') + '.png';
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
placeholder.style.display = 'none';
drawImageToCanvas(img);
// 初始按当前滑杆值处理
scheduleRender();
downloadBtn.disabled = false;
resetBtn.disabled = false;
URL.revokeObjectURL(url);
};
img.onerror = () => {
alert('无法加载该图片,请更换文件试试。');
URL.revokeObjectURL(url);
};
img.src = url;
});
amount.addEventListener('input', scheduleRender);
resetBtn.addEventListener('click', () => {
if(!originalPixels) return;
amount.value = 100;
scheduleRender();
});
// 下载当前画布
function downloadCanvas(filename){
// toBlob 在少数旧版浏览器可能不可用,做个兼容
if(canvas.toBlob){
canvas.toBlob((blob) => {
if(!blob){ fallback(); return; }
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(link.href), 5000);
}, 'image/png');
} else {
fallback();
}
function fallback(){
const dataURL = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.href = dataURL;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
}
}
downloadBtn.addEventListener('click', () => {
if(!originalPixels) return;
downloadCanvas(currentFileName);
});
})();
</script></body>
</html>