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:
+27
@@ -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/
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
@@ -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 = {}
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Matrix Publish addon."""
|
||||||
|
__version__ = "0.1.0"
|
||||||
Reference in New Issue
Block a user