gimp-mcp: clean-room BSD-3 GIMP PDB bridge
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
*.xcf~
|
||||
@@ -0,0 +1,28 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2026 Hanzo AI
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,2 @@
|
||||
hanzo-gimp-mcp -- clean-room BSD-3 implementation by Hanzo AI; no GPL lineage.
|
||||
Drives GIMP via its documented PDB API.
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# Hanzo GIMP Bridge Protocol
|
||||
|
||||
A line-oriented JSON protocol over a plain TCP socket. The bridge plug-in
|
||||
(`hanzo_gimp_bridge.py`) runs inside GIMP 3.0 and listens, by default, on
|
||||
`127.0.0.1:9876` (override with the `HANZO_GIMP_PORT` / `HANZO_GIMP_HOST`
|
||||
environment variables before starting GIMP).
|
||||
|
||||
This is a **clean-room BSD-3** design. Every operation maps onto GIMP's own
|
||||
documented Procedural Database (PDB) and the `Gimp.*` GObject-Introspection
|
||||
API. There is no GPL lineage.
|
||||
|
||||
## Framing
|
||||
|
||||
* One JSON object per line, UTF-8, terminated by a single `\n`.
|
||||
* The client sends one **request** object per line.
|
||||
* The server replies with exactly one **response** object per request,
|
||||
newline-terminated.
|
||||
* Multiple requests may be pipelined on one connection.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{"id": <any>, "method": "<string>", "params": { ... }}
|
||||
```
|
||||
|
||||
* `id` — any JSON value; echoed back so clients can correlate replies.
|
||||
* `method` — one of the methods below.
|
||||
* `params` — method-specific object (may be omitted / empty).
|
||||
|
||||
### Response
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{"id": <any>, "result": <any>}
|
||||
```
|
||||
|
||||
Error:
|
||||
|
||||
```json
|
||||
{"id": <any>, "error": {"message": "<string>", "type": "<ExceptionClass>"}}
|
||||
```
|
||||
|
||||
## Object ids
|
||||
|
||||
GIMP 3.0 PDB procedures operate on live objects (images, layers, drawables,
|
||||
paths). Because JSON can only carry primitives, the bridge exchanges **integer
|
||||
ids**. Where a PDB procedure expects an `Image`, `Item`, `Drawable`, `Layer`,
|
||||
`Channel`, or `Path`, pass its integer id and the bridge rehydrates the live
|
||||
object. Returned objects are likewise reduced to integer ids.
|
||||
|
||||
## Methods
|
||||
|
||||
| Method | Params | Result |
|
||||
|-------------------|------------------------------------------|--------|
|
||||
| `version` | — | `{bridge, gimp, protocol}` |
|
||||
| `pdb_call` | `procedure: str`, `args: list` | the procedure's return value(s) (ids for objects), or `null` |
|
||||
| `open_image` | `path: str` | `{image_id, width, height}` |
|
||||
| `export` | `image_id: int`, `path: str` | `{image_id, path, saved}` |
|
||||
| `new_image` | `width: int`, `height: int` | `{image_id, width, height}` |
|
||||
| `image_info` | `image_id: int` | `{image_id, width, height, base_type, precision, layers, n_layers, file, dirty}` |
|
||||
| `list_procedures` | `prefix?: str` | `{count, procedures: [str, ...]}` |
|
||||
| `flatten` | `image_id: int` | `{image_id, layer_id}` |
|
||||
|
||||
### `pdb_call` — the core power
|
||||
|
||||
`pdb_call` is a generic invocation of any procedure in GIMP's PDB. Anything
|
||||
GIMP can do is reachable through it. The bridge:
|
||||
|
||||
1. Looks up the procedure by name.
|
||||
2. If the first PDB argument is a run-mode and you did not supply one,
|
||||
prepends `NONINTERACTIVE`.
|
||||
3. Coerces integer ids in `args` to the live `Image` / `Item` / `Gio.File`
|
||||
objects the procedure expects.
|
||||
4. Runs it and returns the values after the PDB status code (object results
|
||||
reduced to ids). A non-`SUCCESS` status becomes an `error` response.
|
||||
|
||||
Use `list_procedures` to discover names (e.g. prefix `"gimp-image-"`).
|
||||
|
||||
## Examples
|
||||
|
||||
Assuming a raw TCP connection to `127.0.0.1:9876`:
|
||||
|
||||
**Version**
|
||||
|
||||
```
|
||||
--> {"id": 1, "method": "version"}
|
||||
<-- {"id": 1, "result": {"bridge": "1.0.0", "gimp": "3.0.0", "protocol": "hanzo-gimp/1"}}
|
||||
```
|
||||
|
||||
**Open an image**
|
||||
|
||||
```
|
||||
--> {"id": 2, "method": "open_image", "params": {"path": "/tmp/in.png"}}
|
||||
<-- {"id": 2, "result": {"image_id": 1, "width": 1920, "height": 1080}}
|
||||
```
|
||||
|
||||
**Generic PDB call — Gaussian blur on the active drawable**
|
||||
|
||||
```
|
||||
--> {"id": 3, "method": "pdb_call",
|
||||
"params": {"procedure": "plug-in-gauss", "args": [1, <drawable_id>, 15, 15, 0]}}
|
||||
<-- {"id": 3, "result": null}
|
||||
```
|
||||
|
||||
**Flatten and export**
|
||||
|
||||
```
|
||||
--> {"id": 4, "method": "flatten", "params": {"image_id": 1}}
|
||||
<-- {"id": 4, "result": {"image_id": 1, "layer_id": 7}}
|
||||
--> {"id": 5, "method": "export", "params": {"image_id": 1, "path": "/tmp/out.png"}}
|
||||
<-- {"id": 5, "result": {"image_id": 1, "path": "/tmp/out.png", "saved": true}}
|
||||
```
|
||||
|
||||
**Discover procedures**
|
||||
|
||||
```
|
||||
--> {"id": 6, "method": "list_procedures", "params": {"prefix": "gimp-image-"}}
|
||||
<-- {"id": 6, "result": {"count": 42, "procedures": ["gimp-image-add-layer", ...]}}
|
||||
```
|
||||
|
||||
**Error shape**
|
||||
|
||||
```
|
||||
--> {"id": 7, "method": "pdb_call", "params": {"procedure": "no-such-proc", "args": []}}
|
||||
<-- {"id": 7, "error": {"message": "unknown PDB procedure: no-such-proc", "type": "ValueError"}}
|
||||
```
|
||||
|
||||
## Concurrency
|
||||
|
||||
GIMP/GEGL are single-threaded. The bridge accepts connections on worker
|
||||
threads but marshals every request onto GIMP's GLib main loop, so operations
|
||||
execute serially in the order GIMP receives them.
|
||||
@@ -0,0 +1,101 @@
|
||||
# hanzo-gimp-mcp
|
||||
|
||||
A **clean-room, BSD-3-licensed** bridge that lets external tools drive
|
||||
[GIMP](https://www.gimp.org/) 3.0 programmatically.
|
||||
|
||||
It ships one GIMP 3.0 Python plug-in, `hanzo_gimp_bridge.py`, which registers
|
||||
itself in GIMP and runs a small **JSON-over-TCP server** inside the running
|
||||
GIMP process. Clients connect to that socket and invoke GIMP through its
|
||||
documented **Procedural Database (PDB)** and `Gimp.*` GObject-Introspection
|
||||
API.
|
||||
|
||||
* Protocol: see [`PROTOCOL.md`](./PROTOCOL.md).
|
||||
* The Hanzo MCP `gimp` tool (TypeScript) and `hanzo-tools-gimp` (Python)
|
||||
connect to this bridge and speak the same protocol.
|
||||
|
||||
## Clean-room / licensing
|
||||
|
||||
This implementation is an **original, clean-room rewrite**. It was designed
|
||||
solely from GIMP's own public API documentation (the PDB and the GIMP 3.0
|
||||
Python/GI bindings). It has **no GPL lineage** and reuses no code from any
|
||||
prior GPL GIMP-MCP implementation.
|
||||
|
||||
* License: **BSD-3-Clause** (see [`LICENSE`](./LICENSE)).
|
||||
* Copyright (c) 2026 Hanzo AI.
|
||||
* See [`NOTICE`](./NOTICE).
|
||||
|
||||
GIMP itself remains GPL and is a separate program; this bridge merely talks to
|
||||
it over a socket using its public, documented interfaces — the same way any
|
||||
external automation client would.
|
||||
|
||||
## Install
|
||||
|
||||
GIMP 3.0 loads Python plug-ins from per-user plug-in directories. Each plug-in
|
||||
must live in **its own folder whose name matches the script**, and the script
|
||||
must be **executable**.
|
||||
|
||||
### Linux
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/GIMP/3.0/plug-ins/hanzo_gimp_bridge
|
||||
cp hanzo_gimp_bridge.py ~/.config/GIMP/3.0/plug-ins/hanzo_gimp_bridge/
|
||||
chmod +x ~/.config/GIMP/3.0/plug-ins/hanzo_gimp_bridge/hanzo_gimp_bridge.py
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
```sh
|
||||
mkdir -p "~/Library/Application Support/GIMP/3.0/plug-ins/hanzo_gimp_bridge"
|
||||
cp hanzo_gimp_bridge.py "~/Library/Application Support/GIMP/3.0/plug-ins/hanzo_gimp_bridge/"
|
||||
chmod +x "~/Library/Application Support/GIMP/3.0/plug-ins/hanzo_gimp_bridge/hanzo_gimp_bridge.py"
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Copy `hanzo_gimp_bridge.py` into:
|
||||
|
||||
```
|
||||
%APPDATA%\GIMP\3.0\plug-ins\hanzo_gimp_bridge\hanzo_gimp_bridge.py
|
||||
```
|
||||
|
||||
You can also confirm/extend the search paths in GIMP under
|
||||
**Edit -> Preferences -> Folders -> Plug-ins**.
|
||||
|
||||
## Run
|
||||
|
||||
1. Start GIMP 3.0. (Optionally set the port first:
|
||||
`HANZO_GIMP_PORT=9876 gimp`. `HANZO_GIMP_HOST` defaults to `127.0.0.1`.)
|
||||
2. In GIMP, run **Filters -> Hanzo -> Start Hanzo Bridge**. GIMP shows a
|
||||
message like `Hanzo GIMP bridge listening on 127.0.0.1:9876`.
|
||||
3. The bridge now accepts JSON-line requests on that port.
|
||||
|
||||
A headless variant is possible with GIMP's batch mode; the simplest reliable
|
||||
setup is an interactive GIMP session with the bridge started from the menu.
|
||||
|
||||
## Quick test
|
||||
|
||||
With the bridge running, from any shell:
|
||||
|
||||
```sh
|
||||
printf '{"id":1,"method":"version"}\n' | nc 127.0.0.1 9876
|
||||
```
|
||||
|
||||
You should get back a `version` result.
|
||||
|
||||
## Connecting from Hanzo
|
||||
|
||||
* **TypeScript MCP**: the `gimp` tool in `hanzo/mcp`
|
||||
(`src/tools/gimp.ts`) exposes actions
|
||||
(`version`, `open`, `export`, `pdb_call`, `new_image`, `image_info`,
|
||||
`list_procedures`, `flatten`) and forwards them to this bridge.
|
||||
* **Python**: `hanzo-tools-gimp` provides `GimpTool` with the same action
|
||||
surface.
|
||||
|
||||
Both default to `127.0.0.1:9876` and accept `host` / `port` overrides.
|
||||
|
||||
## Methods at a glance
|
||||
|
||||
`version`, `pdb_call`, `open_image`, `export`, `new_image`, `image_info`,
|
||||
`list_procedures`, `flatten`. `pdb_call` is the generic escape hatch: it can
|
||||
invoke **any** PDB procedure, so anything GIMP can do is reachable. See
|
||||
[`PROTOCOL.md`](./PROTOCOL.md) for full details and examples.
|
||||
@@ -0,0 +1,467 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
# Copyright (c) 2026 Hanzo AI
|
||||
#
|
||||
# hanzo_gimp_bridge.py -- clean-room BSD-3 GIMP 3.0 bridge plug-in.
|
||||
#
|
||||
# This is a GIMP 3.0 Python plug-in (GObject-Introspection style) that runs a
|
||||
# small line-oriented JSON TCP server inside GIMP. External clients (the Hanzo
|
||||
# MCP "gimp" tool, hanzo-tools-gimp, or anything that can open a socket) drive
|
||||
# GIMP entirely through its documented Procedural Database (PDB) and the
|
||||
# Gimp.* introspected API. No GPL lineage: every call below is derived from
|
||||
# GIMP's own public API documentation.
|
||||
#
|
||||
# Protocol (see PROTOCOL.md):
|
||||
# request : {"id": <any>, "method": <str>, "params": {<...>}}\n
|
||||
# response : {"id": <any>, "result": <any>}\n
|
||||
# or {"id": <any>, "error": {"message": <str>, "type": <str>}}\n
|
||||
# One JSON object per line, UTF-8, newline-terminated.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gimp", "3.0")
|
||||
from gi.repository import Gimp, Gio, GLib, GObject # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults / configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 9876
|
||||
BRIDGE_VERSION = "1.0.0"
|
||||
|
||||
# Procedure registered inside GIMP to start the bridge from the Filters menu.
|
||||
PROC_START = "hanzo-gimp-bridge-start"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GIMP value <-> JSON marshalling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _gimp_object_to_id(obj: Any) -> Any:
|
||||
"""Reduce a GIMP object (image/drawable/layer/...) to its integer id.
|
||||
|
||||
GIMP 3.0 PDB procedures accept/return live objects, but a JSON wire
|
||||
protocol can only carry primitives, so we exchange integer ids and
|
||||
rehydrate them on demand via ``Gimp.Image.get_by_id`` and friends.
|
||||
"""
|
||||
if obj is None:
|
||||
return None
|
||||
for getter in ("get_id",):
|
||||
fn = getattr(obj, getter, None)
|
||||
if callable(fn):
|
||||
try:
|
||||
return int(fn())
|
||||
except Exception:
|
||||
pass
|
||||
return obj
|
||||
|
||||
|
||||
def _json_default(obj: Any) -> Any:
|
||||
"""Best-effort JSON fallback for non-primitive GIMP/GLib values."""
|
||||
reduced = _gimp_object_to_id(obj)
|
||||
if reduced is not obj:
|
||||
return reduced
|
||||
if isinstance(obj, (bytes, bytearray)):
|
||||
return list(obj)
|
||||
return str(obj)
|
||||
|
||||
|
||||
def _image_by_id(image_id: int) -> Gimp.Image:
|
||||
img = Gimp.Image.get_by_id(int(image_id))
|
||||
if img is None:
|
||||
raise ValueError(f"no image with id {image_id}")
|
||||
return img
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic PDB invocation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _coerce_arg(spec: GObject.ParamSpec, value: Any) -> Any:
|
||||
"""Coerce a JSON value to the type a PDB argument expects.
|
||||
|
||||
Integer ids are rehydrated into the GIMP object the PDB wants
|
||||
(Image / Drawable / Layer / Item), so callers can pass plain ints.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
value_type = spec.value_type
|
||||
|
||||
# Object arguments: accept an int id, fetch the live object.
|
||||
if GObject.type_is_a(value_type, Gimp.Image.__gtype__):
|
||||
return Gimp.Image.get_by_id(int(value))
|
||||
if GObject.type_is_a(value_type, Gimp.Item.__gtype__):
|
||||
# Covers Drawable, Layer, Channel, Vectors/Path -- all Items.
|
||||
return Gimp.Item.get_by_id(int(value))
|
||||
if GObject.type_is_a(value_type, Gio.File.__gtype__):
|
||||
return Gio.File.new_for_path(str(value))
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _pdb_call(procedure: str, args: list[Any]) -> Any:
|
||||
"""Invoke an arbitrary PDB procedure -- the core power of the bridge.
|
||||
|
||||
Args:
|
||||
procedure: PDB procedure name, e.g. "gimp-image-flatten".
|
||||
args: Positional arguments in PDB order. Integer ids are accepted
|
||||
where the procedure expects a GIMP object.
|
||||
|
||||
Returns:
|
||||
``None`` if the procedure returns only a status,
|
||||
a single value if it returns exactly one result,
|
||||
a list of values if it returns several.
|
||||
GIMP objects are reduced to their integer ids.
|
||||
"""
|
||||
pdb = Gimp.get_pdb()
|
||||
proc = pdb.lookup_procedure(procedure)
|
||||
if proc is None:
|
||||
raise ValueError(f"unknown PDB procedure: {procedure}")
|
||||
|
||||
arg_specs = proc.get_arguments()
|
||||
config = proc.create_config()
|
||||
|
||||
# The first PDB arg of most procedures is a run-mode; default it to
|
||||
# NONINTERACTIVE when the caller did not supply it.
|
||||
supplied = list(args)
|
||||
if arg_specs and arg_specs[0].value_type == Gimp.RunMode.__gtype__:
|
||||
if len(supplied) < len(arg_specs):
|
||||
supplied = [int(Gimp.RunMode.NONINTERACTIVE)] + supplied
|
||||
|
||||
for spec, value in zip(arg_specs, supplied):
|
||||
config.set_property(spec.get_name(), _coerce_arg(spec, value))
|
||||
|
||||
result = proc.run(config)
|
||||
|
||||
status = result.index(0)
|
||||
if status != Gimp.PDBStatusType.SUCCESS:
|
||||
msg = ""
|
||||
try:
|
||||
msg = result.index(1)
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"PDB {procedure} failed: {status.value_nick}: {msg}")
|
||||
|
||||
# Collect return values after the status code.
|
||||
out: list[Any] = []
|
||||
for i in range(1, result.length()):
|
||||
out.append(_gimp_object_to_id(result.index(i)))
|
||||
|
||||
if not out:
|
||||
return None
|
||||
if len(out) == 1:
|
||||
return out[0]
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# High-level convenience methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _open_image(path: str) -> dict[str, Any]:
|
||||
gfile = Gio.File.new_for_path(path)
|
||||
image = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, gfile)
|
||||
return {
|
||||
"image_id": _gimp_object_to_id(image),
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height(),
|
||||
}
|
||||
|
||||
|
||||
def _export(image_id: int, path: str) -> dict[str, Any]:
|
||||
image = _image_by_id(image_id)
|
||||
gfile = Gio.File.new_for_path(path)
|
||||
# Gimp.file_save dispatches to the right exporter by file extension and
|
||||
# works for the common formats (png/jpg/tiff/webp/...). It exports a
|
||||
# flattened copy for non-native formats; the .xcf path is GIMP-native.
|
||||
Gimp.file_save(Gimp.RunMode.NONINTERACTIVE, image, gfile, None)
|
||||
return {"image_id": int(image_id), "path": path, "saved": True}
|
||||
|
||||
|
||||
def _new_image(width: int, height: int) -> dict[str, Any]:
|
||||
image = Gimp.Image.new(int(width), int(height), Gimp.ImageBaseType.RGB)
|
||||
return {
|
||||
"image_id": _gimp_object_to_id(image),
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height(),
|
||||
}
|
||||
|
||||
|
||||
def _image_info(image_id: int) -> dict[str, Any]:
|
||||
image = _image_by_id(image_id)
|
||||
layers = [_gimp_object_to_id(l) for l in image.get_layers()]
|
||||
gfile = image.get_file()
|
||||
return {
|
||||
"image_id": int(image_id),
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height(),
|
||||
"base_type": image.get_base_type().value_nick,
|
||||
"precision": image.get_precision().value_nick,
|
||||
"layers": layers,
|
||||
"n_layers": len(layers),
|
||||
"file": gfile.get_path() if gfile is not None else None,
|
||||
"dirty": bool(image.is_dirty()),
|
||||
}
|
||||
|
||||
|
||||
def _list_procedures(prefix: str | None = None) -> dict[str, Any]:
|
||||
pdb = Gimp.get_pdb()
|
||||
names = list(pdb.query_procedures("", "", "", "", "", "", ""))
|
||||
if prefix:
|
||||
names = [n for n in names if n.startswith(prefix)]
|
||||
names.sort()
|
||||
return {"count": len(names), "procedures": names}
|
||||
|
||||
|
||||
def _flatten(image_id: int) -> dict[str, Any]:
|
||||
image = _image_by_id(image_id)
|
||||
layer = image.flatten()
|
||||
return {"image_id": int(image_id), "layer_id": _gimp_object_to_id(layer)}
|
||||
|
||||
|
||||
def _version() -> dict[str, Any]:
|
||||
return {
|
||||
"bridge": BRIDGE_VERSION,
|
||||
"gimp": Gimp.version(),
|
||||
"protocol": "hanzo-gimp/1",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Method dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def dispatch(method: str, params: dict[str, Any]) -> Any:
|
||||
"""Route a single JSON-protocol request to its handler."""
|
||||
params = params or {}
|
||||
|
||||
if method == "version":
|
||||
return _version()
|
||||
if method == "pdb_call":
|
||||
return _pdb_call(params["procedure"], params.get("args", []))
|
||||
if method == "open_image":
|
||||
return _open_image(params["path"])
|
||||
if method == "export":
|
||||
return _export(params["image_id"], params["path"])
|
||||
if method == "new_image":
|
||||
return _new_image(params["width"], params["height"])
|
||||
if method == "image_info":
|
||||
return _image_info(params["image_id"])
|
||||
if method == "list_procedures":
|
||||
return _list_procedures(params.get("prefix"))
|
||||
if method == "flatten":
|
||||
return _flatten(params["image_id"])
|
||||
|
||||
raise ValueError(f"unknown method: {method}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TCP JSON line server
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BridgeServer:
|
||||
"""Line-oriented JSON TCP server that runs GIMP requests on the main loop.
|
||||
|
||||
GIMP/GEGL are not thread-safe, so every request is marshalled onto GIMP's
|
||||
GLib main loop via ``GLib.idle_add`` and the socket thread blocks on the
|
||||
result. This keeps all PDB work on the thread GIMP expects.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._sock: socket.socket | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._running = False
|
||||
|
||||
def start(self) -> None:
|
||||
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self._sock.bind((self.host, self.port))
|
||||
self._sock.listen(8)
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._accept_loop, daemon=True)
|
||||
self._thread.start()
|
||||
sys.stderr.write(
|
||||
f"[hanzo-gimp-bridge] listening on {self.host}:{self.port}\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
|
||||
def _accept_loop(self) -> None:
|
||||
assert self._sock is not None
|
||||
while self._running:
|
||||
try:
|
||||
conn, _addr = self._sock.accept()
|
||||
except OSError:
|
||||
break
|
||||
threading.Thread(
|
||||
target=self._handle_conn, args=(conn,), daemon=True
|
||||
).start()
|
||||
|
||||
def _handle_conn(self, conn: socket.socket) -> None:
|
||||
with conn:
|
||||
buf = b""
|
||||
while self._running:
|
||||
try:
|
||||
chunk = conn.recv(65536)
|
||||
except OSError:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
if not line.strip():
|
||||
continue
|
||||
reply = self._process_line(line)
|
||||
try:
|
||||
conn.sendall(reply)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
def _process_line(self, line: bytes) -> bytes:
|
||||
req_id: Any = None
|
||||
try:
|
||||
req = json.loads(line.decode("utf-8"))
|
||||
req_id = req.get("id")
|
||||
method = req.get("method")
|
||||
params = req.get("params") or {}
|
||||
if not method:
|
||||
raise ValueError("missing method")
|
||||
result = self._run_on_main(method, params)
|
||||
payload = {"id": req_id, "result": result}
|
||||
except Exception as exc: # noqa: BLE001 -- report any failure to client
|
||||
payload = {
|
||||
"id": req_id,
|
||||
"error": {
|
||||
"message": str(exc),
|
||||
"type": type(exc).__name__,
|
||||
},
|
||||
}
|
||||
return (json.dumps(payload, default=_json_default) + "\n").encode("utf-8")
|
||||
|
||||
def _run_on_main(self, method: str, params: dict[str, Any]) -> Any:
|
||||
"""Run ``dispatch`` on GIMP's main loop and block for the result."""
|
||||
done = threading.Event()
|
||||
box: dict[str, Any] = {}
|
||||
|
||||
def _invoke() -> bool:
|
||||
try:
|
||||
box["result"] = dispatch(method, params)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
box["error"] = exc
|
||||
box["traceback"] = traceback.format_exc()
|
||||
finally:
|
||||
done.set()
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
GLib.idle_add(_invoke)
|
||||
done.wait()
|
||||
if "error" in box:
|
||||
sys.stderr.write(box.get("traceback", ""))
|
||||
sys.stderr.flush()
|
||||
raise box["error"]
|
||||
return box.get("result")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._sock is not None:
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# Keep a module-level reference so the server outlives do_run.
|
||||
_SERVER: BridgeServer | None = None
|
||||
|
||||
|
||||
def _resolve_port() -> int:
|
||||
env = os.environ.get("HANZO_GIMP_PORT")
|
||||
if env:
|
||||
try:
|
||||
return int(env)
|
||||
except ValueError:
|
||||
pass
|
||||
return DEFAULT_PORT
|
||||
|
||||
|
||||
def _resolve_host() -> str:
|
||||
return os.environ.get("HANZO_GIMP_HOST", DEFAULT_HOST)
|
||||
|
||||
|
||||
def start_bridge() -> BridgeServer:
|
||||
"""Start (once) and return the module-level bridge server."""
|
||||
global _SERVER
|
||||
if _SERVER is None:
|
||||
_SERVER = BridgeServer(host=_resolve_host(), port=_resolve_port())
|
||||
_SERVER.start()
|
||||
return _SERVER
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GIMP 3.0 plug-in registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class HanzoGimpBridge(Gimp.PlugIn):
|
||||
"""GIMP 3.0 plug-in that exposes GIMP over a JSON TCP socket."""
|
||||
|
||||
# -- Gimp.PlugIn vfuncs --------------------------------------------------
|
||||
|
||||
def do_query_procedures(self) -> list[str]:
|
||||
return [PROC_START]
|
||||
|
||||
def do_create_procedure(self, name: str) -> Gimp.Procedure:
|
||||
if name != PROC_START:
|
||||
return None
|
||||
|
||||
procedure = Gimp.Procedure.new(
|
||||
self,
|
||||
name,
|
||||
Gimp.PDBProcType.PLUGIN,
|
||||
self.run_start,
|
||||
None,
|
||||
)
|
||||
procedure.set_menu_label("Start Hanzo Bridge")
|
||||
procedure.add_menu_path("<Image>/Filters/Hanzo")
|
||||
procedure.set_documentation(
|
||||
"Start the Hanzo GIMP JSON socket bridge",
|
||||
"Runs a local JSON-over-TCP server that drives GIMP through its "
|
||||
"PDB API. Clean-room BSD-3, no GPL lineage.",
|
||||
name,
|
||||
)
|
||||
procedure.set_attribution("Hanzo AI", "Hanzo AI", "2026")
|
||||
return procedure
|
||||
|
||||
# -- Procedure run callback ---------------------------------------------
|
||||
|
||||
def run_start(self, procedure, run_mode, image, drawables, config, run_data):
|
||||
try:
|
||||
server = start_bridge()
|
||||
Gimp.message(
|
||||
f"Hanzo GIMP bridge listening on "
|
||||
f"{server.host}:{server.port}"
|
||||
)
|
||||
return procedure.new_return_values(
|
||||
Gimp.PDBStatusType.SUCCESS, GLib.Error()
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return procedure.new_return_values(
|
||||
Gimp.PDBStatusType.EXECUTION_ERROR,
|
||||
GLib.Error(str(exc)),
|
||||
)
|
||||
|
||||
|
||||
Gimp.main(HanzoGimpBridge.__gtype__, sys.argv)
|
||||
@@ -0,0 +1,28 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hanzo-gimp-mcp"
|
||||
version = "1.0.0"
|
||||
description = "Clean-room BSD-3 GIMP 3.0 bridge: a GIMP plug-in exposing a JSON-over-TCP protocol that drives GIMP through its documented PDB API."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "BSD-3-Clause" }
|
||||
authors = [{ name = "Hanzo AI", email = "dev@hanzo.ai" }]
|
||||
keywords = ["hanzo", "gimp", "mcp", "pdb", "image", "bridge", "bsd-3-clause"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: BSD License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/hanzoai/gimp-mcp"
|
||||
Repository = "https://github.com/hanzoai/gimp-mcp"
|
||||
|
||||
# This package ships a single GIMP 3.0 plug-in module that you copy into the
|
||||
# GIMP plug-ins directory (see README). It is not a conventional importable
|
||||
# library, so no package discovery is configured.
|
||||
Reference in New Issue
Block a user