Plugin reference

The contract every plugin is written against: plugin API 2. New to plugins? Start with the tutorial track.

AvialSync discovers plugins from the avialsync.loaders and avialsync.sessions entry-point groups, and from drop-ins in ~/.avialsync/plugins/: single *.py files and package folders with an __init__.py, plus pure-Python libraries in its lib/ folder. Drop-ins load in the installed application exactly as from source. The highest non-zero can_open(path) score wins; when two plugins tie, the import review asks the user rather than letting load order decide. File → Plugins lists, reloads and switches plugins off. A plugin must be importable without importing Qt.

Every plugin class may also define api_version (default: the running version; another version is refused), plugin_id(), settings() and configure() (Plugin settings).

Time-series plugins

Subclass TimeSeriesSource, implement can_open, open, channels, and read_chunks, then register the class in your package metadata:

[project.entry-points."avialsync.loaders"]
my_format = "my_plugin:MySource"

open() and read_chunks() execute in an importer worker thread. Each yielded pair contains one-dimensional, globally chronological NumPy arrays. Preserve NaNs; AvialSync owns cache construction, decimation, and gap detection. Duplicate timestamps keep the final value. Sort non-monotonic input or raise NonMonotonicTimeError; never emit decreasing timestamps.

See examples/plugins/avialsync-plugin-example for an installable toy binary loader.

Claiming a whole recording folder

can_open(path) is offered directories as well as files, so a rig with its own folder layout can be supported without changing AvialSync:

@classmethod
def can_open(cls, path: Path) -> float:
    return 1.0 if path.is_dir() and (path / "myrig.marker").exists() else 0.0

When a plugin claims a directory, the drop scan stops there and hands the whole folder to it instead of recursing into the loose files inside. Your open() receives the directory, and the folder becomes one source with as many channels as you declare.

To present one folder as several sources with different roles — separate videos, pose data routed to the 3D view, a sensor trace, all sharing one anchor time — implement SessionSource instead.

Session plugins

A session is a directory that is a recording rather than one that merely contains files. Subclass SessionSource and publish it under the avialsync.sessions entry-point group, or drop the module into ~/.avialsync/plugins/:

from avialsync.core.source import SessionItem, SessionLayout, SessionSource


class MyRigSession(SessionSource):
    @classmethod
    def can_open(cls, path: Path) -> float:
        return 1.0 if (path / "rig.marker").exists() else 0.0

    def scan(self, path: Path, registry) -> SessionLayout:
        items = [
            SessionItem(
                video,
                registry.find_best_loader(video),
                source_epoch=start,  # when this camera's first frame was exposed
            )
            for video, start in cameras(path)
        ]
        items.append(
            SessionItem(
                path / "wheel.csv",
                MyEncoderLoader,
                {"role": ""},
                source_epoch=midnight,  # its timestamps count from midnight
            )
        )
        return SessionLayout(
            items=items,
            session_epoch=min(start for _video, start in cameras(path)),
            anchor_epoch=...,
            camera_fps=...,
        )

The session scanner supplies the candidate files and their labels, kinds, and import defaults. They are shown in the same batch review used for ordinary drops, where the user confirms or changes each choice. A session plugin never imports its candidates without that review.

[project.entry-points."avialsync.sessions"]
myrig = "my_plugin:MyRigSession"

can_open is asked about every dropped directory before per-file scanning, so keep it cheap — a marker file or a name pattern, not a directory walk. scan runs off the UI thread and may read files.

Return session-wide settings as SessionLayout fields, not as extra items: session_epoch (the UTC instant you want master-clock zero to be — usually when the recording started), anchor_epoch (the UTC instant relative timestamps are measured from — it also switches the display to wall-clock time), camera_fps, skeleton (body-part pairs; declaring them takes precedence over the skeleton the 3D view otherwise detects from pairwise rigidity), and rotary (a RotaryHint naming the channel that carries a running wheel’s cumulative angle in degrees, and the wheel’s bar count and radius when your rig records them — it pre-fills the wheel setup in Props and is never applied on its own). It may also carry a generic PeriodicRotaryMotion plus motion_sign: AvialSync shows that as a reviewable rotation proposal after the wheel is placed, and the user explicitly applies it. Keep your rig’s model-file schema, unit conversion, and any timing interpretation inside the plugin. Set a SessionItem.loader of None to let capability resolution pick one, which is what you should do for ordinary video.

A recording that comes in parts – trials, recordings – declares them in segments, (start, end, name) in Unix seconds, placed with avialsync.core.trials.place: each part at its recorded start, the time between parts kept as a gap, and a part without a start or overlapping the one before left out and named. Pass Placement.BACK_TO_BACK only when your format defines its parts that way. Nothing more is needed for the rest: each stream’s Data Streams lane is empty between parts where it has no frame or sample, and View → Collapse Gaps Without Data hides only the time no stream recorded. Declare a stream recorded in every part once, joined: a JoinedVideoSource item whose config lists parts ({"path", "start"}, start in seconds from the item’s source_epoch) plays the files as one video in place, any codec; a JoinedTimeSeriesSource item adds part_loader ("module:Class") and each part’s own config, and reads every part with it (both in avialsync.loaders.joined_parts). One camera is then one pane and one lane, one tracked body one 3D object.

session_epoch and anchor_epoch are commonly different, and both are useful: a rig whose logs are written as seconds since midnight has an anchor_epoch of that midnight, while its master zero is the instant the cameras started, hours later. Declare session_epoch rather than letting it be derived — sources load concurrently, so a derived zero would depend on which file happened to finish first.

Optional: recordings split across folders

Some rigs write one recording into several folders that only mean something together, such as a microscope’s trials and the camera PC’s recordings of them. scan sees one folder at a time and cannot pair them. Override scan_together to claim several dropped paths at once:

def scan_together(self, paths, loaded, registry):
    mine = [path for path in paths if belongs_to_my_rig(path)]
    if len(mine) < 2:
        return None  # leave every path to scan()
    return lay_out_together(mine), mine

It is asked once per drop, before any path is scanned on its own. Return the layout and the dropped paths it used (those are not scanned again), or None. loaded lists the sources already open as SessionItems whose source_epoch is where each source’s own zero sits now, so a later drop can be placed against what the user already has, including any hand corrections. The default claims nothing.

Say where your timestamps start, never where the source should go

SessionItem.source_epoch is the UTC instant your file’s t=0 is, and it is the only timing question a session has to answer. The application derives the placement from it (session_zero - source_epoch), so you never compute an offset:

Your file’s timestamps are…

Declare

seconds from its own first frame (a video container)

that frame’s UTC instant

seconds since midnight

that midnight

Unix epoch seconds already

0.0, or nothing — they are recognised

something with no knowable instant

nothing; it keeps its own zero

Always declare an absolute instant. A session-relative number declares nothing: 34526 is equally 09:35:26 and a nine-hour elapsed time, and the application cannot tell which you meant.

Never put a placement in config. config is hashed into the cache key, so an offset there lets a re-placement invalidate the samples underneath it — and it lands in the offset control the user nudges by hand, which is how an AOL session came to open with -34526 s already typed into every camera. source_epoch is a SessionItem field for exactly that reason, as label and coverage_group are.

If your scan leaves something out, say so in warnings. A folder holding one unreadable recording beside three good ones should still yield the three — do not fail the whole scan — but returning fewer items and nothing else leaves a session that looks complete while missing data. Each string is shown to the user; the log is not.

return SessionLayout(items=items, warnings=[f"{name} could not be read — {why}"])

config reaches the loader as its import config, and is hashed into the sidecar cache key — so put what the loader needs to read the file there, and nothing about where the file belongs in time. One key is interpreted by the application: role routes a source away from the plot rows — "pose3d" to the 3D view, "overlay2d" (with overlay_video) to that camera’s overlay.

For a direct file import, a TimeSeriesSource that can yield coordinate channels can override pose_roles() to offer "pose3d" and/or "overlay2d" in the import review. The default is empty, so existing plugins keep their current behavior. The user chooses the role and, for 2D, the target video. A session scanner can declare the same choice in each SessionItem.config; the review starts with that choice selected. The application checks for complete _x/_y or _x/_y/_z channel groups after import and plots the channels if the declared pose is unusable.

A bare offset in config still works, and means the whole source-to-master mapping rather than a correction on top of a placement. Prefer source_epoch: it composes with the session zero, survives a source being re-placed, and leaves the offset control free for what the user actually uses it for.

If your scanner raises, the folder falls back to per-file scanning and the reason appears in Help → Diagnostics; a broken plugin never makes a folder unopenable. loaders/aol_session_loader.py is a full worked example.

Naming your format

The import dialog lists whatever the registry found, and each format supplies its own label — override display_name() to control it, and display_aliases() to be offered under several. Both have defaults, so this is optional.

Optional: single-pass bulk ingest

read_chunks is called once per channel, so a format parsed in one pass is re-parsed for every channel it declares — an 80-channel file, 80 times. If your format is like that, you may also define read_all_chunks:

def read_all_chunks(self) -> Iterator[dict[str, tuple[np.ndarray, np.ndarray]]]:
    """Yield {channel_name: (t, v)} per chunk, every declared channel aligned."""

AvialSync uses it in place of the per-channel calls when it is present, and falls back to read_chunks when it is not. This is an optional extension, not part of the abstract contract: it is absent from TimeSeriesSource, and a plugin that never defines it is fully supported. If you do define it, every chunk must carry every channel you declared, on the same rows, under the same ordering, duplicate, and NaN rules as read_chunks — both paths must build the same cache from the same file.

Optional: messages the recording carries

Many formats store prose the experimenter wrote — an acquisition system’s annotation stream, a commented file header, a note appended when the session stopped. If yours does, return it from messages():

from avialsync.core.messages import Message


def messages(self) -> list[Message]:
    """Return free-text records, in this source's own timeline."""
    return [Message(text="stimulus on", time=61.4, channel="MessageCenter")]

It is called once after open(), on the import thread, and has a default that returns nothing, so a plugin that never defines it is fully supported. Times are in your source’s timeline — the same one read_chunks yields — so the session’s alignment moves a note and the samples it describes together.

Leave time as None for anything the file did not timestamp. A header or a closing comment has no place on the clock, and AvialSync shows those as untimed notes rather than pinning them to the start of the recording. Do not substitute 0.0: that asserts a moment the file never recorded.

Messages are displayed read-only, in their own Inspector page and timeline lane. They are not annotations — the user’s own markers are separate, editable, and exported as their work.

Do not return a note the file wrote about itself. A sync preamble — the line declaring the recording’s start sample or its software clock — places the clock rather than saying anything a person wrote, and listing it puts the same two rows at the head of every recording a user opens. Parse it for the times you need and withhold it, as both Open Ephys readers do with sync_messages.txt and with messages.events’ Software time: and start time: lines.

If your format stores a message as a sample number, divide by the rate onto the same axis your read_chunks timestamps use. Rebasing onto the first recorded sample when your samples are not rebased offsets every note from the trace it describes by the recording’s own start.

Video plugins

Subclass VideoSource and implement every abstract method. open() runs in a background worker. If needs_conversion() is true, prepare(progress_cb) must create a playable cached file and return it; AvialSync opens only media_path() after preparation succeeds. time_bounds() returns (metadata_start, metadata_start + duration) when a UTC start is available, otherwise (0.0, duration). start_time() is only a metadata guess: the user offset always takes precedence.

video_metadata() is an optional, source-compatible extension with a default implementation. Override it to return VideoMetadata when the format exposes codec, byte size, and timestamp-derived CFR/VFR evidence.

Imaging plugins

Subclass ImagingSource for a time-indexed image stack that is not a video container. open(path, config) returns ImagingMetadata with a one-dimensional, strictly increasing frame_times array in source seconds, image shape, pixel dtype, and channel_count. read_frame(index, channel=0) returns one two-dimensional NumPy plane at that presentation index and channel, in the file’s own pixel units. close() releases the handle. The application opens and reads the source on a background thread, requests only the planes the current picture needs, and coalesces requests during playback. A plugin should use native slice or page reads and must not materialize the entire stack. Publish it under avialsync.loaders.

When the file does not settle something only the user can answer — a frame rate, which dataset or series, the axis order, a depth plane — raise avialsync.core.errors.ImagingChoiceRequired with the config key as choice (fps, dataset, series, axes or z) and the valid answers as options when the file lists them. The application asks, adds the answer to config, opens again, and saves the answer with the session. Never substitute a default. The HDF5/TIFF built-ins demonstrate the contract; a session plugin can also provide these choices in SessionItem.config.

Trigger lines

There is no trigger-plugin kind. A loader declares a trigger line on the channel that carries it: ChannelInfo(trigger=..., trigger_for=...), with trigger one of frame_strobe, frame_trigger, sync_train or sparse_events. The sync wizard starts from the declared kind; the user still accepts the fit. See Trigger lines.

Keep laboratory-specific parsing and semantics in the plugin. AvialSync preserves raw timestamps, performs the alignment, shows the matched evidence and fit quality, and requires user acceptance before changing a source mapping. It does not provide acquisition drivers or built-in scientific analysis.