Module Authoring Guide¶
API Version: 1.1
API 1.1 change: Effects modules receive and return
torch.Tensor float32 ACEScginstead ofnp.ndarray uint8. See the Effects Interface section below. Modules declaringapi_version: "1.0"are flagged as incompatible.
This guide covers everything you need to write, test, package, and distribute a Transduce module. A working knowledge of Python 3.10+ is assumed.
What is a Module?¶
A Transduce module is a Python class that implements one of four container interfaces, packaged as a .a2fmod file. Once installed through the Module Manager, it integrates into the processing pipeline exactly like a built-in capability.
Modules can extend four parts of the pipeline:
| Container | What it does | Example |
|---|---|---|
effects |
Per-frame image processing, post-CC | Film halation, VHS distortion, custom LUT |
encoder |
Output format writers | Custom codec, platform-specific format |
decoder |
Source format readers + colour space detection | Platform-specific AI video decoder |
analysis |
Post-encode operations | Quality metrics, metadata extraction |
The most common module type for community authors is effects.
Note: The resize engine and AI model containers are internal to the application and are not open for community contribution at this time.
Quick Start: Writing an Effects Module¶
1. Create your project directory¶
my_halation/
├── manifest.json
├── effect.py
└── (optional) utils.py
2. Write the effect class¶
# effect.py
from __future__ import annotations
from typing import Any, Dict
import torch
import torch.nn.functional as F
from src.containers.effects_base import EffectsModule
class MyHalation(EffectsModule):
manifest: Dict[str, Any] = {
"container": "effects",
"name": "my_halation", # snake_case; stable identifier
"display_name": "Film Halation", # shown in UI
"version": "1.0.0",
"author": "Your Name",
"api_version": "1.1",
"entry_point": "effect.MyHalation", # module.ClassName
"description": "Simulates light bleeding behind the film base.",
"licence": "MIT",
"scope": "per_clip",
"reference_propagation": "copy",
"capabilities": {
"category": "Optical",
},
"params": [
{
"key": "strength",
"label": "Strength",
"type": "float",
"default": 0.4,
"min": 0.0,
"max": 1.0,
},
{
"key": "radius",
"label": "Radius",
"type": "float",
"default": 8.0,
"min": 1.0,
"max": 32.0,
},
],
}
def apply(self, frame: torch.Tensor, params: dict) -> torch.Tensor:
"""
Apply halation to one frame.
Parameters
----------
frame : torch.Tensor
Shape (H, W, C), dtype float32, ACEScg linear.
Device may be CPU, MPS, or CUDA — use frame.device for any
tensors you create. Do not modify in-place.
Small negative values are valid — do not clamp unless your
effect specifically requires it.
params : dict
Keys match manifest param 'key' fields. Always use .get() with
the manifest default as fallback — the dict may be missing keys
if the module was just added to an existing project.
Returns
-------
torch.Tensor
Same shape (H, W, C), dtype float32, device as input.
ACEScg linear colour space preserved.
"""
strength = float(params.get("strength", 0.4))
radius = float(params.get("radius", 8.0))
if strength == 0.0:
return frame
# All tensor operations must use frame.device
dev = frame.device
# ... your processing logic here using torch ops ...
# Example: extract red channel, blur it, add back as warm glow
result = frame # replace with your transform
return result
3. Write manifest.json¶
{
"container": "effects",
"name": "my_halation",
"display_name": "Film Halation",
"version": "1.0.0",
"author": "Your Name",
"api_version": "1.1",
"entry_point": "effect.MyHalation",
"description": "Simulates light bleeding behind the film base.",
"licence": "MIT",
"scope": "per_clip",
"reference_propagation": "copy",
"platforms": ["windows", "macos", "linux"],
"dependencies": ["scipy>=1.10"],
"capabilities": {
"category": "Optical"
},
"params": [
{ "key": "strength", "label": "Strength", "type": "float", "default": 0.4, "min": 0.0, "max": 1.0 },
{ "key": "radius", "label": "Radius", "type": "float", "default": 8.0, "min": 1.0, "max": 32.0 },
{ "key": "hue", "label": "Hue", "type": "float", "default": 0.0, "min": -180.0, "max": 180.0 }
]
}
4. Package as .a2fmod¶
cd my_halation/
zip -r ../my_halation.a2fmod manifest.json effect.py utils.py
manifest.json must be at the root of the zip — not inside a subdirectory.
5. Install and test¶
Open Transduce → Edit → Module Manager → Install from File → select my_halation.a2fmod.
manifest.json — Full Schema Reference¶
Required fields (all containers)¶
| Field | Type | Description |
|---|---|---|
container |
string | One of: effects, encoder, decoder, analysis |
name |
string | Unique snake_case identifier. Never change this — the install database and user projects are keyed on it. Use a namespace prefix for community modules: yourname_halation. |
display_name |
string | Human-readable name shown in the UI |
version |
string | Semantic version, e.g. "1.2.0" |
author |
string | Your name or organisation |
api_version |
string | Must be "1.1" for current release |
entry_point |
string | Dotted path to the class: "module_file.ClassName". Resolved relative to your install directory. |
Optional fields (all containers)¶
| Field | Type | Default | Description |
|---|---|---|---|
description |
string | "" |
One paragraph shown in Module Manager |
licence |
string | "proprietary" |
SPDX licence identifier or "proprietary" |
platforms |
array | all | ["windows", "macos", "linux"] — omit platforms your module does not support |
dependencies |
array | [] |
pip package names required at runtime. See Allowed Dependencies below. |
capabilities |
object | {} |
Container-specific capability descriptor (see per-container sections below) |
permissions |
array | [] |
Declare non-standard permissions your module needs. Currently: ["network"]. Triggers a user consent prompt on first use. |
Effects-container fields¶
| Field | Type | Default | Description |
|---|---|---|---|
scope |
string | "per_clip" |
"per_clip": params on ClipRecord, vary per clip. "global": params on ProjectRecord, same for all clips. |
reference_propagation |
string | "none" |
"none": no reference support. "copy": exact params copied to all clips when reference is set. "ai_target": AI adapts params per clip toward the reference target. |
params |
array | [] |
List of param specs. Each spec: {key, label, type, default, min, max} |
Param spec fields¶
| Field | Type | Required | Description |
|---|---|---|---|
key |
string | Yes | Internal identifier. Referenced in params dict passed to apply(). Stable across versions. |
label |
string | Yes | Displayed in the UI next to the slider |
type |
string | Yes | "float" or "int" |
default |
number | Yes | Value used when the module is first added |
min |
number | Yes | Slider minimum |
max |
number | Yes | Slider maximum |
AI model fields¶
| Field | Type | Description |
|---|---|---|
download_url |
string | URL of the ONNX model binary (downloaded separately from the .a2fmod) |
model_sha256 |
string | Expected SHA-256 of the downloaded model file for integrity verification |
Container Interfaces¶
EffectsModule (API 1.1)¶
from src.containers.effects_base import EffectsModule
import torch
class MyEffect(EffectsModule):
manifest = { ... } # required; api_version must be "1.1"
def apply(self, frame: torch.Tensor, params: dict) -> torch.Tensor:
"""
frame : torch.Tensor — shape (H, W, C), float32, ACEScg linear.
Device: CPU, MPS, or CUDA. Use frame.device.
params: dict keyed by manifest param 'key' fields.
Returns: torch.Tensor — same shape, float32, same device. ACEScg preserved.
"""
...
Rules:
- Never modify
framein-place. Torch in-place ops (frame.mul_(x),frame[:] = …) corrupt the frame for the next effect in the stack. Always produce a new tensor. - Return same shape and device.
frame.shape,frame.dtype, andframe.devicemust be preserved. - Small negatives are valid. ACEScg linear can have values below 0.0 in deep shadows. Do not call
.clamp(0, 1)unless your effect specifically requires it — clamping harms shadow detail. - Always use
.get(key, default)for params — keys may be absent on older saved projects. - Build tensors on
frame.device. Any constant tensors (Gaussian kernels, matrices) must be moved toframe.devicevia.to(frame.device)or created withdevice=frame.device. - Threshold values are in ACEScg linear space, not display-referred. Quick reference:
- sRGB display 0.18 (mid-grey) ≈ ACEScg 0.064 linear
- sRGB display 0.5 ≈ ACEScg 0.214 linear
- sRGB display 0.75 ≈ ACEScg 0.522 linear
- sRGB display 1.0 ≈ ACEScg 1.0 linear
ACEScg note for effects designed for display-referred footage¶
All V1 AI-generated footage enters Transduce as sRGB 8-bit and is converted to ACEScg for the working space. The pixel values you operate on are linear light values, not gamma-encoded. This affects any threshold, strength, or sigma parameter your effect uses. Scale appropriately (see threshold table above) and test at representative param values.
EncoderModule¶
from src.containers.registry import ContainerModule
class MyEncoder(ContainerModule):
manifest = {
"container": "encoder",
"name": "my_format",
"display_name": "My Format (EXT)",
# ...
"export_options": [
{
"key": "quality", "type": "combo", "label": "Quality",
"options": ["High", "Medium", "Low"], "default": "High"
}
],
}
def apply_export_options(self, opts: dict) -> None:
"""Called before each export run with user-selected option values."""
self._quality = opts.get("quality", "High")
def encode(self, frames, output_path: str, fps: float, **kwargs) -> None:
"""Write frames to output_path."""
...
DecoderModule¶
from src.containers.registry import ContainerModule
class MyDecoder(ContainerModule):
manifest = {
"container": "decoder",
"name": "my_decoder",
# ...
"capabilities": {
"colour_spaces": ["sRGB_8bit", "Rec709"],
"file_extensions": [".myext"],
}
}
def detect(self, file_path: str) -> dict:
"""Return {"colour_space": "sRGB_8bit", "platform": "my_platform"} or {}."""
...
def iter_frames(self, file_path: str, colour_space: str):
"""Yield HxWx3 float32 ACEScg frames."""
...
Security Constraints¶
Read this section carefully. Modules that violate these constraints will be rejected at install time by the static scanner and will not load.
What is blocked¶
The following are blocked unconditionally and will cause InstallError at install time:
Shell execution
# BLOCKED — do not use any of these
import subprocess
import os; os.system(...)
import os; os.popen(...)
import commands
Dynamic code execution
# BLOCKED
eval(...)
exec(...)
compile(...)
__import__(...) # use standard import instead
importlib.import_module # only allowed in __init__ for lazy-loading your own submodules
Network access (without declaration)
# BLOCKED unless "network" declared in manifest permissions
import socket
import requests
import urllib
import urllib2
import httpx
import http.client
import ftplib
import smtplib
import imaplib
If your module legitimately needs network access (e.g. to fetch a supplementary data file), declare "permissions": ["network"] in your manifest. The user will be prompted to approve on first use.
C bridges
# BLOCKED — community modules are Python-only
import ctypes
import cffi
import cython
Modules reviewed and authorized directly by the Transduce team may include compiled binaries. Community modules may not.
Filesystem access outside your sandbox
# BLOCKED — absolute paths and parent traversal
open("/etc/passwd")
open("../../../sensitive_file")
open(os.path.expanduser("~/.ssh/id_rsa"))
You may read and write within your module's data directory. The path is available through the module context system (V2). For V1, write to a subdirectory of your install path.
Monkey-patching
# BLOCKED
sys.modules["transduce.core"] = my_replacement
importlib.reload(some_core_module)
What is allowed¶
Standard data-processing imports
import numpy as np
import scipy
import PIL
import cv2
import skimage
import imageio
Your own submodules
from . import my_utils # relative imports within your package
from my_utils import helper # absolute imports within install dir
AI inference (declared dependencies)
import torch
import onnxruntime
Filesystem access within your module
# Reading assets bundled in your module directory
asset_path = Path(__file__).parent / "lut.cube"
data = asset_path.read_bytes() # OK — relative to module dir
Allowed dependencies¶
Community modules may only declare pip dependencies from this list. Requesting anything else will cause the submission to be rejected (community platform) or flagged with a warning (sideload).
numpy, scipy, pillow, Pillow, opencv-python, opencv-python-headless,
scikit-image, scikit-learn, imageio, imageio-ffmpeg,
torch, torchvision, onnxruntime, onnxruntime-gpu,
colour-science, rawpy, tifffile,
PySide6 (version must match app's bundled version — use sparingly)
If you need a library not on this list, reach out through official Transduce support channels to discuss adding it.
Testing Your Module¶
Local test without packaging¶
Add your module directory to sys.path and instantiate directly:
import sys
sys.path.insert(0, "/path/to/my_halation")
from effect import MyHalation
import torch
effect = MyHalation()
# Test on CPU (no GPU required for unit tests)
device = torch.device("cpu")
# ACEScg values: random float32 in a plausible scene-linear range
frame = torch.rand(1080, 1920, 3, dtype=torch.float32, device=device) * 0.8
params = {"strength": 0.5, "radius": 10.0}
result = effect.apply(frame, params)
assert result.shape == frame.shape, f"Shape changed: {result.shape}"
assert result.dtype == torch.float32, f"dtype changed: {result.dtype}"
assert result.device == frame.device, f"device changed: {result.device}"
# Verify no in-place mutation
assert not torch.equal(frame, result) or True # OK if effect was a no-op at these params
print("OK")
Test on GPU if available:
if torch.backends.mps.is_available():
device = torch.device("mps")
elif torch.cuda.is_available():
device = torch.device("cuda")
else:
device = torch.device("cpu")
frame_gpu = frame.to(device)
result_gpu = effect.apply(frame_gpu, params)
assert result_gpu.device == frame_gpu.device, "Effect returned tensor on wrong device"
print(f"GPU OK ({device})")
Install and test in-app¶
Package as .a2fmod, install via Module Manager, add the effect in the Effects Panel, scrub the preview. Check the Log Panel (Edit → Log, or the LOG button in the status bar) for any errors from your module.
Edge cases to test¶
- All params at minimum values
- All params at maximum values
- Single-pixel frame (1×1)
- Very wide frame (7680×4320)
- Params dict is empty (simulates fresh install on old project)
- Called twice in sequence on same frame (check for in-place mutation)
- Frame containing negative values (valid in ACEScg deep shadows — effect must not corrupt them)
- Frame containing values > 1.0 (valid in ACEScg highlights — e.g. after bloom additive composite)
Versioning and Updates¶
When you update your module:
- Increment
versionin bothmanifest.jsonand the Pythonmanifestdict - Never change
name— it is the stable identifier across installs and user projects. If you need to rename it, treat the new name as a new module entirely. - Adding new params: always include a
default— existing projects that don't have the key will fall back to it. - Removing params: leave them in the manifest with their old defaults for one major version to avoid breaking existing projects.
- The Module Manager will show the new version and offer an upgrade.
Submitting to the Community Platform¶
A community module platform, where authors can submit modules for other users to discover and install directly from the Module Manager's Catalog tab, is planned for a future release.
Planned submission flow:
- Create a developer account (verified email required)
- Upload your .a2fmod via the developer portal
- Automated static analysis + AI code review (results typically within minutes)
- If flagged, you receive a report and can revise and resubmit
- First submission from a new account goes to human review before going live
- Once approved, your module appears in the Catalog tab of Module Manager
Consent for community page: During submission you will be asked whether you consent to your module being listed on the community page and whether the source may be viewed. Modules for internal company use can be marked private — they will not be listed publicly and will only be installable by users in your organisation.
Proprietary / enterprise modules: If your module contains proprietary processing logic you do not wish to share, you may submit a closed-source module. The static analysis still runs on the source (required for security), but the source is not displayed to other users.
Common Mistakes¶
Modifying frame in-place
# Wrong — Torch in-place ops corrupt the original tensor
frame.mul_(strength)
frame[:, :, 0] = 0.0
return frame
# Right — always create a new tensor
return frame * strength
result = frame.clone()
result[:, :, 0] = 0.0
return result
Clamping unnecessarily
# Wrong — destroys valid ACEScg highlights and deep shadows
return result.clamp(0.0, 1.0)
# Right — only clamp if your effect specifically requires a bounded range
# (e.g. a mask that must be in [0,1]). Leave the final output unclamped.
return result # display transform clips at the OpenGL shader stage
Building tensors on the wrong device
# Wrong — kernel is on CPU, frame may be on MPS/CUDA → conv2d will error
kernel = torch.ones(3, 3)
result = F.conv2d(frame, kernel)
# Right — always use frame.device
kernel = torch.ones(3, 3, device=frame.device)
result = F.conv2d(frame, kernel)
Threshold values from display-referred experience
# Wrong — 0.75 is a display-referred sRGB threshold, not linear ACEScg
highlight_mask = (lum > 0.75).float()
# Right — convert to linear. sRGB 0.75 ≈ ACEScg 0.52 linear
highlight_mask = (lum > 0.52).float()
# See the threshold table in the EffectsModule section above.
Crashing on empty params
# Wrong — KeyError if "strength" was never set
strength = params["strength"]
# Right
strength = params.get("strength", 0.5) # always provide the manifest default
entry_point mismatch
# manifest.json says: "entry_point": "effect.MyEffect"
# but your class is named MyHalation in effect.py
# → ImportError at install time
# Fix: class name in entry_point must exactly match the Python class name
"entry_point": "effect.MyHalation"
Hardcoded absolute paths
# Wrong — path doesn't exist on other machines
lut = open("/Users/username/my_project/lut.cube")
# Right — relative to your module's install directory
lut_path = Path(__file__).parent / "lut.cube"
lut = lut_path.open()
Getting Help¶
- Module authoring questions and bug reports: through official Transduce support channels
- This document is the authoritative reference for the module API — check the version number at the top against your installed app version before relying on interface details
For security-related concerns about an existing module (suspected malicious code), use the in-app "Report" button in Module Manager.