gif-encoder/FIGMA-INTEGRATION.md
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

438 lines
12 KiB
Markdown

# 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
```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.
```javascript
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.
```html
<!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
1. User selects frames in Figma and clicks **Export Selection & Generate GIF**.
2. `code.js` calls `exportAsync({ format: 'PNG' })` on each selected node.
3. The raw `Uint8Array` bytes are sent to the UI iframe via `postMessage`.
4. `ui.html` converts each `Uint8Array` to a `Blob`, wraps it in `FormData`, and POSTs to the API.
5. The API returns JSON with a base64-encoded GIF and a download URL.
6. 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:
```javascript
// 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`:
```javascript
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 `allowedDomains` in `manifest.json`.
- For localhost during development, use `devAllowedDomains`.
- Make sure you're NOT manually setting `Content-Type` on 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_time` on 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](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)