|
|
||
|---|---|---|
| assets | ||
| .gitignore | ||
| .htaccess | ||
| api.php | ||
| encoder.py | ||
| FIGMA-INTEGRATION.md | ||
| index.php | ||
| process.php | ||
| README.md | ||
GIF Encoder
Batch PNG-to-animated-GIF converter with a PHP web frontend and Python (Pillow) backend. Upload sets of PNGs, group them into GIF animations with per-frame timing control, and download the results. Includes a JSON API for integration with Figma plugins and external scripts.
Live URL: https://ai-sandbox.oliver.solutions/GIF-ENCODER/ API Endpoint: https://ai-sandbox.oliver.solutions/GIF-ENCODER/api.php Repo: https://bitbucket.org/zlalani/gif-encoder/src/main/
Features
- Drag-and-drop PNG uploads
- Group frames into multiple GIF sets (batch ~10 GIFs at once)
- Per-frame delay control (ms) — different timing for each frame
- Per-group loop toggle (infinite loop or play once)
- Adjustable quality (color count 8-256)
- Same PNG can be used across multiple groups
- JSON API endpoint for Figma plugin / script integration
- API returns base64-encoded GIFs + download URLs
- CORS enabled for cross-origin requests
Project Structure
GIF-ENCODER/
├── index.php Frontend UI
├── process.php PHP backend (form handling + API logic)
├── api.php API endpoint (proxies to process.php)
├── encoder.py Python GIF encoder (Pillow)
├── venv/ Python virtual environment
├── assets/
│ └── style.css Styles (Montserrat, black/#FFC407)
├── uploads/ Temporary uploaded PNGs (auto-created)
└── output/ Generated GIFs (auto-created)
Requirements
- PHP 7.4+ with
shell_execenabled - Python 3.8+
- Apache (MAMP, XAMPP, or standalone) or PHP built-in server
Installation
1. Clone the project
cd /var/www/html
git clone https://zlalani@bitbucket.org/zlalani/gif-encoder.git GIF-ENCODER
cd GIF-ENCODER
2. Set up the Python virtual environment
python3 -m venv venv
source venv/bin/activate
pip install Pillow
deactivate
On Ubuntu, if python3 -m venv fails:
sudo apt install python3-venv -y
3. Create working directories and set permissions
mkdir -p uploads output
sudo chown -R www-data:www-data uploads output
sudo chmod 775 uploads output
sudo chmod +x encoder.py
4. PHP configuration
If uploading many or large PNGs, update your php.ini:
upload_max_filesize = 64M
post_max_size = 64M
max_file_uploads = 100
MAMP: Edit /Applications/MAMP/bin/php/phpX.X.X/conf/php.ini
Ubuntu/Apache: Edit /etc/php/X.X/apache2/php.ini then sudo systemctl restart apache2
Verify shell_exec is NOT in the disable_functions list in php.ini.
5. Access the app
If installed in /var/www/html/GIF-ENCODER/, it's available at:
https://your-domain.com/GIF-ENCODER/
No virtual host needed — it runs from the default Apache document root.
MAMP (local dev): http://localhost:8888/GIF-ENCODER/
PHP built-in server (quick test):
cd GIF-ENCODER
php -S localhost:8000
Usage — Web UI
- Open https://ai-sandbox.oliver.solutions/GIF-ENCODER/ in your browser.
- Drag PNG files onto the upload zone (or click to browse).
- Uploaded PNGs appear in the file pool. Drag them into GIF group cards.
- Click + Add Group to create additional GIF sets (up to 10+).
- Adjust settings per group:
- Default delay (ms): Base timing for frames in that group.
- Per-frame delay: Each frame has its own delay input below the thumbnail.
- Loop checkbox: Toggle infinite loop on/off.
- Set the Quality slider (global — controls GIF color palette size).
- Click Generate GIFs.
- Download individual GIFs from the results, or right-click to save.
Tips:
- The same PNG can be dragged into multiple groups.
- Double-click a frame inside a group to remove it.
Usage — Python CLI
The encoder can be used standalone from the command line:
source venv/bin/activate
# Basic: uniform 500ms delay, looping
python encoder.py --input frame1.png frame2.png frame3.png --output out.gif
# Custom uniform delay
python encoder.py --input frame1.png frame2.png frame3.png --output out.gif --delay 200
# Per-frame delays
python encoder.py --input frame1.png frame2.png frame3.png --output out.gif --delays 500,200,800
# Play once (no loop)
python encoder.py --input frame1.png frame2.png frame3.png --output out.gif --delay 300 --no-loop
# Lower quality (fewer colors, smaller file)
python encoder.py --input frame1.png frame2.png frame3.png --output out.gif --quality 64
CLI Arguments
| Argument | Default | Description |
|---|---|---|
--input |
required | Space-separated list of PNG file paths |
--output |
required | Output GIF file path |
--delay |
500 | Uniform delay for all frames (ms) |
--delays |
— | Comma-separated per-frame delays (e.g. 500,200,800) |
--quality |
256 | Color count for palette quantization (2-256) |
--no-loop |
false | Play once instead of looping infinitely |
Usage — API
Endpoint
POST https://ai-sandbox.oliver.solutions/GIF-ENCODER/api.php
CORS is enabled. Responses are JSON.
Option A: Multipart upload (recommended for Figma)
Send PNG files as multipart uploads with a json field containing the configuration.
curl -X POST https://ai-sandbox.oliver.solutions/GIF-ENCODER/api.php \
-F "json={\"groups\":{\"banner\":{\"files\":[\"frame1.png\",\"frame2.png\",\"frame3.png\"],\"delays\":[500,200,800],\"loop\":true}},\"quality\":256}" \
-F "files[]=@frame1.png" \
-F "files[]=@frame2.png" \
-F "files[]=@frame3.png"
Option B: JSON body with base64 files
Send everything as a single JSON payload with base64-encoded file data.
curl -X POST https://ai-sandbox.oliver.solutions/GIF-ENCODER/api.php \
-H "Content-Type: application/json" \
-d '{
"files": {
"frame1.png": "<base64 encoded PNG data>",
"frame2.png": "<base64 encoded PNG data>"
},
"groups": {
"my_animation": {
"files": ["frame1.png", "frame2.png"],
"delays": [500, 300],
"loop": true
}
},
"quality": 256
}'
Request Schema
{
// Base64-encoded PNG files (Option B only)
"files": {
"filename.png": "<base64 string>"
},
// GIF groups to generate
"groups": {
"group_name": {
"files": ["file1.png", "file2.png"], // filenames matching uploads
"delays": [500, 200], // per-frame delays in ms (optional)
"delay": 500, // uniform delay fallback (optional, default 500)
"loop": true // true = infinite, false = play once (optional, default true)
}
},
// Global quality setting (optional, default 256)
"quality": 256
}
Response Schema
{
"success": true,
"batchId": "api_67be1a2f3c4d5",
"results": [
{
"group": "group_name",
"success": true,
"frames": 3,
"url": "output/api_67be1a2f3c4d5/group_name.gif",
"download_url": "https://ai-sandbox.oliver.solutions/GIF-ENCODER/output/api_67be1a2f3c4d5/group_name.gif",
"base64": "<base64 encoded GIF>"
}
]
}
Figma Plugin Integration
Architecture
Figma plugins run in two separate threads:
| Thread | Has access to | Limitation |
|---|---|---|
Main thread (code.js) |
Figma scene, exportAsync, figma.* |
No FormData, no Blob, limited fetch |
UI iframe (ui.html) |
Full browser APIs: fetch, FormData, Blob |
No Figma scene access |
The workflow: export PNGs in the main thread, send the bytes to the UI iframe via postMessage, then upload to the API from the iframe.
Figma Main Thread UI Iframe GIF Encoder API
───────────────── ───────── ───────────────
exportAsync (PNG)
│
├─ postMessage ──▶ Uint8Array → Blob
FormData.append()
│
├─ POST /api.php ──▶ encoder.py
│ │
◀── JSON response ────────┘
│ (base64 GIF + URL)
◀── postMessage ────┘
figma.notify("Done!")
manifest.json
{
"name": "GIF Encoder",
"id": "your-plugin-id",
"api": "1.0.0",
"editorType": ["figma"],
"main": "code.js",
"ui": "ui.html",
"documentAccess": "dynamic-page",
"networkAccess": {
"allowedDomains": [
"https://ai-sandbox.oliver.solutions"
],
"devAllowedDomains": [
"http://localhost:8888",
"http://localhost:8000"
],
"reasoning": "Uploads exported PNG frames to the GIF Encoder API."
}
}
code.js (Main Thread)
Handles frame selection, PNG export, and communication with the UI.
figma.showUI(__html__, { visible: true, width: 420, height: 500 });
figma.ui.onmessage = async (msg) => {
// ── Export selected frames as PNGs ──
if (msg.type === 'EXPORT_FRAMES') {
const selection = figma.currentPage.selection;
if (selection.length === 0) {
figma.notify('Select at least one frame.');
figma.ui.postMessage({ type: 'ERROR', error: 'No frames selected' });
return;
}
figma.notify(`Exporting ${selection.length} frames...`);
const frames = [];
for (const node of selection) {
const bytes = await node.exportAsync({
format: 'PNG',
constraint: { type: 'SCALE', value: msg.scale || 1 },
});
frames.push({
name: node.name.replace(/[^a-zA-Z0-9._-]/g, '_') + '.png',
bytes: bytes, // Uint8Array — transfers via postMessage
});
}
figma.ui.postMessage({ type: 'FRAMES_READY', frames });
}
// ── Handle results from the API ──
if (msg.type === 'GENERATION_COMPLETE') {
figma.notify(`Generated ${msg.count} GIF(s)`);
}
if (msg.type === 'GENERATION_ERROR') {
figma.notify(`Error: ${msg.error}`, { error: true });
}
if (msg.type === 'CLOSE') {
figma.closePlugin();
}
};
ui.html (UI Iframe)
Provides the settings UI and handles the HTTP upload to the GIF Encoder API.
<!DOCTYPE html>
<html>
<head>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; padding: 16px; background: #2c2c2c; color: #fff; }
h3 { font-size: 14px; margin-bottom: 12px; color: #FFC407; }
label { display: block; font-size: 11px; color: #999; margin-bottom: 4px; text-transform: uppercase; }
input, select { width: 100%; padding: 8px; margin-bottom: 12px; background: #1a1a1a; border: 1px solid #444;
color: #fff; border-radius: 6px; font-size: 13px; }
input:focus { outline: none; border-color: #FFC407; }
button { width: 100%; padding: 10px; background: #FFC407; color: #000; border: none; border-radius: 6px;
font-weight: 600; font-size: 13px; cursor: pointer; text-transform: uppercase; }
button:hover { background: #e6b006; }
button:disabled { background: #555; color: #999; cursor: not-allowed; }
#status { margin-top: 12px; font-size: 12px; color: #888; }
.result { margin-top: 8px; padding: 8px; background: #1a1a1a; border-radius: 6px; }
.result img { max-width: 100%; border-radius: 4px; }
.result a { color: #FFC407; font-size: 12px; }
.row { display: flex; gap: 12px; }
.row > div { flex: 1; }
</style>
</head>
<body>
<h3>GIF ENCODER</h3>
<label>API Endpoint</label>
<input type="text" id="endpoint" value="https://ai-sandbox.oliver.solutions/GIF-ENCODER/api.php">
<label>GIF Name</label>
<input type="text" id="gifName" value="animation">
<div class="row">
<div>
<label>Delay (ms)</label>
<input type="number" id="delay" value="500" min="50" max="5000" step="50">
</div>
<div>
<label>Quality (colors)</label>
<input type="number" id="quality" value="256" min="8" max="256">
</div>
</div>
<div class="row">
<div>
<label>Export Scale</label>
<select id="scale">
<option value="1">1x</option>
<option value="2" selected>2x</option>
<option value="3">3x</option>
</select>
</div>
<div>
<label>Loop</label>
<select id="loop">
<option value="true" selected>Infinite</option>
<option value="false">Play once</option>
</select>
</div>
</div>
<button id="generateBtn">Export Selection & Generate GIF</button>
<div id="status"></div>
<div id="results"></div>
<script>
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const generateBtn = document.getElementById('generateBtn');
// ── Step 1: User clicks Generate → ask main thread to export frames ──
generateBtn.addEventListener('click', () => {
generateBtn.disabled = true;
statusEl.textContent = 'Exporting frames from Figma...';
resultsEl.innerHTML = '';
const scale = parseInt(document.getElementById('scale').value);
parent.postMessage({ pluginMessage: { type: 'EXPORT_FRAMES', scale } }, '*');
});
// ── Step 2: Receive exported PNGs from main thread → upload to API ──
window.onmessage = async (event) => {
const msg = event.data.pluginMessage;
if (!msg) return;
if (msg.type === 'ERROR') {
statusEl.textContent = 'Error: ' + msg.error;
generateBtn.disabled = false;
return;
}
if (msg.type !== 'FRAMES_READY') return;
statusEl.textContent = `Got ${msg.frames.length} frames. Uploading...`;
try {
const endpoint = document.getElementById('endpoint').value;
const gifName = document.getElementById('gifName').value || 'animation';
const delay = parseInt(document.getElementById('delay').value) || 500;
const quality = parseInt(document.getElementById('quality').value) || 256;
const loop = document.getElementById('loop').value === 'true';
// Build FormData with PNG files
const formData = new FormData();
const fileNames = [];
msg.frames.forEach((frame, i) => {
// Convert Uint8Array → Blob → File for FormData
const blob = new Blob([frame.bytes], { type: 'image/png' });
const fileName = frame.name || `frame_${i}.png`;
formData.append('files[]', new File([blob], fileName, { type: 'image/png' }));
fileNames.push(fileName);
});
// Build config JSON
const config = {
groups: {
[gifName]: {
files: fileNames,
delay: delay,
loop: loop,
}
},
quality: quality,
};
formData.append('json', JSON.stringify(config));
// POST to the GIF Encoder API
// DO NOT set Content-Type header — browser sets it with the multipart boundary
const response = await fetch(endpoint, {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data.success && data.results) {
const succeeded = data.results.filter(r => r.success);
const failed = data.results.filter(r => !r.success);
statusEl.textContent = `Done! ${succeeded.length} GIF(s) generated.`;
// Show results with preview and download link
succeeded.forEach(r => {
const div = document.createElement('div');
div.className = 'result';
div.innerHTML = `
<img src="data:image/gif;base64,${r.base64}" alt="${r.group}">
<br><a href="${r.download_url}" target="_blank">${r.group}.gif — Download</a>
`;
resultsEl.appendChild(div);
});
failed.forEach(r => {
const div = document.createElement('div');
div.className = 'result';
div.style.color = '#ff4444';
div.textContent = `${r.group}: ${r.error}`;
resultsEl.appendChild(div);
});
parent.postMessage({
pluginMessage: { type: 'GENERATION_COMPLETE', count: succeeded.length }
}, '*');
} else {
throw new Error(data.error || 'Unknown error');
}
} catch (err) {
statusEl.textContent = 'Error: ' + err.message;
parent.postMessage({
pluginMessage: { type: 'GENERATION_ERROR', error: err.message }
}, '*');
}
generateBtn.disabled = false;
};
</script>
</body>
</html>
How It Works
- User selects frames in Figma and clicks Export Selection & Generate GIF.
code.jscallsexportAsync({ format: 'PNG' })on each selected node.- The raw
Uint8Arraybytes are sent to the UI iframe viapostMessage. ui.htmlconverts eachUint8Arrayto aBlob, wraps it inFormData, and POSTs to the API.- The API returns JSON with a base64-encoded GIF and a download URL.
- The plugin shows a preview and download link inline.
Batch Mode: Multiple GIFs
To generate multiple GIFs from one export, organise frames by naming convention. For example, if your Figma frames are named:
banner_frame1
banner_frame2
banner_frame3
icon_frame1
icon_frame2
Group them in the config by prefix:
// In ui.html, after receiving frames:
const groups = {};
msg.frames.forEach((frame) => {
// Split on last underscore: "banner_frame1" → group "banner"
const parts = frame.name.replace('.png', '').split('_');
parts.pop(); // remove frameN
const groupName = parts.join('_') || 'default';
if (!groups[groupName]) groups[groupName] = [];
groups[groupName].push(frame.name);
});
// Build config with multiple groups
const config = {
groups: {},
quality: quality,
};
Object.entries(groups).forEach(([name, files]) => {
config.groups[name] = {
files: files,
delay: delay,
loop: loop,
};
});
This generates one GIF per naming group in a single API call.
Per-Frame Delays from Frame Names
If different frames need different timings, encode it in the Figma frame name:
frame1_500ms
frame2_200ms
frame3_800ms
Then parse in ui.html:
const delays = [];
const fileNames = [];
msg.frames.forEach((frame) => {
fileNames.push(frame.name);
const match = frame.name.match(/_(\d+)ms/);
delays.push(match ? parseInt(match[1]) : defaultDelay);
});
config.groups[gifName] = {
files: fileNames,
delays: delays, // per-frame array
loop: loop,
};
Figma Troubleshooting
CORS errors — The API sends Access-Control-Allow-Origin: * by default. Make sure https://ai-sandbox.oliver.solutions is in allowedDomains in your manifest.json.
CSP blocked — If requests silently fail, open the Figma developer console (Plugins > Development > Open Console) and look for CSP errors. Add the blocked domain to allowedDomains.
"No frames selected" — Select actual frame/group/component nodes in Figma before clicking generate. Text and vector nodes also work with exportAsync.
Large exports timing out — Lower the export scale (1x instead of 2x), export in smaller batches, or increase PHP max_execution_time on the server.
Uint8Array issues — Only Uint8Array can cross the postMessage boundary in Figma plugins. Don't try to send ArrayBuffer, Blob, or other typed arrays from the main thread.
General Troubleshooting
GIFs not generating / empty results
- Check that
shell_execis enabled inphp.ini. - Verify the venv exists:
ls venv/bin/python3 - Test the encoder directly:
venv/bin/python3 encoder.py --input test.png --output test.gif
Upload fails or files missing
- Check
upload_max_filesizeandpost_max_sizeinphp.ini. - Verify
uploads/andoutput/are writable by the PHP process.
RGBA PNGs look wrong
- The encoder composites RGBA onto a white background since GIF doesn't support full alpha transparency. This is expected.
Large file sizes
- Lower the quality slider (fewer colors = smaller GIFs).
- Use shorter delays — fewer unique frames = better compression.