No description
Find a file
DJP 254017eb71 Disable directory listing in .htaccess
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:24:08 -05:00
assets Initial commit — GIF Encoder tool 2026-02-25 12:05:09 -05:00
.gitignore Merge remote and resolve .gitignore conflict 2026-02-25 12:05:50 -05:00
.htaccess Disable directory listing in .htaccess 2026-02-25 13:24:08 -05:00
api.php Initial commit — GIF Encoder tool 2026-02-25 12:05:09 -05:00
encoder.py Initial commit — GIF Encoder tool 2026-02-25 12:05:09 -05:00
FIGMA-INTEGRATION.md Update README with live URLs, install guide, and full Figma integration docs 2026-02-25 12:35:32 -05:00
index.php Initial commit — GIF Encoder tool 2026-02-25 12:05:09 -05:00
process.php Initial commit — GIF Encoder tool 2026-02-25 12:05:09 -05:00
README.md Update README with live URLs, install guide, and full Figma integration docs 2026-02-25 12:35:32 -05:00

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_exec enabled
  • 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

  1. Open https://ai-sandbox.oliver.solutions/GIF-ENCODER/ in your browser.
  2. Drag PNG files onto the upload zone (or click to browse).
  3. Uploaded PNGs appear in the file pool. Drag them into GIF group cards.
  4. Click + Add Group to create additional GIF sets (up to 10+).
  5. 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.
  6. Set the Quality slider (global — controls GIF color palette size).
  7. Click Generate GIFs.
  8. 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.

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

  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

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_exec is enabled in php.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_filesize and post_max_size in php.ini.
  • Verify uploads/ and output/ 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.