gif-encoder/index.php
DJP cc14d77257 Initial commit — GIF Encoder tool
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>
2026-02-25 12:05:09 -05:00

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">&#9650;</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>