Skip to content

Module Authoring Guide

API Version: 1.1

API 1.1 change: Effects modules receive and return torch.Tensor float32 ACEScg instead of np.ndarray uint8. See the Effects Interface section below. Modules declaring api_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 frame in-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, and frame.device must 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 to frame.device via .to(frame.device) or created with device=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:

  1. Increment version in both manifest.json and the Python manifest dict
  2. 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.
  3. Adding new params: always include a default — existing projects that don't have the key will fall back to it.
  4. Removing params: leave them in the manifest with their old defaults for one major version to avoid breaking existing projects.
  5. 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:

  1. Create a developer account (verified email required)
  2. Upload your .a2fmod via the developer portal
  3. Automated static analysis + AI code review (results typically within minutes)
  4. If flagged, you receive a report and can revise and resubmit
  5. First submission from a new account goes to human review before going live
  6. 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.