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

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

  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:

// 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 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 for full API documentation including:

  • Request/response schemas
  • curl examples for testing
  • Multipart vs JSON body modes
  • All available parameters (delays, quality, loop)