419 lines
18 KiB
HTML
419 lines
18 KiB
HTML
<!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&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>
|