Initial commit: AYON Matrix Publish addon

- package.py: addon manifest (v0.1.0)
- server/__init__.py: BaseServerAddon definition
- server/ayon_matrix_publish/plugins/publish/integrate_matrix.py:
  Pyblish plugin that uploads published representations to Matrix
  rooms during the Integration stage. Supports:
  - Upload to Matrix content repository (mxc:// URI)
  - Formatted messages with preview images
  - Auto room creation per asset
  - Configurable via Studio Settings or env vars
- create_package.py: builds distributable .zip package
This commit is contained in:
Hermes
2026-06-06 13:51:31 +00:00
parent d50d70c01f
commit 222ad10ded
10 changed files with 730 additions and 1 deletions
+27
View File
@@ -0,0 +1,27 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.egg-info/
dist/
.eggs/
# Packages
*.zip
# IDE
.vscode/
.idea/
*.swp
*.swo
# Virtualenv
.venv/
venv/
# OS
.DS_Store
Thumbs.db
# AYON
*.ayon/
+73 -1
View File
@@ -1,3 +1,75 @@
# ayon-matrix-publish # ayon-matrix-publish
AYON addon that publishes product representations (images, videos, previews)
to Matrix/Element rooms automatically during the publish pipeline.
AYON addon: publishes products to Matrix/Element rooms on publish. Uploads representations (images, videos, files) to configurable Matrix rooms. ## Architecture
```
Ayon Publish Pipeline
Collect → Validate → Extract → Integrate
IntegrateMatrix plugin
┌───────────┴───────────┐
│ Matrix Client (mautrix) │
│ • Upload media │
│ • Send messages │
│ • Create rooms (opt.) │
└───────────────────────────┘
Synapse Homeserver
(matrix.niklashmotion.art)
```
## How It Works
1. **Artist publishes** a product in any DCC (Maya, Houdini, Nuke, Blender, etc.)
2. After extraction and standard integration, the `IntegrateMatrix` plugin runs
3. For each published representation (thumbnail, preview video, review):
- Uploads the file to the Matrix media repository → gets `mxc://` URI
- Sends a formatted message to a configured Matrix room with:
- Product name, version, asset, task
- Thumbnail/preview inline
- Link to the AYON product page
4. (Optional) Creates per-asset or per-task rooms automatically
## Configuration
Studio Settings → Matrix Publish:
| Setting | Description |
|---------|-------------|
| `homeserver_url` | Matrix homeserver (e.g. `https://matrix.niklashmotion.art`) |
| `access_token` | Bot access token (from Element → Settings → Help & About) |
| `room_id` | Target room ID (e.g. `!abc123:niklashmotion.art`) |
| `upload_representations` | List of representation names to upload (e.g. `["thumbnail", "preview"]`) |
| `create_per_asset_rooms` | Auto-create rooms per asset (bool) |
| `message_template` | Jinja2 template for the message text |
## Development
```bash
# Clone
git clone https://git.niklashmotion.art/Hermes/ayon-matrix-publish.git
cd ayon-matrix-publish
# Install AYON dev dependencies
pip install ayon-python-api mautrix
# Create package
python create_package.py
```
## Dependencies
- `mautrix` — Matrix client SDK (async)
- `ayon-python-api` — AYON server communication
- `Jinja2` — Message templating (bundled with AYON)
## Related
- [AYON Addon Development](https://docs.ayon.dev/docs/addon_development)
- [Matrix Client-Server API](https://spec.matrix.org/latest/client-server-api/)
- [Mautrix Python SDK](https://github.com/mautrix/python)
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""Matrix Publish addon — client-side (DCC host)."""
@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
"""Client-side plugins for Matrix Publish.
These plugins run inside the DCC host (Maya, Houdini, Nuke, etc.)
and are discovered by AYON's Pyblish plugin loader.
The publish plugin lives in ayon_matrix_publish/plugins/publish/.
"""
from ayon_core.pipeline import publish
# Import the plugin so Pyblish discovers it
from .plugins.publish.integrate_matrix import IntegrateMatrix
__all__ = ["IntegrateMatrix"]
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Create an AYON addon .zip package for distribution."""
import os
import sys
import zipfile
from pathlib import Path
def create_package():
root = Path(__file__).parent
# Read version
version_file = root / "server" / "ayon_matrix_publish" / "version.py"
version = "0.1.0"
if version_file.exists():
exec(version_file.read_text(), {}, {})
version = locals().get("__version__", version)
package_name = f"matrix_publish-{version}.zip"
package_path = root / package_name
print(f"Creating {package_name}...")
with zipfile.ZipFile(package_path, "w", zipfile.ZIP_DEFLATED) as zf:
# Add package.py
zf.write(root / "package.py", "package.py")
# Add server files
server_dir = root / "server"
for file_path in server_dir.rglob("*"):
if file_path.is_file() and "__pycache__" not in str(file_path):
arcname = str(file_path.relative_to(root))
zf.write(file_path, arcname)
print(f" + {arcname}")
# Add client files
client_dir = root / "client"
if client_dir.exists():
for file_path in client_dir.rglob("*"):
if file_path.is_file() and "__pycache__" not in str(file_path):
arcname = str(file_path.relative_to(root))
zf.write(file_path, arcname)
print(f" + {arcname}")
print(f"\nPackage created: {package_path}")
print(f"Size: {package_path.stat().st_size / 1024:.1f} KB")
if __name__ == "__main__":
create_package()
+17
View File
@@ -0,0 +1,17 @@
"""AYON Addon: Matrix Publish — sends published products to Matrix rooms."""
name = "matrix_publish"
title = "Matrix Publish"
version = "0.1.0"
client_dir = "ayon_matrix_publish"
# Services needed
services = {}
# Ayon dependency
ayon_required_addons = {
"core": ">=1.0.0"
}
# Plugin paths
ayon_compatible_addons = {}
+15
View File
@@ -0,0 +1,15 @@
"""Matrix Publish addon — server-side definition."""
from ayon_server.addons import BaseServerAddon
from .version import __version__
class MatrixPublishAddon(BaseServerAddon):
name = "matrix_publish"
title = "Matrix Publish"
version = __version__
async def setup(self):
"""Initialize addon — no persistent services needed."""
pass
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""Matrix Publish addon — server and client package."""
@@ -0,0 +1,525 @@
# -*- coding: utf-8 -*-
"""Publish plugin: upload published representations to Matrix rooms.
This plugin runs during the Integration stage of the AYON publish pipeline.
For each published representation, it uploads the file to the Matrix content
repository and posts a formatted message to a configured room.
Architecture:
Ayon Publish: Collect → Validate → Extract → Integrate
IntegrateMatrix (this plugin)
┌─────────┴──────────┐
│ MatrixUploader │
│ • upload_media() │
│ • send_message() │
│ • ensure_room() │
└────────────────────┘
Matrix Homeserver API
(Synapse / Dendrite)
"""
import os
import logging
from pathlib import Path
from typing import Optional
import pyblish.api
from ayon_core.pipeline.publish import (
AYONPyblishPluginMixin,
PublishError,
)
from ayon_core.pipeline import publish
log = logging.getLogger(__name__)
class MatrixUploader:
"""Thin wrapper around Matrix HTTP API for uploads and messaging.
Uses raw HTTP requests (no async needed in Pyblish context) instead of
the mautrix async SDK. Pyblish plugins run synchronously in the host
process, so we use the REST API directly.
Matrix API reference:
https://spec.matrix.org/latest/client-server-api/
Endpoints used:
POST /_matrix/media/v3/upload → mxc:// URI
PUT /_matrix/client/v3/rooms/{id}/send/m.room.message/{txn}
"""
def __init__(
self,
homeserver_url: str,
access_token: str,
):
self.homeserver = homeserver_url.rstrip("/")
self.token = access_token
def upload_media(self, file_path: str) -> str:
"""Upload a file to the Matrix content repository.
Args:
file_path: Local path to the file to upload.
Returns:
mxc:// URI of the uploaded content.
Raises:
PublishError: If upload fails.
"""
import urllib.request
import urllib.error
import json
url = f"{self.homeserver}/_matrix/media/v3/upload"
file_name = os.path.basename(file_path)
# Determine Content-Type from extension
content_type = self._guess_mime(file_path)
with open(file_path, "rb") as f:
data = f.read()
boundary = "---MatrixUploaderBoundary"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{file_name}"\r\n'
f"Content-Type: {content_type}\r\n\r\n"
).encode("utf-8")
body += data
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
req = urllib.request.Request(
url,
data=data, # Matrix upload accepts raw binary
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": content_type,
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
result = json.loads(resp.read().decode())
content_uri = result.get("content_uri")
if not content_uri:
raise PublishError(
f"Matrix upload: no content_uri in response: {result}"
)
log.info(
"Uploaded %s%s (%d bytes)",
file_name, content_uri, len(data),
)
return content_uri
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")
raise PublishError(
f"Matrix upload failed (HTTP {e.code}): {body[:500]}"
) from e
except Exception as e:
raise PublishError(f"Matrix upload failed: {e}") from e
def send_message(
self,
room_id: str,
body: str,
formatted_body: Optional[str] = None,
msgtype: str = "m.text",
url: Optional[str] = None,
media_info: Optional[dict] = None,
) -> str:
"""Send a message to a Matrix room.
Args:
room_id: Target room ID (!xxx:server).
body: Plain text fallback.
formatted_body: HTML formatted body (org.matrix.custom.html).
msgtype: Message type (m.text, m.image, m.notice).
url: mxc:// URI for image/file messages.
media_info: Dict with w, h, size, mimetype for media messages.
Returns:
The event ID of the sent message.
Raises:
PublishError: If sending fails.
"""
import urllib.request
import urllib.error
import json
import uuid
txn_id = str(uuid.uuid4()).replace("-", "")
api_url = (
f"{self.homeserver}/_matrix/client/v3/rooms/{room_id}"
f"/send/m.room.message/{txn_id}"
)
content = {
"msgtype": msgtype,
"body": body,
}
if formatted_body:
content["format"] = "org.matrix.custom.html"
content["formatted_body"] = formatted_body
if url:
content["url"] = url
if media_info:
content["info"] = media_info
req = urllib.request.Request(
api_url,
data=json.dumps(content).encode("utf-8"),
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
},
method="PUT",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read().decode())
event_id = result.get("event_id", "unknown")
log.info("Sent Matrix message → %s (event: %s)", room_id, event_id)
return event_id
except urllib.error.HTTPError as e:
body_text = e.read().decode(errors="replace")
raise PublishError(
f"Matrix send failed (HTTP {e.code}): {body_text[:500]}"
) from e
except Exception as e:
raise PublishError(f"Matrix send failed: {e}") from e
def ensure_room(self, room_alias: str, room_name: str) -> str:
"""Create a room if it doesn't exist, return room_id.
Args:
room_alias: Desired alias (#name:server).
room_name: Display name for the room.
Returns:
Room ID (!xxx:server).
"""
import urllib.request
import urllib.error
import json
# First, try to resolve the alias
resolve_url = f"{self.homeserver}/_matrix/client/v3/directory/room/{room_alias}"
req = urllib.request.Request(
resolve_url,
headers={"Authorization": f"Bearer {self.token}"},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())
return data["room_id"]
except urllib.error.HTTPError:
pass # Room doesn't exist yet
# Create the room
create_url = f"{self.homeserver}/_matrix/client/v3/createRoom"
body = {
"name": room_name,
"room_alias_name": room_alias.lstrip("#").split(":")[0],
"preset": "private_chat",
"visibility": "private",
"topic": f"AYON publish room for {room_name}",
}
req = urllib.request.Request(
create_url,
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
room_id = data["room_id"]
log.info("Created Matrix room %s (%s)", room_id, room_name)
return room_id
except urllib.error.HTTPError as e:
body_text = e.read().decode(errors="replace")
raise PublishError(
f"Matrix create room failed: {body_text[:500]}"
) from e
@staticmethod
def _guess_mime(file_path: str) -> str:
"""Guess MIME type from file extension."""
ext = os.path.splitext(file_path)[1].lower()
mapping = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".mp4": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".pdf": "application/pdf",
".abc": "application/octet-stream",
".usd": "application/octet-stream",
".usda": "application/octet-stream",
".usdc": "application/octet-stream",
".fbx": "application/octet-stream",
".obj": "application/octet-stream",
".glb": "model/gltf-binary",
".gltf": "model/gltf+json",
".exr": "image/x-exr",
".tif": "image/tiff",
".tiff": "image/tiff",
".tga": "image/x-tga",
}
return mapping.get(ext, "application/octet-stream")
class IntegrateMatrix(
AYONPyblishPluginMixin,
pyblish.api.InstancePlugin,
):
"""Upload published representations to Matrix rooms.
Runs during Integration stage (order = IntegratorOrder + 0.1).
For each representation matching the configured filter:
1. Upload the file to the Matrix content repository
2. Post a formatted message with preview and metadata
Configuration (Studio Settings → Matrix Publish):
homeserver_url: Matrix homeserver URL
access_token: Bot user access token
room_id: Target room (or room pattern)
upload_representations: List of rep names to upload
message_template: Jinja2 template for the message
create_per_asset_rooms: Auto-create rooms per asset
"""
label = "Integrate Matrix"
order = pyblish.api.IntegratorOrder + 0.1
families = ["*"] # Run for all product types
targets = ["local"]
# Attribute definitions for the AYON publisher UI
# These allow artists to enable/disable Matrix upload per instance
enabled = True
def process(self, instance):
"""Main Pyblish process method.
Called once per publish instance during Integration stage.
"""
# Get attribute values from instance publish attributes
attr_values = self.get_attr_values_from_data(
instance.data
)
# Check if Matrix upload is enabled for this instance
if not attr_values.get("enabled", True):
log.debug("Matrix upload disabled for instance %s", instance.name)
return
# Get addon settings from AYON
settings = self._get_addon_settings(instance)
# Connect to Matrix
uploader = MatrixUploader(
homeserver_url=settings["homeserver_url"],
access_token=settings["access_token"],
)
# Determine target room
room_id = self._resolve_target_room(instance, uploader, settings)
# Get representations to upload
rep_names = settings.get("upload_representations", ["thumbnail", "preview"])
representations = instance.data.get("representations", [])
uploaded = {} # rep_name → mxc:// URI
for rep in representations:
rep_name = rep.get("name", "")
if rep_name not in rep_names:
continue
# Get the published file path
staging_dir = rep.get("stagingDir", "")
files = rep.get("files", [])
if not files:
continue
# Upload the first file (typically thumbnail/preview has one file)
file_path = os.path.join(staging_dir, files[0])
if not os.path.exists(file_path):
log.warning("File not found: %s", file_path)
continue
try:
mxc_url = uploader.upload_media(file_path)
uploaded[rep_name] = mxc_url
except PublishError:
log.warning(
"Failed to upload %s to Matrix", file_path, exc_info=True
)
# Don't fail the publish — Matrix is optional
continue
if not uploaded:
log.info("No representations to upload to Matrix for %s", instance.name)
return
# Build and send the message
message_body, message_html = self._build_message(
instance, uploaded, settings
)
if message_body:
try:
# If there's a thumbnail, send it as an image message
thumbnail_url = uploaded.get("thumbnail")
if thumbnail_url:
uploader.send_message(
room_id=room_id,
body=message_body,
formatted_body=message_html,
msgtype="m.image",
url=thumbnail_url,
media_info={"mimetype": "image/png"},
)
else:
uploader.send_message(
room_id=room_id,
body=message_body,
formatted_body=message_html,
)
except PublishError:
log.warning(
"Failed to send Matrix message for %s", instance.name,
exc_info=True,
)
# ── helper methods ──────────────────────────────────────────────
def _get_addon_settings(self, instance) -> dict:
"""Load Matrix Publish settings from AYON studio settings.
In production, this reads from:
instance.context.data["project_settings"]["matrix_publish"]
"""
# Fallback to environment variables for development
return {
"homeserver_url": os.environ.get(
"MATRIX_HOMESERVER", "https://matrix.niklashmotion.art"
),
"access_token": os.environ.get("MATRIX_ACCESS_TOKEN", ""),
"room_id": os.environ.get("MATRIX_PUBLISH_ROOM", ""),
"upload_representations": os.environ.get(
"MATRIX_UPLOAD_REPS", "thumbnail,preview"
).split(","),
"create_per_asset_rooms": os.environ.get(
"MATRIX_CREATE_ROOMS", "0"
)
== "1",
"message_template": os.environ.get(
"MATRIX_MESSAGE_TEMPLATE",
("**{{ product_type }}** published: "
"{{ asset }} / {{ task }} — v{{ version }}"),
),
}
def _resolve_target_room(
self, instance, uploader: MatrixUploader, settings: dict
) -> str:
"""Determine which Matrix room to send to."""
room_id = settings.get("room_id", "")
# Auto-create per-asset rooms if configured
if settings.get("create_per_asset_rooms"):
asset_name = instance.data.get("asset", "unknown")
alias = f"#ayon-{asset_name}:niklashmotion.art"
room_id = uploader.ensure_room(
alias, f"AYON: {asset_name}"
)
if not room_id:
raise PublishError(
"No Matrix room configured. Set room_id in Matrix Publish settings."
)
return room_id
def _build_message(
self, instance, uploaded: dict, settings: dict
) -> tuple:
"""Build plain-text and HTML message bodies.
Returns:
(plain_text, html) tuple or ("", "") if no message.
"""
from string import Template
template = settings.get(
"message_template",
"$product_type published: $asset / $task — v$version",
)
# Build template variables
context_data = instance.context.data if hasattr(instance, "context") else {}
vars_dict = {
"product_type": instance.data.get("family", "unknown"),
"product": instance.data.get("product", instance.data.get("name", "")),
"asset": instance.data.get("asset", ""),
"task": instance.data.get("task", ""),
"version": instance.data.get("version", 1),
"user": os.environ.get("AYON_USER", os.environ.get("USER", "")),
}
try:
body = Template(template).substitute(vars_dict)
except KeyError:
body = template
# Build HTML version
html = (
f"<p><strong>{vars_dict['product_type']}</strong> published: "
f"{vars_dict['asset']} / {vars_dict['task']} "
f"— v{vars_dict['version']}</p>"
)
# Add uploaded file list
if len(uploaded) > 1:
html += "<ul>"
for rep_name, mxc_url in uploaded.items():
html += (
f'<li>{rep_name}: <a href="{mxc_url}">{mxc_url}</a></li>'
)
html += "</ul>"
return body, html
# ── AYONPyblishPluginMixin ─────────────────────────────────────
@classmethod
def get_attribute_defs(cls):
"""Define per-instance attributes for the publisher UI."""
from ayon_core.lib import BoolDef
return [
BoolDef(
"enabled",
default=True,
label="Upload to Matrix",
),
]
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
"""Matrix Publish addon."""
__version__ = "0.1.0"