3. Python plugin reference: Difference between revisions
No edit summary |
No edit summary |
||
| (One intermediate revision by the same user not shown) | |||
| Line 36: | Line 36: | ||
# The installation / resource directory: <code>plugins/media/python/</code> | # The installation / resource directory: <code>plugins/media/python/</code> | ||
Playlist plugins: | Playlist plugins, descending precedence: | ||
# The open <code>.mxw</code> project's <code>plugins/playlist/python/</code> tree (opt-in via settings, highest precedence) | |||
# Directories configured explicitly in the settings (Python tab) | |||
# The per-user data directory: <code>.../plugins/playlist/python/</code> | # The per-user data directory: <code>.../plugins/playlist/python/</code> | ||
# The installation / resource directory: <code>plugins/playlist/python/</code> | # The installation / resource directory: <code>plugins/playlist/python/</code> | ||
| Line 439: | Line 441: | ||
| <code>plugin_video_writer</code> || playlist || Write video files from the playlist || [https://github.com/mxwendler/plugin_video_writer plugin_video_writer] | | <code>plugin_video_writer</code> || playlist || Write video files from the playlist || [https://github.com/mxwendler/plugin_video_writer plugin_video_writer] | ||
|} | |} | ||
=== Developing a new plugin inside a project === | |||
The most convenient place to develop a plugin is '''inside a project folder''': the plugin travels with the show, needs no write access to the installation, and overrides a same-named bundled plugin. Conceptually: | |||
# '''Create a project folder''' — a plain directory that will hold everything belonging to the show, e.g. <code>D:/shows/particles/</code>. | |||
# '''Save the project file there''' — ''File → Save As..'' into that folder, e.g. <code>D:/shows/particles/particles.mxw</code>. The folder containing the <code>.mxw</code> file ''is'' the project folder; loading the project later makes MXWendler pick up its plugins again. | |||
# '''Create the plugin directory there''' — the project mirrors the installation layout: <code>D:/shows/particles/plugins/media/python/<plugin_folder>/</code> for media plugins (<code>plugins/playlist/python/...</code> for playlist plugins). Enable ''Settings → Python → Check for plugins inside the project folder'' — project plugins have the highest precedence of all plugin locations. | |||
=== Letting Claude Code write the plugin === | |||
An AI coding agent like [https://claude.com/claude-code Claude Code] can write a complete plugin from the contract described on this page. Start it '''in the project folder''', so it creates the plugin directly where MXWendler looks for it: | |||
<pre> | |||
cd D:/shows/particles | |||
claude | |||
</pre> | |||
Give it the reference material and a precise task — for a generative OpenGL particle system for example: | |||
<pre> | |||
Create an MXWendler media plugin under plugins/media/python/plugin_particle_system/ | |||
(folder + mxw_plugin.ini + mxw_main.py). | |||
Reference: the bundled example in <MXWendler install dir>/plugins/media/python/ | |||
plugin_opengl_cube_direct/ - follow its structure and hook usage exactly, and the | |||
plugin howto at https://wiki.mxwendler.net/index.php?title=Python_Plugin_Howto. | |||
The plugin is a generative OpenGL particle system, addressed as | |||
generative://particle_system, rendered with ModernGL directly into the media | |||
texture via onRenderFrameGL (no readback, no onRenderFrame). Keep per-instance | |||
state keyed by media_id. Emit particles from the center, integrate velocity and | |||
gravity per frame scaled by the clip playback speed (onSetSpeed, onSpeedRange | |||
-5..5). Expose particle count, gravity and start size as sliders in | |||
onRenderPanel (mxw_imgui) and persist them via onSave/onLoad. Respect the GL | |||
rules: attach with moderngl.create_context() lazily on the first render call, | |||
never release the external texture, rebuild the fbo when (texture, w, h) | |||
changes, flip Y in the projection. | |||
</pre> | |||
Then iterate: create the media in a clip (it appears under its <code>plugin_menu_name</code>, or address <code>generative://particle_system</code> directly) and refine with follow-up prompts ("make the particles fade out", "add a color gradient slider", ...). Errors show up in the interpreter console. For playlist plugins, ''Settings → Python → Reload Python Plugins'' hot-reloads the edited source; for media plugins, recreate the media (or restart) to re-import the module. | |||
== Persistence == | == Persistence == | ||
Latest revision as of 22:34, 3 July 2026
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:
- The open
.mxwproject'splugins/media/python/tree (opt-in via settings, highest precedence) - Directories configured explicitly in the settings (Python tab)
- The per-user data directory, e.g.
%APPDATA%/MXWendler/plugins/media/python/ - The installation / resource directory:
plugins/media/python/
Playlist plugins, descending precedence:
- The open
.mxwproject'splugins/playlist/python/tree (opt-in via settings, highest precedence) - Directories configured explicitly in the settings (Python tab)
- The per-user data directory:
.../plugins/playlist/python/ - 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) anditem_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 byonRenderPanel()).
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:
- 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.
- 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
- 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 texture — texture 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 inonOpen). 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()insideonRenderFrame / onRenderFrameGLattaches 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 |
Developing a new plugin inside a project
The most convenient place to develop a plugin is inside a project folder: the plugin travels with the show, needs no write access to the installation, and overrides a same-named bundled plugin. Conceptually:
- Create a project folder — a plain directory that will hold everything belonging to the show, e.g.
D:/shows/particles/. - Save the project file there — File → Save As.. into that folder, e.g.
D:/shows/particles/particles.mxw. The folder containing the.mxwfile is the project folder; loading the project later makes MXWendler pick up its plugins again. - Create the plugin directory there — the project mirrors the installation layout:
D:/shows/particles/plugins/media/python/<plugin_folder>/for media plugins (plugins/playlist/python/...for playlist plugins). Enable Settings → Python → Check for plugins inside the project folder — project plugins have the highest precedence of all plugin locations.
Letting Claude Code write the plugin
An AI coding agent like Claude Code can write a complete plugin from the contract described on this page. Start it in the project folder, so it creates the plugin directly where MXWendler looks for it:
cd D:/shows/particles claude
Give it the reference material and a precise task — for a generative OpenGL particle system for example:
Create an MXWendler media plugin under plugins/media/python/plugin_particle_system/ (folder + mxw_plugin.ini + mxw_main.py). Reference: the bundled example in <MXWendler install dir>/plugins/media/python/ plugin_opengl_cube_direct/ - follow its structure and hook usage exactly, and the plugin howto at https://wiki.mxwendler.net/index.php?title=Python_Plugin_Howto. The plugin is a generative OpenGL particle system, addressed as generative://particle_system, rendered with ModernGL directly into the media texture via onRenderFrameGL (no readback, no onRenderFrame). Keep per-instance state keyed by media_id. Emit particles from the center, integrate velocity and gravity per frame scaled by the clip playback speed (onSetSpeed, onSpeedRange -5..5). Expose particle count, gravity and start size as sliders in onRenderPanel (mxw_imgui) and persist them via onSave/onLoad. Respect the GL rules: attach with moderngl.create_context() lazily on the first render call, never release the external texture, rebuild the fbo when (texture, w, h) changes, flip Y in the projection.
Then iterate: create the media in a clip (it appears under its plugin_menu_name, or address generative://particle_system directly) and refine with follow-up prompts ("make the particles fade out", "add a color gradient slider", ...). Errors show up in the interpreter console. For playlist plugins, Settings → Python → Reload Python Plugins hot-reloads the edited source; for media plugins, recreate the media (or restart) to re-import the module.
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-emptyplugin_menu_name, and the level key (developmentplugins 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 black —
onRenderFramereturned a buffer of the wrong size (must bewidth*height*4bytes), 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.