3. Python plugin reference

From MXWendler Wiki
Revision as of 18:31, 3 July 2026 by Admin (talk | contribs)
Jump to navigation Jump to search


MXWendler StageDesigner can be extended with Python plugins. A plugin is a plain folder containing a manifest file and a Python module; MXWendler discovers it at startup, shows it in the user interface and calls a defined set of Python functions (hooks) at the right moments.

Two plugin types exist:

  • Media plugins produce pixels: they appear as a media source that can be placed in a clip like any video file. Examples: a web browser media, a generative OpenGL cube.
  • Playlist plugins are items in the playlist grid: they run logic when their cue plays. Examples: advance the playlist at a given time of day, write video files, ping a host.

The embedded interpreter is Python 3.12. The general Python command interface (module mxw) is documented in the Python Reference.

Plugin anatomy

A plugin is one folder with at least two files:

plugins/media/python/plugin_my_effect/
    mxw_plugin.ini      the manifest: identity, menu entries, addressing
    mxw_main.py         the Python module with the hook functions
    (more .py files)    optional: importable as "from plugin_my_effect.xyz import ..."

The folder name is the Python package name: additional modules in the folder are imported with absolute imports, e.g. from plugin_my_effect.helpers import foo.

Plugin locations

Plugins are searched in several directories. User-controlled locations always override the bundled system location, so a user can replace a built-in plugin by placing a same-named folder in a higher-precedence directory — even without write access to the installation.

Media plugins, descending precedence:

  1. The open .mxw project's plugins/media/python/ tree (opt-in via settings, highest precedence)
  2. Directories configured explicitly in the settings (Python tab)
  3. The per-user data directory, e.g. %APPDATA%/MXWendler/plugins/media/python/
  4. The installation / resource directory: plugins/media/python/

Playlist plugins:

  1. The per-user data directory: .../plugins/playlist/python/
  2. The installation / resource directory: plugins/playlist/python/

The manifest: mxw_plugin.ini

[mxw_plugin]                            ; must be here
plugin_version = 1                      ; must be V1
plugin_script_language = Python         ; must be Python

Common keys:

Key Used by Meaning
plugin_menu_parent both Menu the plugin is listed under (e.g. Media, IO)
plugin_menu_name both Display name in the menu
plugin_grid_name both Short name shown in the grid / media list
plugin_tooltip both Tooltip text
plugin_menu_level (media) / plugin_action_level (playlist) both user (default, visible), development (visible in debug builds only), disabled (invisible)

Media-only keys:

Key Meaning
plugin_media_scheme The URI scheme this plugin handles, e.g. generative or web. Media is addressed as <scheme>://<payload>.
plugin_media_payload Optional. If set, the plugin handles exactly scheme://payload; if omitted, it handles every URI of its scheme. Several plugins can share one scheme and dispatch on the payload.
plugin_media_create_uri The URI used when the user creates this media from the menu / Create button.

Playlist-only keys:

Key Meaning
plugin_grid_bg_color Item background color as r g b a floats, e.g. 0.05 0.05 0.45 1.0

The plugin environment

Per-instance state

One Python module serves many simultaneous instances (several clips using the same media plugin, several copies of a playlist item). Before every hook call, the host sets module globals identifying the instance:

  • Media plugins: media_id (integer)
  • Playlist plugins: item_id (integer) and item_position (tuple (x, y), the grid position)

The standard pattern is a module-level dictionary keyed by the id:

storage = {}

def onCreate():                    # playlist; media plugins use onOpen(uri)
    storage[item_id] = my_instance_state()

def onDelete():
    storage.pop(item_id, None)

Host modules

  • mxw — the MXWendler command interface (playlist control, layers, clips, ...), see the Python Reference.
  • mxw_imgui — Dear ImGui bindings for drawing settings panels inside MXWendler (used by onRenderPanel()).

Installing own modules with mxw-pip

Plugins may use third-party packages (numpy, moderngl, opencv-python, ...). Packages are not installed into the installation folder — the mxw-pip helper (shipped next to the StageDesigner executable) installs them into the current user's writable folder, no administrator rights required:

%APPDATA%\Python\Python312\site-packages

This is exactly the directory StageDesigner prepends to sys.path when the option Prepend user site-path is enabled (Settings → Python tab).

mxw-pip runs pip with the Python 3.12 shipped with StageDesigner (python.exe in the installation folder / in its python312/ subfolder) — nothing else needs to be installed. Only when the shipped interpreter is missing does it fall back to a system Python (py -3.12, then python on PATH), which must be 3.12 so binary wheels match the runtime ABI.

Step by step:

  1. Open Settings → Python and enable Prepend user site-path. The two installer buttons below are only active while this option is on, because mxw-pip installs into exactly that folder. Enabling applies immediately; disabling takes full effect after a restart.
  2. Click Launch CMD window with mxw-pip module installer.. — this opens the installer shell (mxw-pip-shell.bat). In the shell, type:
    mxw-pip install <modulename>
    for example:
    mxw-pip install moderngl
  3. Restart MXWendler StageDesigner so the new module is loaded.

Other pip subcommands are passed through unchanged, e.g. mxw-pip list, mxw-pip show moderngl, mxw-pip uninstall moderngl. The button Open per-user module install folder.. next to the installer button shows the target directory in the Explorer. Both mxw-pip.bat and mxw-pip-shell.bat can also be run directly from the installation folder in any command prompt.

Because the target folder lives in the user profile, every user keeps their own module set, and reinstalling / updating StageDesigner never touches the installed modules.

Error handling

Exceptions raised by a hook are reported to the interpreter console (IO dialog) — throttled, so a per-frame error does not flood the log. A per-frame hook that throws is blacklisted: it is not called again until the plugin is reloaded, so one broken callback cannot stall the render loop. Playlist plugins can be hot-reloaded from disk (state is snapshotted via onSave(), the module source re-executed, then onCreate() + onLoad() restore each instance), which also clears the blacklist.

Media plugins

A media plugin is addressed by a URI: <scheme>://<payload>, for example generative://cube_spin_opengl or web://https://mxwendler.net. Every created media instance internally carries a unique instance token so two clips never share state; the plugin always sees the clean URI.

Hooks

All hooks are optional except onOpen and one of the two render hooks.

Hook Direction Meaning
onOpen(uri) returns (width, height, length, fps, has_alpha) Called when the media is created / loaded. Create the per-instance state here. Do not touch OpenGL yet (see below).
onRenderFrame(frame) returns a pixel buffer Approach 1 and 2: return a H*W*4 uint8 buffer (bytes / bytearray / numpy), BGRA byte order, top-down. The host uploads it into the media texture.
onRenderFrameGL(frame, texture, width, height) returns bool Approach 3: render directly into the media texturetexture is its raw GL handle. Return True when the frame is in the texture; the pixel upload path is then skipped. Tried before onRenderFrame; a plugin implements one of the two.
onClose() The media is destroyed: release per-instance resources.
onRenderPanel() Draw ImGui controls (via mxw_imgui) in the clip panel, above the Video Info section.
onSizeChange(w, h) The host changed the render size: recreate size-dependent resources.
onSpeedRange() returns (min, max) Allowed clip playback speed range. Without it the media is locked to speed 1.
onSetSpeed(speed) The clip playback speed changed. Not clamped: 0 and negative values are allowed.
onSave() / onLoad(state) string round-trip Persist per-instance state in the project file.
onDisplayName() returns string Optional live display name (e.g. the web plugin reports the current page URL).

OpenGL rules for media plugins

These rules apply to approaches 2 and 3:

  • Never create a standalone GL context (no windowed/standalone moderngl.create_context() at import or in onOpen). MXWendler makes its own context current every frame; GL objects created in a foreign context will be dereferenced against the wrong context and crash the driver.
  • Instead, lazily attach on the first render call: moderngl.create_context() inside onRenderFrame / onRenderFrameGL attaches to MXWendler's context, which the host guarantees to be current there.
  • The host snapshots and restores all relevant GL state around the render hooks (FBO bindings, program, VAO, buffers, viewport, enables, pixel store). The plugin does not need to unbind anything.

Approach 1: rendering into pixels (CPU)

The simplest approach: compute the frame on the CPU and return it. No OpenGL knowledge required. The host uploads the buffer into the media texture through a PBO (pixel buffer object) double-buffer, so the upload is asynchronous and cheap — the cost of this approach is the CPU rendering itself.

import numpy as np

W, H = 640, 360
storage = {}

def onOpen(uri):
    storage[media_id] = {"phase": 0.0}
    return (W, H, 1, 60.0, True)          # width, height, length, fps, has_alpha

def onRenderFrame(frame):
    inst = storage[media_id]
    inst["phase"] += 0.01
    # a moving horizontal gradient, BGRA byte order, top-down
    x = (np.linspace(0.0, 1.0, W) + inst["phase"]) % 1.0
    row = np.zeros((W, 4), dtype=np.uint8)
    row[:, 0] = (x * 255).astype(np.uint8)     # B
    row[:, 2] = ((1 - x) * 255).astype(np.uint8)  # R
    row[:, 3] = 255                            # A
    return np.ascontiguousarray(np.tile(row, (H, 1, 1)))

def onClose():
    storage.pop(media_id, None)

Use this for: generated images, PIL/Pillow output, OpenCV results, slow-changing content.

Approach 2: rendering into an FBO and streaming

Render with OpenGL (e.g. via ModernGL, installed once with mxw-pip install moderngl numpy) into an offscreen framebuffer, read the pixels back and return them like in approach 1. The scene renders on the GPU, but every frame makes a round trip GPU → CPU (fbo.read()) → GPU (PBO upload).

Bundled reference: plugins/media/python/plugin_opengl_cube (github).

import numpy as np
import moderngl

storage = {}

class inst_t:
    def __init__(self):
        self.ctx = None
        self.fbo = None

def onOpen(uri):
    storage[media_id] = inst_t()
    return (1024, 1024, 1, 60.0, True)

def onRenderFrame(frame):
    inst = storage[media_id]
    if inst.ctx is None:
        # first frame: attach to MXWendler's context (never earlier!)
        inst.ctx = moderngl.create_context()
        color = inst.ctx.texture((1024, 1024), 4)
        depth = inst.ctx.depth_renderbuffer((1024, 1024))
        inst.fbo = inst.ctx.framebuffer([color], depth)
        # ... build program / vao here ...

    inst.fbo.use()
    inst.ctx.clear(0.0, 0.0, 0.0, 0.0, depth=1.0)
    # ... draw the scene ...

    # read back: GL is bottom-up RGBA, the host wants top-down BGRA
    raw = inst.fbo.read(components=4, alignment=1)
    img = np.frombuffer(raw, dtype=np.uint8).reshape((1024, 1024, 4))
    img = np.flipud(img)
    return np.ascontiguousarray(img[:, :, [2, 1, 0, 3]])

Use this for: GPU-rendered content when you also need the pixels on the CPU (analysis, recording), or as a stepping stone to approach 3.

Approach 3: rendering into a bound texture target (direct)

The fastest path: the host passes the raw GL handle of the media texture to onRenderFrameGL. The plugin attaches it to its own framebuffer and renders straight into it. No readback, no upload, no texture streaming — the frame never leaves the GPU.

Bundled reference: plugins/media/python/plugin_opengl_cube_direct.

import moderngl

storage = {}

class inst_t:
    def __init__(self):
        self.ctx = None
        self.fbo = None
        self.depth = None
        self.fbo_key = None       # (texture, w, h) the fbo was built for

def onOpen(uri):
    storage[media_id] = inst_t()
    return (1024, 1024, 1, 60.0, True)

def onRenderFrameGL(frame, texture, width, height):
    inst = storage[media_id]
    if inst.ctx is None:
        inst.ctx = moderngl.create_context()   # attach to MXWendler's context
        # ... build program / vao here ...

    # (re)wrap the host texture when the handle or size changed: the host
    # recreates the media texture on a render size change
    if inst.fbo_key != (texture, width, height):
        if inst.fbo:
            inst.fbo.release()
        if inst.depth:
            inst.depth.release()
        color = inst.ctx.external_texture(texture, (width, height), 4, 0, "f1")
        inst.depth = inst.ctx.depth_renderbuffer((width, height))
        inst.fbo = inst.ctx.framebuffer([color], inst.depth)
        inst.fbo_key = (texture, width, height)

    inst.fbo.use()
    inst.ctx.clear(0.0, 0.0, 0.0, 0.0, depth=1.0)
    # ... draw the scene; flip Y in the projection, see below ...

    return True    # the frame is in the texture: host skips the upload path

Rules specific to the direct approach:

  • Never release() the external texture wrapper — the GL texture belongs to MXWendler. Only release your own framebuffer and depth renderbuffer.
  • Rebuild the framebuffer when (texture, width, height) changes. The handle is not stable: a render size change makes the host recreate the media texture.
  • Flip Y. MXWendler media textures are top-down (row 0 = top of the image), a GL render is bottom-up. Negate the Y row of your projection matrix (approaches 1/2 flip on the CPU instead, e.g. with numpy.flipud).

Choosing an approach

1: Pixels (CPU) 2: FBO + streaming 3: Direct texture
Scene renders on CPU GPU GPU
Per-frame copies CPU → GPU upload GPU → CPU readback + CPU → GPU upload none
OpenGL required no yes yes
CPU access to the pixels yes (you made them) yes (after readback) no
Typical use generated images, PIL, OpenCV GPU render that also needs CPU pixels pure GPU generative content, best performance

Playlist plugins

A playlist plugin is an item placed in the playlist grid. It participates in the cue lifecycle: it can run logic when its cue is played, every frame while its cue is active, or every frame globally. Playlist control functions (mxw.playlist.play(), mxw.playlist.navigate_index(), ...) are documented in the Python Reference.

Hooks

Hook Required Meaning
onCreate() yes A new instance was placed in the grid (also called after hot-reload): create the per-instance state entry.
onDelete() yes The instance was deleted: release the state entry.
onAction() yes The item's cue was played (the item fires).
onPostAction() no Called after the action completed.
onSave() / onLoad(state) yes Serialize / restore per-instance state (string). Called on project save/load and around hot-reload.
onRenderPanel() yes Draw the item's settings panel with mxw_imgui.
getText() yes Detail text appended to the item's grid label.
onNewFrameAlways() no Called every rendered frame, regardless of cue state.
onNewFrameInPlayoutCue() no Called every rendered frame while the item's cue is the active playout cue.
onPause(is_paused) no Pause was pressed / released.
onPreparePlayback() / onCleanup() no Prepare / release playout resources.
onActivateInUI() no The item was selected in the user interface.
onActiveCueChange(direction) no The active cue changed past this item (navigation).
onSettingsChanged() no Item settings were changed from outside.
getDuration() no Report the item's action duration in milliseconds.
getTimeSinceOnActionIssued() no Report elapsed action time in milliseconds (drives the progress display).
getColorBG() no Return a mxw_imgui.Vec4 (rgb 0..255) to set the grid cell background color dynamically.
renderBlinking() no Return True to make the grid cell blink.

Minimal example

import pickle, codecs
import mxw
import mxw_imgui

class state_t:
    fire_count = 0

storage = {}

def onCreate():
    storage[item_id] = state_t()

def onDelete():
    storage.pop(item_id, None)

def onAction():
    # the item's cue was played
    storage[item_id].fire_count += 1

def getText():
    return " : fired %d times" % storage[item_id].fire_count

def onRenderPanel():
    inst = storage[item_id]
    mxw_imgui.text_unformatted("Fired %d times" % inst.fire_count)
    if mxw_imgui.button("Reset"):
        inst.fire_count = 0

def onSave():
    return codecs.encode(pickle.dumps(storage[item_id]), "base64").decode()

def onLoad(serialized):
    storage[item_id] = pickle.loads(codecs.decode(serialized.encode(), "base64"))

With a manifest:

[mxw_plugin]
plugin_version = 1
plugin_script_language = Python
plugin_menu_parent = IO
plugin_menu_name = Fire Counter
plugin_grid_name = Counter
plugin_tooltip = Counts how often its cue was played

Bundled references: plugins/playlist/python/plugin_advance_datetime (timed playlist navigation, rich settings panel — github), plugin_video_writer (github), plugin_ping.

Example plugins on GitHub

The bundled example plugins are maintained as public repositories under github.com/mxwendler — good starting points to fork for an own plugin:

Plugin Type Approach / purpose Repository
plugin_opengl_cube media Approach 2: ModernGL cube into an offscreen FBO, streamed mxw-plugin-opengl-cube
plugin_opengl_cube_direct media Approach 3: same cube rendered directly into the media texture ships with the installation
plugin_web_media media A web browser as media source (whole-scheme web:// plugin) mxw-plugin-web-media
plugin_advance_datetime playlist Timed playlist navigation with a rich settings panel plugin_advance_datetime
plugin_video_writer playlist Write video files from the playlist plugin_video_writer

Persistence

Both plugin types persist per-instance state through the onSave() → string / onLoad(string) pair. The string is stored inside the project file; anything goes, a common choice is base64-encoded pickle (see the playlist example above). Media plugins additionally have their render size stored and restored by the host automatically.

Troubleshooting

  • Nothing appears in the menu — check the manifest: plugin_version = 1, plugin_script_language = Python, a non-empty plugin_menu_name, and the level key (development plugins only show in debug builds).
  • Errors — open the interpreter console (IO dialog): hook exceptions are reported there (throttled). Remember that a throwing per-frame hook is blacklisted until reload.
  • Media shows blackonRenderFrame returned a buffer of the wrong size (must be width*height*4 bytes), or a GL plugin created its own context instead of attaching to MXWendler's (see the OpenGL rules).
  • Missing packages — install them with mxw-pip (see Installing own modules with mxw-pip) and make sure Prepend user site-path is enabled: the embedded interpreter does not see a system-wide Python installation's packages.