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>
12 KiB
Figma Plugin Integration Guide
Connect your Figma plugin to the GIF Encoder API to export selected frames as PNGs and receive animated GIFs back.
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!")
Plugin Setup
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://yourserver.com"
],
"devAllowedDomains": [
"http://localhost:8888",
"http://localhost:8000"
],
"reasoning": "Uploads exported PNG frames to the GIF Encoder API."
}
}
Replace yourserver.com with your production server domain. devAllowedDomains allows localhost during development (MAMP on 8888, or PHP built-in server on 8000).
Plugin Code
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="http://localhost:8888/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) => {
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
You can 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
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,
};
Troubleshooting
CORS errors
The GIF Encoder API sends Access-Control-Allow-Origin: * by default. If you still see CORS errors:
- Check that the domain is in
allowedDomainsinmanifest.json. - For localhost during development, use
devAllowedDomains. - Make sure you're NOT manually setting
Content-Typeon the fetch — let the browser handle the multipart boundary.
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"
Make sure you've selected actual frame/group/component nodes in Figma before clicking generate. Text and vector nodes also work with exportAsync.
Large exports timing out
For many frames or high-resolution exports:
- Lower the export scale (1x instead of 2x).
- Export in smaller batches.
- Increase PHP
max_execution_timeon the server.
Uint8Array issues
Only Uint8Array can cross the postMessage boundary in Figma plugins. If you see transfer errors, make sure you're not trying to send ArrayBuffer, Blob, or other typed arrays from the main thread.
API Reference
See the main README.md for full API documentation including:
- Request/response schemas
- curl examples for testing
- Multipart vs JSON body modes
- All available parameters (delays, quality, loop)