PNG-to-animated-GIF batch converter with PHP frontend, Python/Pillow backend, and JSON API for Figma plugin integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
392 lines
13 KiB
PHP
392 lines
13 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>GIF Encoder</title>
|
|
<link rel="stylesheet" href="assets/style.css">
|
|
</head>
|
|
<body>
|
|
|
|
<h1>GIF ENCODER</h1>
|
|
<p class="subtitle">Upload PNGs, group them into GIF sets, and generate animated GIFs</p>
|
|
|
|
<!-- Upload Zone -->
|
|
<div class="upload-zone" id="uploadZone">
|
|
<div class="icon">▲</div>
|
|
<p>Drop PNG files here or click to browse</p>
|
|
<input type="file" id="fileInput" multiple accept=".png" style="display:none">
|
|
</div>
|
|
|
|
<!-- Settings -->
|
|
<div class="settings-bar">
|
|
<div class="setting-group">
|
|
<label>Quality (colors)</label>
|
|
<input type="range" id="quality" min="8" max="256" value="256">
|
|
<span class="setting-value" id="qualityValue">256</span>
|
|
</div>
|
|
<div class="setting-group" style="margin-left:auto">
|
|
<button class="btn btn-secondary" id="addGroupBtn">+ Add Group</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Uploaded Files Pool -->
|
|
<div class="file-pool hidden" id="filePool">
|
|
<h3>Uploaded PNGs <span id="fileCount"></span></h3>
|
|
<div class="thumbs" id="thumbsContainer"></div>
|
|
</div>
|
|
|
|
<!-- GIF Groups -->
|
|
<div class="groups-container" id="groupsContainer"></div>
|
|
|
|
<!-- Actions -->
|
|
<div class="actions-bar hidden" id="actionsBar">
|
|
<button class="btn btn-primary" id="generateBtn" disabled>Generate GIFs</button>
|
|
<span id="statusText"></span>
|
|
</div>
|
|
|
|
<!-- Results -->
|
|
<div class="results-container hidden" id="resultsContainer">
|
|
<h2>Generated GIFs</h2>
|
|
<div id="resultsList"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const state = {
|
|
batchId: null,
|
|
files: [], // {name, file, batchId, preview, id}
|
|
groups: {}, // groupId: {name, files: [fileId, ...]}
|
|
groupCounter: 0,
|
|
};
|
|
|
|
// Elements
|
|
const uploadZone = document.getElementById('uploadZone');
|
|
const fileInput = document.getElementById('fileInput');
|
|
const filePool = document.getElementById('filePool');
|
|
const thumbsContainer = document.getElementById('thumbsContainer');
|
|
const fileCount = document.getElementById('fileCount');
|
|
const groupsContainer = document.getElementById('groupsContainer');
|
|
const addGroupBtn = document.getElementById('addGroupBtn');
|
|
const actionsBar = document.getElementById('actionsBar');
|
|
const generateBtn = document.getElementById('generateBtn');
|
|
const statusText = document.getElementById('statusText');
|
|
const resultsContainer = document.getElementById('resultsContainer');
|
|
const resultsList = document.getElementById('resultsList');
|
|
const qualitySlider = document.getElementById('quality');
|
|
const qualityValue = document.getElementById('qualityValue');
|
|
|
|
// Quality slider display
|
|
qualitySlider.addEventListener('input', () => {
|
|
qualityValue.textContent = qualitySlider.value;
|
|
});
|
|
|
|
// Upload zone events
|
|
uploadZone.addEventListener('click', () => fileInput.click());
|
|
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);
|
|
});
|
|
fileInput.addEventListener('change', () => handleFiles(fileInput.files));
|
|
|
|
// Upload files
|
|
async function handleFiles(fileList) {
|
|
const formData = new FormData();
|
|
formData.append('action', 'upload');
|
|
for (const f of fileList) {
|
|
if (f.type === 'image/png') formData.append('files[]', f);
|
|
}
|
|
|
|
const resp = await fetch('process.php', { method: 'POST', body: formData });
|
|
const data = await resp.json();
|
|
|
|
if (data.success && data.files.length > 0) {
|
|
state.batchId = data.batchId;
|
|
data.files.forEach(f => {
|
|
f.id = 'file_' + Math.random().toString(36).substr(2, 9);
|
|
state.files.push(f);
|
|
});
|
|
renderFilePool();
|
|
actionsBar.classList.remove('hidden');
|
|
|
|
// Auto-create first group if none exist
|
|
if (Object.keys(state.groups).length === 0) {
|
|
addGroup();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Render file pool thumbnails
|
|
function renderFilePool() {
|
|
filePool.classList.remove('hidden');
|
|
fileCount.textContent = `(${state.files.length} files)`;
|
|
|
|
thumbsContainer.innerHTML = '';
|
|
state.files.forEach(f => {
|
|
const thumb = createThumb(f);
|
|
thumbsContainer.appendChild(thumb);
|
|
});
|
|
|
|
updateGenerateBtn();
|
|
}
|
|
|
|
function createThumb(f) {
|
|
const div = document.createElement('div');
|
|
div.className = 'thumb';
|
|
div.draggable = true;
|
|
div.dataset.fileId = f.id;
|
|
|
|
const img = document.createElement('img');
|
|
img.src = f.preview;
|
|
img.alt = f.name;
|
|
div.appendChild(img);
|
|
|
|
const nameEl = document.createElement('div');
|
|
nameEl.className = 'name';
|
|
nameEl.textContent = f.name;
|
|
div.appendChild(nameEl);
|
|
|
|
div.addEventListener('dragstart', e => {
|
|
e.dataTransfer.setData('text/plain', f.id);
|
|
div.classList.add('dragging');
|
|
});
|
|
div.addEventListener('dragend', () => div.classList.remove('dragging'));
|
|
|
|
return div;
|
|
}
|
|
|
|
// Groups
|
|
function addGroup() {
|
|
state.groupCounter++;
|
|
const groupId = 'group_' + state.groupCounter;
|
|
state.groups[groupId] = { name: 'GIF ' + state.groupCounter, files: [], delays: {}, defaultDelay: 500, loop: true };
|
|
renderGroups();
|
|
}
|
|
|
|
addGroupBtn.addEventListener('click', addGroup);
|
|
|
|
function renderGroups() {
|
|
groupsContainer.innerHTML = '';
|
|
Object.entries(state.groups).forEach(([groupId, group]) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'gif-group';
|
|
card.dataset.groupId = groupId;
|
|
|
|
// Header
|
|
const header = document.createElement('div');
|
|
header.className = 'group-header';
|
|
|
|
const nameInput = document.createElement('input');
|
|
nameInput.type = 'text';
|
|
nameInput.value = group.name;
|
|
nameInput.addEventListener('input', e => { group.name = e.target.value; });
|
|
|
|
const frameCount = document.createElement('span');
|
|
frameCount.className = 'frame-count';
|
|
frameCount.textContent = group.files.length + ' frames';
|
|
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.className = 'btn btn-danger';
|
|
removeBtn.textContent = 'Remove';
|
|
removeBtn.addEventListener('click', () => {
|
|
delete state.groups[groupId];
|
|
renderGroups();
|
|
renderFilePool();
|
|
});
|
|
|
|
header.appendChild(nameInput);
|
|
header.appendChild(frameCount);
|
|
header.appendChild(removeBtn);
|
|
card.appendChild(header);
|
|
|
|
// Settings row: default delay + loop toggle
|
|
const settingsRow = document.createElement('div');
|
|
settingsRow.className = 'group-delay';
|
|
|
|
const delayLabel = document.createElement('label');
|
|
delayLabel.textContent = 'Default delay (ms)';
|
|
const delayInput = document.createElement('input');
|
|
delayInput.type = 'number';
|
|
delayInput.value = group.defaultDelay;
|
|
delayInput.min = 50;
|
|
delayInput.max = 5000;
|
|
delayInput.step = 50;
|
|
delayInput.addEventListener('input', e => { group.defaultDelay = parseInt(e.target.value) || 500; });
|
|
settingsRow.appendChild(delayLabel);
|
|
settingsRow.appendChild(delayInput);
|
|
|
|
const loopLabel = document.createElement('label');
|
|
loopLabel.textContent = 'Loop';
|
|
loopLabel.style.marginLeft = '1rem';
|
|
const loopCheck = document.createElement('input');
|
|
loopCheck.type = 'checkbox';
|
|
loopCheck.checked = group.loop;
|
|
loopCheck.style.accentColor = '#FFC407';
|
|
loopCheck.style.width = '18px';
|
|
loopCheck.style.height = '18px';
|
|
loopCheck.addEventListener('change', () => { group.loop = loopCheck.checked; });
|
|
settingsRow.appendChild(loopLabel);
|
|
settingsRow.appendChild(loopCheck);
|
|
|
|
card.appendChild(settingsRow);
|
|
|
|
// Frames drop zone
|
|
const frames = document.createElement('div');
|
|
frames.className = 'frames' + (group.files.length === 0 ? ' empty' : '');
|
|
|
|
group.files.forEach((fileId, idx) => {
|
|
const f = state.files.find(x => x.id === fileId);
|
|
if (!f) return;
|
|
|
|
// Frame wrapper: thumb + per-frame delay input
|
|
const frameWrap = document.createElement('div');
|
|
frameWrap.className = 'frame-wrap';
|
|
|
|
const thumb = createThumb(f);
|
|
thumb.addEventListener('dblclick', () => {
|
|
group.files = group.files.filter(id => id !== fileId);
|
|
delete group.delays[idx];
|
|
renderGroups();
|
|
renderFilePool();
|
|
});
|
|
frameWrap.appendChild(thumb);
|
|
|
|
const frameDelay = document.createElement('input');
|
|
frameDelay.type = 'number';
|
|
frameDelay.className = 'frame-delay-input';
|
|
frameDelay.value = group.delays[idx] ?? group.defaultDelay;
|
|
frameDelay.min = 50;
|
|
frameDelay.max = 5000;
|
|
frameDelay.step = 50;
|
|
frameDelay.title = 'Delay for this frame (ms)';
|
|
frameDelay.addEventListener('input', e => {
|
|
group.delays[idx] = parseInt(e.target.value) || group.defaultDelay;
|
|
});
|
|
frameWrap.appendChild(frameDelay);
|
|
|
|
frames.appendChild(frameWrap);
|
|
});
|
|
|
|
// Drop events
|
|
card.addEventListener('dragover', e => {
|
|
e.preventDefault();
|
|
card.classList.add('dragover');
|
|
});
|
|
card.addEventListener('dragleave', e => {
|
|
if (!card.contains(e.relatedTarget)) card.classList.remove('dragover');
|
|
});
|
|
card.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
card.classList.remove('dragover');
|
|
const fileId = e.dataTransfer.getData('text/plain');
|
|
if (!fileId) return;
|
|
|
|
// Allow same file in multiple groups, but not duplicated in same group
|
|
if (!group.files.includes(fileId)) {
|
|
group.files.push(fileId);
|
|
}
|
|
|
|
renderGroups();
|
|
renderFilePool();
|
|
});
|
|
|
|
card.appendChild(frames);
|
|
groupsContainer.appendChild(card);
|
|
});
|
|
|
|
updateGenerateBtn();
|
|
}
|
|
|
|
function updateGenerateBtn() {
|
|
const hasGroups = Object.values(state.groups).some(g => g.files.length > 0);
|
|
generateBtn.disabled = !hasGroups;
|
|
}
|
|
|
|
// Generate GIFs
|
|
generateBtn.addEventListener('click', async () => {
|
|
generateBtn.disabled = true;
|
|
statusText.innerHTML = '<span class="spinner"></span> Generating GIFs...';
|
|
|
|
// Build groups payload with per-frame delays and loop flag
|
|
const groups = {};
|
|
Object.values(state.groups).forEach(g => {
|
|
if (g.files.length === 0) return;
|
|
const fileNames = g.files.map(fId => {
|
|
const f = state.files.find(x => x.id === fId);
|
|
return f ? f.file : null;
|
|
}).filter(Boolean);
|
|
|
|
// Build per-frame delay array
|
|
const delays = g.files.map((_, idx) => g.delays[idx] ?? g.defaultDelay);
|
|
|
|
groups[g.name] = { files: fileNames, delays: delays, delay: g.defaultDelay, loop: g.loop };
|
|
});
|
|
|
|
const formData = new FormData();
|
|
formData.append('action', 'generate');
|
|
formData.append('groups', JSON.stringify(groups));
|
|
formData.append('quality', qualitySlider.value);
|
|
formData.append('batchId', state.batchId);
|
|
|
|
try {
|
|
const resp = await fetch('process.php', { method: 'POST', body: formData });
|
|
const data = await resp.json();
|
|
|
|
if (data.success) {
|
|
statusText.textContent = 'Done!';
|
|
renderResults(data.results);
|
|
} else {
|
|
statusText.textContent = 'Error: ' + (data.error || 'Unknown error');
|
|
}
|
|
} catch (err) {
|
|
statusText.textContent = 'Error: ' + err.message;
|
|
}
|
|
|
|
generateBtn.disabled = false;
|
|
});
|
|
|
|
function renderResults(results) {
|
|
resultsContainer.classList.remove('hidden');
|
|
resultsList.innerHTML = '';
|
|
|
|
results.forEach(r => {
|
|
const card = document.createElement('div');
|
|
card.className = 'result-card';
|
|
|
|
if (r.success) {
|
|
const img = document.createElement('img');
|
|
img.src = r.url + '?t=' + Date.now();
|
|
img.alt = r.group;
|
|
card.appendChild(img);
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'info';
|
|
info.innerHTML = `<h4>${r.group}</h4><p>${r.frames} frames</p>`;
|
|
card.appendChild(info);
|
|
|
|
const btn = document.createElement('a');
|
|
btn.className = 'btn btn-primary';
|
|
btn.href = r.url;
|
|
btn.download = r.group + '.gif';
|
|
btn.textContent = 'Download';
|
|
card.appendChild(btn);
|
|
} else {
|
|
const info = document.createElement('div');
|
|
info.className = 'info';
|
|
info.innerHTML = `<h4>${r.group}</h4><p style="color:#ff4444">${r.error}</p>`;
|
|
card.appendChild(info);
|
|
}
|
|
|
|
resultsList.appendChild(card);
|
|
});
|
|
}
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|