Skip to main content
Version: 0.4.1

Plugin Development Guide

Build a Solar Sailer plugin with one plugin.json manifest and one Python file. Solar Sailer supplies the Process menu entry, settings dialog, progress, cancellation, and result handling.

Plugins are trusted code. server.py runs inside Solar Sailer's Python server with full app privileges and no sandbox, so only install code you would run as a normal program.

Start from the template​

  1. Open Edit > Preferences... > Plugins and copy the plugins directory path.
  2. Copy the plugin template into that directory. Ask a teammate for the template folder, or fetch it through the plugin HTTP endpoints.
  3. Rename and edit the template's plugin.json and server.py. The folder name does not set the plugin identity. The id in plugin.json does.
  4. Click Reload plugins. A valid plugin appears in the Process menu. If loading fails, the plugin row in Preferences lists every error.
  5. Run the plugin from Process. After each code change, click Reload plugins and run it again. You do not need to build the plugin or restart the app.

The folder needs these two files:

my-plugin/
plugin.json
server.py

You may add a README or test data. Keep executable plugin code in server.py, because reload does not clear imported helper modules from Python's module cache.

To work from another directory, start Solar Sailer with PROKO_PLUGINS_DEV_DIR=<your folder>. Solar Sailer scans that directory first, so its copy of a plugin ID wins over an installed copy.

Reload is refused while any plugin run is pending or running. Finish or cancel the run first. Reload creates a fresh module object for each server.py, but it does not stop background threads. Do not start threads that outlive run(), and do not perform work at import time beyond register(...). Restart Solar Sailer if your plugin does not fit this single-file reload contract.

Follow the safety rules​

  • Change the timeline only through the four completion modes. Do not mutate Redux state, dispatch commands, or write timeline-shaped files for Solar Sailer to discover.
  • Never edit a .sailer project file on disk. The open editor state is authoritative.
  • Use TranscriptDoc.replace_span from server/services/transcript_doc.py for transcript text changes. Do not rewrite word_segments yourself.
  • Read and write transcripts under transcript_lock, then save with write_transcript_atomic, both from server/services/transcript_io.py. For slow work such as an AI call, read under the lock, release it, do the slow work, reacquire the lock, reread and merge, then write atomically.
  • Call AI models through from server.services.llm_router import call_llm and embeddings through from server.services.embeddings_router import embed_batch. Pass a tier to call_llm. Do not import provider SDKs or skell_e_router directly, and do not read or write provider key files.
  • Use Python's standard library or the supported packages already bundled with the app: numpy, scipy, librosa, soxr, pyloudnorm, psutil, opencv-python as cv2, av, matplotlib, requests, mediapipe, and fastapi. ffmpeg is on PATH. User machines cannot install packages for a plugin, and v1 does not support vendored lib/ folders or new compiled dependencies.
  • Check cancel_event at loop and phase boundaries. Stop any subprocesses you launched before returning ModuleResult(status="cancelled").

These rules keep timeline edits atomic, preserve linked clips, and leave one useful undo step when the completion mode changes the timeline. Transcript-sidecar edits are permanent and do not enter the timeline's undo history.

Write the manifest​

Solar Sailer validates plugin.json before it imports server.py. Load errors appear in Preferences > Plugins.

FieldRequiredContract
idyesLowercase snake_case matching ^[a-z][a-z0-9_]*$. It must be unique and must equal the ModuleDefinition.name registered by server.py. The IDs batch, conform, waveform, poster, sprite, proxy, capability, and audio_quality are reserved.
nameyesNon-empty Process menu and dialog label. Use an action such as Delete Long Silences.
versionyesNon-empty version string shown in Preferences. It is informational in v1.
descriptionnoDescription shown to the agent, tooling, and the Batch Process info tooltip.
menuOrdernoFinite number controlling Process menu position. Built-ins use 10 through 100. Plugins default to 200.
activeLabelnoStatus label while the plugin runs, such as Detecting silences. Solar Sailer uses name if this is absent.
requiresnoCapabilities required on every selected media item: audio, video, transcript, or sync_groups.
batchablenoWhether the plugin appears in Batch Process. Defaults to true.
settingsnoOrdered list of fields for the generated settings dialog.
completionyes{ "mode": "ranges" | "review" | "report" | "transforms", "rangeAction"?: "delete" | "disable", "ripple"?: boolean }. rangeAction and ripple are valid only for ranges.

Use the template's plugin.json as a working example.

Define settings​

Every settings entry needs key, type, label, and default. The current values arrive in run() as the settings dictionary.

All field types accept these optional properties:

  • help adds an info tooltip beside the label.
  • devOnly shows the field only in Dev Mode.
  • visibleWhen hides the field until another field matches { "key": "<other field>", "equals": <value> }.
  • enabledWhen disables the field until another field matches the same condition shape.

The condition must reference another field. Its equals value must use the referenced field's value type. For a select, it must match one of the option values.

TypeControlExtra fields
booleanCheckboxNone
numberNumber inputOptional min, max, and positive step. Fractional values require a fractional step.
sliderSlider and exact-value inputNumeric min and max are required. step is optional and must be positive.
selectDropdownNon-empty options: [{ "value", "label" }]. Each value is a string, number, or boolean. default must match one value.
textText inputOptional placeholder. default must be a string.

Settings keys must be unique. dev_mode and seam_fades are reserved. Solar Sailer injects dev_mode into every run. In ranges mode, Solar Sailer adds its own Add audio fades at cuts option and sends the choice as seam_fades. Your run() should ignore seam_fades, because Solar Sailer applies the fades with the range edits.

Write the module​

At import time, server.py must register exactly one ModuleDefinition. Its name must match the manifest ID.

from server.modules.registry import register
from server.modules.types import ModuleDefinition, ModuleResult

def run(*, media_ids, file_paths_by_id, project_path, settings,
cancel_event=None, progress_callback=None) -> ModuleResult:
...

register(ModuleDefinition(
name="my_plugin_id", # Must equal plugin.json "id"
display_name="My Plugin",
description="One line.",
func=run,
))

Keep the run arguments keyword-only and use these exact names:

  • media_ids contains the stable project IDs selected in the run dialog.
  • file_paths_by_id maps each available media ID to its absolute source path.
  • project_path is already the project's <project>.sailer.data directory. Do not append .data. Store persistent plugin files under a subfolder named after your plugin ID.
  • settings contains the generated dialog values plus dev_mode.
  • cancel_event is a threading.Event. Return promptly when cancel_event.is_set().
  • progress_callback accepts a float from 0 through 1. Report monotonically and cap your own progress at 0.95. Solar Sailer marks the final completion.

ModuleDefinition.requires is different from the manifest's requires. If you set it, it must contain registered module names such as transcribe, not media capabilities.

One run() call receives the whole selected batch. Solar Sailer does not call your function once per media item. If you parallelize the batch, use a bounded ThreadPoolExecutor, keep progress monotonic, and shut it down with cancel_futures=True when cancelled.

Return a ModuleResult with one of these statuses: completed, needs_review, failed, or cancelled. Put readable failures in errors and name the media item and cause. For a partially successful run, repeat each skipped item and reason in summary.report; errors may be lost if the renderer disconnects before it receives the live completion event. Use summary.report for the normal result, counts, and those durable skip notes.

Return results​

The manifest's completion.mode tells Solar Sailer how to handle ModuleResult.summary. A plugin cannot apply an edit through another route.

Delete or disable ranges​

Use ranges when your plugin finds source-time spans to delete or disable.

ModuleResult(status="completed", summary={
"range_edits": {
"ranges": {
"<media_id>": [
{"start_sec": 12.4, "end_sec": 15.9},
],
},
"action": "delete", # Optional per-run override of rangeAction
"ripple": True, # Optional per-run override, delete only
},
"report": "Found 12 silences.\nSkipped intro.mp4: no audio stream",
})

start_sec and end_sec are seconds from the start of the source media, not timeline positions. Solar Sailer maps them through every matching timeline clip, including trims, then applies the edit as one undo step while keeping linked clips in sync. A missing or malformed range_edits payload is an error. An empty ranges object reports that there is nothing to apply.

Send suggestions to Review​

Use review for transcript spans that a person should approve. Append candidates to the transcript's review_candidates list with this shape:

{
"word_id_start": 120,
"word_id_end": 128,
"text": "Candidate text",
"module": "my_plugin_id"
}

Write the transcript under transcript_lock with write_transcript_atomic. Use TranscriptDoc for any text changes. On completion, Solar Sailer refreshes the transcript and places candidates tagged with your plugin ID in the Review tab.

Review candidates must anchor to transcript word spans in v1. Resolving a candidate marks its words reviewed for every module, so candidates from two modules on the same words share resolution state.

Show a report​

Use report for analysis that does not edit the timeline. Return a string in summary.report. The first non-empty line is the completion message. Later lines appear as details.

ModuleResult(status="completed", summary={
"report": "Found 4 framing changes.\nLongest stable shot: 18.2 seconds",
})

Transform video ranges​

Use transforms to apply visual transform patches over source-time ranges.

ModuleResult(status="completed", summary={
"transform_edits": {
"transforms": {
"<media_id>": [
{
"start_sec": 12.4,
"end_sec": 15.9,
"transform": {
"scale": 1.5,
"positionX": -0.25,
"positionY": -0.25,
},
},
],
},
},
"report": "Zoomed 3 close-ups.\nSkipped intro.mp4: no face found",
})

Solar Sailer maps the media-time ranges through matching video clips, including trims and splits. Audio-only media has no visual target. It splits clips at the range boundaries, keeps linked boundaries aligned, and patches the in-range video segments. Nothing moves, and the batch is one undo step.

Each range must have start_sec >= 0 and start_sec < end_sec. A transform patch must set at least one of positionX, positionY, scale, scaleX, scaleY, rotation, opacity, cropLeft, cropTop, cropRight, or cropBottom. Values must be finite numbers or null. An omitted property keeps the current value. null resets that property to its default.

scale, scaleX, and scaleY accept 0 through 100, where 1 means 100 percent. Do not combine uniform scale with scaleX or scaleY. opacity accepts 0 through 1. Crop values are non-negative source pixels and are rounded to integers. The combined left and right crop must leave at least one source pixel, as must the combined top and bottom crop.

Unknown keys, empty patches, invalid values, and invalid ranges reject the whole result. Ranges with the same patch may touch or overlap and will merge. Ranges with different patches must not overlap on the same clip.

Position a zoom correctly​

Scale and position use the top-left corner of the fitted frame as their anchor. Only rotation pivots around the center. A bare scale: s grows the frame right and down, so pair every zoom with a position:

  • For a centered zoom on full-frame video, use positionX = positionY = -(s-1)/2.
  • To keep source point (u, v) in the preview center, where each coordinate is a fraction from 0 through 1, use positionX = 0.5 - s*u and positionY = 0.5 - s*v.
  • For letterboxed, pillarboxed, or cropped media, use the cropped source's aspect-fit rectangle in project resolution W x H: positionX = (W/2 - fitX - u*fitW*s)/W and positionY = (H/2 - fitY - v*fitH*s)/H.

For example, this keeps a detected face center at (0.62, 0.35) centered during a 1.5x zoom:

s = 1.5
u, v = 0.62, 0.35
zoom = {"scale": s, "positionX": 0.5 - s * u, "positionY": 0.5 - s * v}
ranges = [{"start_sec": 12.4, "end_sec": 15.9, "transform": zoom}]

Build a plugin over HTTP​

The plugin endpoints use the same bearer token as the rest of the Solar Sailer API.

  • GET /plugins/guide returns the plugin guide as Markdown.
  • GET /plugins/template returns {"files": {"<name>": "<content>"}, "skippedNonText": [...]}.
  • GET /plugins/list returns pluginsDir, devPluginsDir, and one load record per plugin in plugins. Each record contains folder, source, id, status, errors, and manifest. Failed records have manifest: null.
  • POST /plugins/reload reloads all plugins and returns the list payload plus reloadSeq. It returns HTTP 409 while a plugin run is pending or running.
  • POST /modules/{id}/run with {"media_ids": ["..."], "settings": {...}, "batch_id": null} submits a run. batch_id is optional. The project must be saved and at least one media ID must be valid. The response contains task_id, module, status: "submitted", and seed.
  • GET /modules/tasks/status returns current and recent module tasks under tasks. Each task contains task_id, module_id, status, progress, error, media_ids, summary, settings, started_at, and error_kind.
  • POST /modules/tasks/{task_id}/cancel requests cancellation and returns {"ok": true} for a known module task.

Know the v1 limits​

A plugin cannot import new files into the media bin, add a custom panel or React interface, render effects or transitions, or drive the chat agent. The four completion modes are the entire result interface. If they cannot express the edit you need, the plugin API needs a new command.

Test the plugin​

  1. Import server.py from a scratch script or pytest and call run() with a small test file. Assert on the returned ModuleResult.
  2. Load the plugin through the real plugin loader to catch manifest and registration errors. A useful fixture is a short synthesized WAV rather than a large production file.
  3. Mock AI at the app wrapper, for example monkeypatch.setattr("<your module>.call_llm", fake).
  4. Run the plugin in a throwaway Solar Sailer project. For ranges and transforms, confirm the edit maps correctly through trims, split clips, and linked clips. Use Ctrl+Z to reset the whole applied batch.
  5. Test cancellation during the slowest phase, not only before the run starts.

Share the plugin​

Distribute the plugin as a folder in a git repository or shared drive. The recipient copies it into the plugins directory shown in Preferences > Plugins, replacing the previous copy when updating, then clicks Reload plugins.

Solar Sailer v1 has no plugin marketplace or automatic plugin updates. The version in plugin.json is a label, not an updater.

For an external script that controls Solar Sailer over HTTP without becoming a Process menu tool, see Scripting Solar Sailer.