Created
December 26, 2025 10:59
-
-
Save zhmushan/afdff683501763743e9fff9637737524 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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