GIMP
Un modèle pilote GIMP 3.0.4 par le Hub pour dessiner une bannière sur deux calques et l’exporter en PNG. Cette page donne le texte qu’il a reçu, son script, les réponses du Hub et l’image, avec l’erreur que son premier script a cachée : il s’est exécuté sans échec et a exporté une seule couleur unie.

Sur cette page
Démarrer GIMP avec son pont
Le pont tourne dans le Python de GIMP et ne démarre jamais de lui-même. Cette exécution a utilisé la commande du guide pour un GIMP lancé sans son interface. La machine n’avait pas d’écran, et GIMP s’arrête sans affichage X même avec --no-interface : xvfb-run -a lui en a donc donné un virtuel.
LIBREMODULAR_BRIDGE_PORT=9301 \
LIBREMODULAR_TOKEN_PATH=$HOME/.config/libremodular-mcp/token \
xvfb-run -a gimp-3.0 --new-instance --no-data --no-fonts --no-interface --batch-interpreter python-fu-eval \
-b "exec(open('/usr/share/libremodular-mcp/bridges/gimp_bridge.py', encoding='utf-8').read()); serve_main_thread()"
L’exécution elle-même a utilisé le port 9311, un répertoire personnel neuf et un jeton neuf. Le port s’est mis à l’écoute 5,5 s après le démarrage de GIMP. get_state sur hub a alors listé GIMP comme connecté, avec la version que GIMP déclarait :
{
"app": "hub",
"app_version": "0.1.0",
"bridge_version": null,
"captured_at": "2026-10-21T12:47:26.453Z",
"connected": true,
"content_origin": "hub",
"screenshot": null,
"state": {
"modules": [
{
"app_version": "3.0.4",
"bridge_version": "1",
"connected": true,
"mode": "tcp",
"name": "gimp"
}
]
},
"warnings": []
}Texte donné au modèle
Avant d’écrire, le modèle a appelé get_state sur gimp, comme le demande le guide du modèle. GIMP ne contenait aucune image : "images": [] et "active_image": null.
Script écrit
Un modèle, Claude Sonnet 5.5, a écrit ce script à partir du guide et de cet état vide. Ce n’est pas un script tiré des tests du projet.
import os
image = Gimp.Image.new(800, 450, Gimp.ImageBaseType.RGB)
sky = Gimp.Layer.new(image, "Sky", 800, 450, Gimp.ImageType.RGBA_IMAGE, 100.0, Gimp.LayerMode.NORMAL)
image.insert_layer(sky, None, 0)
Gimp.context_set_foreground(Gimp.color_parse_hex("#1c385c"))
Gimp.context_set_background(Gimp.color_parse_hex("#29a196"))
sky.edit_gradient_fill(Gimp.GradientType.LINEAR, 0.0, False, 1, 0.0, True, 0.0, 0.0, 0.0, 450.0)
sun = Gimp.Layer.new(image, "Sun", 800, 450, Gimp.ImageType.RGBA_IMAGE, 100.0, Gimp.LayerMode.NORMAL)
image.insert_layer(sun, None, 0)
sun.fill(Gimp.FillType.TRANSPARENT)
image.select_ellipse(Gimp.ChannelOps.REPLACE, 533 - 60, 150 - 60, 120, 120)
Gimp.context_set_foreground(Gimp.color_parse_hex("#f2b344"))
sun.fill(Gimp.FillType.FOREGROUND)
Gimp.Selection.none(image)
path = os.path.join(LMM_WORK_DIR, "banner.png")
Gimp.file_save(Gimp.RunMode.NONINTERACTIVE, image, Gio.File.new_for_path(path), None)
result = {"layers": [layer.get_name() for layer in image.get_layers()], "png_bytes": os.path.getsize(path)}La réponse du Hub
{
"app": "gimp",
"duration_ms": 432,
"error": null,
"result": {
"layers": [
"Sun",
"Sky"
],
"png_bytes": 3594
},
"status": "ok",
"stderr": "",
"stdout": "",
"truncated": false
}status vaut ok, error vaut null et result est ce que le script a défini. Rien dans la réponse ne dit que l’image est fausse. Le modèle a lu le PNG que le script avait exporté :

Ce que la première image a montré
L’image a partout la couleur du cercle. Un deuxième script, envoyé à l’image ouverte, a lu deux choses que la réponse ne donnait pas : la sélection après select_ellipse, et les couleurs du calque Sky en haut et en bas.
image = images()[0]
sun = [layer for layer in image.get_layers() if layer.get_name() == "Sun"][0]
sky = [layer for layer in image.get_layers() if layer.get_name() == "Sky"][0]
ok = image.select_ellipse(Gimp.ChannelOps.REPLACE, 473, 90, 120, 120)
print("select_ellipse returned", ok)
print("bounds", Gimp.Selection.bounds(image))
print("is_empty", Gimp.Selection.is_empty(image))
sky_top = sky.get_pixel(5, 5)
sky_bottom = sky.get_pixel(5, 444)
print("sky pixels", sky_top.get_rgba(), sky_bottom.get_rgba()){
"app": "gimp",
"duration_ms": 141,
"error": null,
"result": null,
"status": "ok",
"stderr": "",
"stdout": "select_ellipse returned True\nbounds (True, non_empty=True, x1=473, y1=90, x2=593, y2=210)\nis_empty False\nsky pixels (red=0.011612247675657272, green=0.04091521352529526, blue=0.10702310502529144, alpha=1.0) (red=0.022173885256052017, green=0.35153278708457947, blue=0.3005438446998596, alpha=1.0)\n",
"truncated": false
}La sélection était juste (x1=473, y1=90, x2=593, y2=210, 120 par 120 pixels) et le dégradé aussi : le pixel du haut de Sky est bleu foncé et celui du bas turquoise, en valeurs linéaires. Le calque Sun avait donc été rempli en entier.
Un troisième script a mesuré la cause sur une image neuve de 100 par 100, la sélection étant définie. Il a rempli un calque transparent avec layer.fill et un autre avec layer.edit_fill, puis a lu l’alpha d’un pixel au centre et d’un pixel dans le coin :
image = Gimp.Image.new(100, 100, Gimp.ImageBaseType.RGB)
Gimp.context_set_foreground(Gimp.color_parse_hex("#f2b344"))
image.select_ellipse(Gimp.ChannelOps.REPLACE, 30, 30, 40, 40)
result = {}
for name, method in (("fill", "fill"), ("edit_fill", "edit_fill")):
layer = Gimp.Layer.new(image, name, 100, 100, Gimp.ImageType.RGBA_IMAGE, 100.0, Gimp.LayerMode.NORMAL)
image.insert_layer(layer, None, 0)
layer.fill(Gimp.FillType.TRANSPARENT)
getattr(layer, method)(Gimp.FillType.FOREGROUND)
result[name] = {"alpha_centre": layer.get_pixel(50, 50).get_rgba().alpha, "alpha_corner": layer.get_pixel(2, 2).get_rgba().alpha}
image.delete(){
"app": "gimp",
"duration_ms": 27,
"error": null,
"result": {
"edit_fill": {
"alpha_centre": 1.0,
"alpha_corner": 0.0
},
"fill": {
"alpha_centre": 1.0,
"alpha_corner": 1.0
}
},
"status": "ok",
"stderr": "",
"stdout": "",
"truncated": false
}layer.fill ignore la sélection : son pixel de coin est opaque. layer.edit_fill la respecte : son pixel de coin est transparent.
Le script corrigé et sa réponse
Seul ce qui a échoué a changé. Le script vide le calque Sun, le remplit de nouveau avec edit_fill alors que la sélection est toujours définie, et exporte l’image de nouveau par-dessus banner.png.
import os
image = images()[0]
sun = [layer for layer in image.get_layers() if layer.get_name() == "Sun"][0]
print("is_empty before", Gimp.Selection.is_empty(image))
sun.fill(Gimp.FillType.TRANSPARENT)
Gimp.context_set_foreground(Gimp.color_parse_hex("#f2b344"))
sun.edit_fill(Gimp.FillType.FOREGROUND)
Gimp.Selection.none(image)
inside = sun.get_pixel(533, 150).get_rgba()
outside = sun.get_pixel(5, 5).get_rgba()
path = os.path.join(LMM_WORK_DIR, "banner.png")
Gimp.file_save(Gimp.RunMode.NONINTERACTIVE, image, Gio.File.new_for_path(path), None)
result = {"sun_inside_alpha": inside.alpha, "sun_outside_alpha": outside.alpha, "png_bytes": os.path.getsize(path)}{
"app": "gimp",
"duration_ms": 2246,
"error": null,
"result": {
"png_bytes": 232912,
"sun_inside_alpha": 1.0,
"sun_outside_alpha": 0.0
},
"status": "ok",
"stderr": "",
"stdout": "is_empty before False\n",
"truncated": false
}Le fichier exporté fait 232 912 octets, contre 3 594 pour l’image unie. Le calque Sun est opaque au centre du cercle (sun_inside_alpha) et transparent au pixel (5, 5) (sun_outside_alpha).
L’image produite
Voici le banner.png exporté, de 800 par 450 pixels. Les pixels du haut et du bas valent srgba(28,57,92,1) et srgba(41,160,149,1), à un niveau près de #1c385c et de #29a196 : le dégradé est tramé, si bien qu’un rejeu diffère d’un niveau sur certains pixels.
