diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca4ca6f --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 34f514c..21817b7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,75 @@ # 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. \ No newline at end of file +## 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) diff --git a/client/ayon_matrix_publish/__init__.py b/client/ayon_matrix_publish/__init__.py new file mode 100644 index 0000000..5cd5e0e --- /dev/null +++ b/client/ayon_matrix_publish/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Matrix Publish addon — client-side (DCC host).""" diff --git a/client/ayon_matrix_publish/plugins/publish/__init__.py b/client/ayon_matrix_publish/plugins/publish/__init__.py new file mode 100644 index 0000000..aeee19a --- /dev/null +++ b/client/ayon_matrix_publish/plugins/publish/__init__.py @@ -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"] diff --git a/create_package.py b/create_package.py new file mode 100644 index 0000000..54d46ce --- /dev/null +++ b/create_package.py @@ -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() diff --git a/package.py b/package.py new file mode 100644 index 0000000..be1aae1 --- /dev/null +++ b/package.py @@ -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 = {} diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e747f21 --- /dev/null +++ b/server/__init__.py @@ -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 diff --git a/server/ayon_matrix_publish/__init__.py b/server/ayon_matrix_publish/__init__.py new file mode 100644 index 0000000..003390e --- /dev/null +++ b/server/ayon_matrix_publish/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Matrix Publish addon — server and client package.""" diff --git a/server/ayon_matrix_publish/plugins/publish/integrate_matrix.py b/server/ayon_matrix_publish/plugins/publish/integrate_matrix.py new file mode 100644 index 0000000..727250a --- /dev/null +++ b/server/ayon_matrix_publish/plugins/publish/integrate_matrix.py @@ -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"

{vars_dict['product_type']} published: " + f"{vars_dict['asset']} / {vars_dict['task']} " + f"— v{vars_dict['version']}

" + ) + + # Add uploaded file list + if len(uploaded) > 1: + html += "" + + 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", + ), + ] diff --git a/server/ayon_matrix_publish/version.py b/server/ayon_matrix_publish/version.py new file mode 100644 index 0000000..f40d6c7 --- /dev/null +++ b/server/ayon_matrix_publish/version.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +"""Matrix Publish addon.""" +__version__ = "0.1.0"