Skip to content

Instantly share code, notes, and snippets.

@zhmushan
Created December 26, 2025 10:59
Show Gist options
  • Select an option

  • Save zhmushan/afdff683501763743e9fff9637737524 to your computer and use it in GitHub Desktop.

Select an option

Save zhmushan/afdff683501763743e9fff9637737524 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lottie 工具</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 2rem;
max-width: 800px;
margin: 0 auto;
color: #1a1a1a;
line-height: 1.6;
}
h1 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
h2 {
font-size: 1.125rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e5e5e5;
}
.section {
margin-bottom: 2.5rem;
padding: 1.5rem;
border: 1px solid #e5e5e5;
border-radius: 8px;
}
.desc {
color: #666;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.drop-zone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 1.5rem;
text-align: center;
cursor: pointer;
margin-bottom: 1rem;
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: #666;
background-color: #f9f9f9;
}
.drop-zone input {
display: none;
}
.options {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.options label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #666;
}
.options input[type="number"] {
width: 60px;
padding: 0.25rem;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
padding: 0.5rem 1rem;
background: #1a1a1a;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
button:hover {
background: #333;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.preview-area {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.5rem;
margin-top: 1rem;
}
.preview-item {
border: 1px solid #e5e5e5;
border-radius: 4px;
padding: 0.5rem;
text-align: center;
}
.preview-item img {
max-width: 100%;
max-height: 60px;
object-fit: contain;
}
.preview-item p {
font-size: 0.65rem;
color: #666;
margin-top: 0.25rem;
word-break: break-all;
}
.result {
margin-top: 1rem;
padding: 1rem;
background: #f5f5f5;
border-radius: 4px;
font-size: 0.875rem;
}
.result.success {
background: #e6f4ea;
color: #1e7e34;
}
.result.error {
background: #fce8e6;
color: #c5221f;
}
/* 播放器 */
.player {
margin-top: 1rem;
padding: 1rem;
border: 1px solid #e5e5e5;
border-radius: 8px;
display: none;
}
.player.active {
display: block;
}
.canvas {
width: 280px;
height: 280px;
margin: 0 auto;
border: 1px solid #eee;
border-radius: 4px;
background: #fafafa;
}
.controls {
margin-top: 1rem;
}
.progress {
width: 100%;
margin-bottom: 0.5rem;
cursor: pointer;
}
.buttons {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.buttons button {
padding: 0.375rem 0.75rem;
font-size: 0.8rem;
}
.speed-ctrl {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.speed-ctrl select {
padding: 0.25rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.info {
font-size: 0.75rem;
color: #666;
margin-top: 0.5rem;
}
</style>
</head>
<body>
<h1>🎬 Lottie 工具</h1>
<!-- Lottie 预览 -->
<section class="section">
<h2>预览 Lottie</h2>
<p class="desc">上传 Lottie JSON 文件,支持播放控制、进度拖拽、速度调整。</p>
<div class="drop-zone" id="preview-drop">
<p>拖拽 Lottie JSON 到这里</p>
<input type="file" id="preview-input" accept=".json,application/json">
</div>
<div class="player" id="player">
<div class="canvas" id="canvas"></div>
<div class="controls">
<input type="range" class="progress" id="progress" min="0" max="100" value="0">
<div class="buttons">
<button id="play-btn">▶️ 播放</button>
<button id="pause-btn">⏸️ 暂停</button>
<button id="stop-btn">⏹️ 停止</button>
<div class="speed-ctrl">
<label>速度:</label>
<select id="speed">
<option value="0.25">0.25x</option>
<option value="0.5">0.5x</option>
<option value="1" selected>1x</option>
<option value="1.5">1.5x</option>
<option value="2">2x</option>
</select>
</div>
</div>
<div class="info" id="info"></div>
</div>
</div>
</section>
<!-- 序列帧转 Lottie -->
<section class="section">
<h2>序列帧转 Lottie</h2>
<p class="desc">将一组序列帧图片转换为 Lottie JSON(内嵌 Base64)。</p>
<div class="drop-zone" id="seq-drop">
<p>拖拽序列帧图片到这里(按文件名排序)</p>
<input type="file" id="seq-input" accept="image/*" multiple>
</div>
<div class="options">
<label>帧率: <input type="number" id="fps" min="1" max="60" value="24"> FPS</label>
<label>宽度: <input type="number" id="width" min="1" value="512"> px</label>
<label>高度: <input type="number" id="height" min="1" value="512"> px</label>
</div>
<button id="convert-btn" disabled>生成 Lottie JSON</button>
<div class="preview-area" id="seq-preview"></div>
<div class="result" id="result"></div>
</section>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.12.2/lottie.min.js"></script>
<script type="module">
/* ============================================
Lottie 预览
============================================ */
const previewDrop = document.getElementById('preview-drop');
const previewInput = document.getElementById('preview-input');
const player = document.getElementById('player');
const canvas = document.getElementById('canvas');
const progress = document.getElementById('progress');
const playBtn = document.getElementById('play-btn');
const pauseBtn = document.getElementById('pause-btn');
const stopBtn = document.getElementById('stop-btn');
const speedSelect = document.getElementById('speed');
const info = document.getElementById('info');
/** @type {AnimationItem|null} */
let anim = null;
let isPlaying = false;
let isDragging = false;
setupDropZone(previewDrop, previewInput, async (files) => {
if (files.length === 0) return;
try {
const text = await files[0].text();
loadAnimation(JSON.parse(text));
} catch (e) {
alert('无法解析 Lottie JSON');
}
});
/**
* 加载动画
* @param {object} data
*/
function loadAnimation(data) {
if (anim) anim.destroy();
canvas.innerHTML = '';
player.classList.add('active');
anim = lottie.loadAnimation({
container: canvas,
renderer: 'svg',
loop: true,
autoplay: true,
animationData: data
});
isPlaying = true;
anim.addEventListener('enterFrame', () => {
if (!isDragging && anim) {
progress.value = (anim.currentFrame / anim.totalFrames) * 100;
updateInfo();
}
});
updateInfo();
}
function updateInfo() {
if (!anim) return;
const frame = Math.floor(anim.currentFrame);
const total = Math.floor(anim.totalFrames);
const fps = anim.frameRate;
info.textContent = `帧: ${frame}/${total} | 时间: ${(frame / fps).toFixed(2)}s/${(total / fps).toFixed(2)}s | ${fps} FPS`;
}
playBtn.onclick = () => { anim?.play(); isPlaying = true; };
pauseBtn.onclick = () => { anim?.pause(); isPlaying = false; };
stopBtn.onclick = () => { anim?.stop(); isPlaying = false; progress.value = 0; };
speedSelect.onchange = () => anim?.setSpeed(parseFloat(speedSelect.value));
progress.addEventListener('mousedown', () => { isDragging = true; anim?.pause(); });
progress.addEventListener('input', () => {
if (anim) {
anim.goToAndStop((parseFloat(progress.value) / 100) * anim.totalFrames, true);
updateInfo();
}
});
progress.addEventListener('mouseup', () => { isDragging = false; if (isPlaying) anim?.play(); });
/* ============================================
序列帧转 Lottie
============================================ */
const seqDrop = document.getElementById('seq-drop');
const seqInput = document.getElementById('seq-input');
const fpsInput = document.getElementById('fps');
const widthInput = document.getElementById('width');
const heightInput = document.getElementById('height');
const convertBtn = document.getElementById('convert-btn');
const seqPreview = document.getElementById('seq-preview');
const result = document.getElementById('result');
/** @type {File[]} */
let seqFiles = [];
setupDropZone(seqDrop, seqInput, (files) => {
seqFiles = files.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
convertBtn.disabled = files.length === 0;
seqPreview.innerHTML = '';
seqFiles.slice(0, 8).forEach((file) => {
const item = document.createElement('div');
item.className = 'preview-item';
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
const p = document.createElement('p');
p.textContent = file.name;
item.appendChild(img);
item.appendChild(p);
seqPreview.appendChild(item);
});
if (files.length > 8) {
const more = document.createElement('p');
more.style.cssText = 'grid-column: 1 / -1; color: #666; font-size: 0.75rem;';
more.textContent = `... 还有 ${files.length - 8} 个文件`;
seqPreview.appendChild(more);
}
});
convertBtn.addEventListener('click', async () => {
const fps = parseInt(fpsInput.value);
const w = parseInt(widthInput.value);
const h = parseInt(heightInput.value);
try {
result.className = 'result';
result.textContent = '正在生成...';
const lottieJson = await generateLottie(seqFiles, fps, w, h);
// 保存
const blob = new Blob([JSON.stringify(lottieJson)], { type: 'application/json' });
const baseName = seqFiles[0].name.replace(/\.[^.]+$/, '').replace(/[\d_-]+$/, '') || 'animation';
if ('showSaveFilePicker' in window) {
const handle = await window.showSaveFilePicker({
suggestedName: `${baseName}.json`,
types: [{ description: 'Lottie JSON', accept: { 'application/json': ['.json'] } }]
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
}
// 预览
loadAnimation(lottieJson);
result.className = 'result success';
result.textContent = `生成成功!${seqFiles.length} 帧,${fps} FPS,时长 ${(seqFiles.length / fps).toFixed(2)}s`;
} catch (e) {
if (e.name !== 'AbortError') {
result.className = 'result error';
result.textContent = `失败: ${e.message}`;
}
}
});
/**
* 生成 Lottie JSON
* @param {File[]} files
* @param {number} fps
* @param {number} w
* @param {number} h
*/
async function generateLottie(files, fps, w, h) {
const assets = [];
const layers = [];
for (let i = 0; i < files.length; i++) {
const base64 = await fileToBase64(files[i]);
const id = `img_${i}`;
assets.push({ id, w, h, u: '', p: base64, e: 1 });
layers.push({
ddd: 0, ind: i + 1, ty: 2, nm: `Frame ${i}`, refId: id, sr: 1,
ks: {
o: { a: 0, k: 100 },
r: { a: 0, k: 0 },
p: { a: 0, k: [w / 2, h / 2, 0] },
a: { a: 0, k: [w / 2, h / 2, 0] },
s: { a: 0, k: [100, 100, 100] }
},
ao: 0, ip: i, op: i + 1, st: 0, bm: 0
});
}
return { v: '5.7.4', fr: fps, ip: 0, op: files.length, w, h, nm: 'Sequence', ddd: 0, assets, layers };
}
/* ============================================
工具函数
============================================ */
/**
* @param {HTMLElement} zone
* @param {HTMLInputElement} input
* @param {(files: File[]) => void} onFiles
*/
function setupDropZone(zone, input, onFiles) {
zone.onclick = () => input.click();
zone.ondragover = (e) => { e.preventDefault(); zone.classList.add('dragover'); };
zone.ondragleave = () => zone.classList.remove('dragover');
zone.ondrop = (e) => { e.preventDefault(); zone.classList.remove('dragover'); onFiles(Array.from(e.dataTransfer?.files || [])); };
input.onchange = () => onFiles(Array.from(input.files || []));
}
/**
* @param {File} file
* @returns {Promise<string>}
*/
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment