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
- Open Edit > Preferences... > Plugins and copy the plugins directory path.
- Copy the plugin template into that directory. Ask a teammate for the template folder, or fetch it through the plugin HTTP endpoints.
- Rename and edit the template's
plugin.jsonandserver.py. The folder name does not set the plugin identity. Theidinplugin.jsondoes. - Click Reload plugins. A valid plugin appears in the Process menu. If loading fails, the plugin row in Preferences lists every error.
- 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
.sailerproject file on disk. The open editor state is authoritative. - Use
TranscriptDoc.replace_spanfromserver/services/transcript_doc.pyfor transcript text changes. Do not rewriteword_segmentsyourself. - Read and write transcripts under
transcript_lock, then save withwrite_transcript_atomic, both fromserver/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_llmand embeddings throughfrom server.services.embeddings_router import embed_batch. Pass atiertocall_llm. Do not import provider SDKs orskell_e_routerdirectly, 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-pythonascv2,av,matplotlib,requests,mediapipe, andfastapi.ffmpegis onPATH. User machines cannot install packages for a plugin, and v1 does not support vendoredlib/folders or new compiled dependencies. - Check
cancel_eventat loop and phase boundaries. Stop any subprocesses you launched before returningModuleResult(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.
| Field | Required | Contract |
|---|---|---|
id | yes | Lowercase 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. |
name | yes | Non-empty Process menu and dialog label. Use an action such as Delete Long Silences. |
version | yes | Non-empty version string shown in Preferences. It is informational in v1. |
description | no | Description shown to the agent, tooling, and the Batch Process info tooltip. |
menuOrder | no | Finite number controlling Process menu position. Built-ins use 10 through 100. Plugins default to 200. |
activeLabel | no | Status label while the plugin runs, such as Detecting silences. Solar Sailer uses name if this is absent. |
requires | no | Capabilities required on every selected media item: audio, video, transcript, or sync_groups. |
batchable | no | Whether the plugin appears in Batch Process. Defaults to true. |
settings | no | Ordered list of fields for the generated settings dialog. |
completion | yes | { "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:
helpadds an info tooltip beside the label.devOnlyshows the field only in Dev Mode.visibleWhenhides the field until another field matches{ "key": "<other field>", "equals": <value> }.enabledWhendisables 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.
| Type | Control | Extra fields |
|---|---|---|
boolean | Checkbox | None |
number | Number input | Optional min, max, and positive step. Fractional values require a fractional step. |
slider | Slider and exact-value input | Numeric min and max are required. step is optional and must be positive. |
select | Dropdown | Non-empty options: [{ "value", "label" }]. Each value is a string, number, or boolean. default must match one value. |
text | Text input | Optional 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_idscontains the stable project IDs selected in the run dialog.file_paths_by_idmaps each available media ID to its absolute source path.project_pathis already the project's<project>.sailer.datadirectory. Do not append.data. Store persistent plugin files under a subfolder named after your plugin ID.settingscontains the generated dialog values plusdev_mode.cancel_eventis athreading.Event. Return promptly whencancel_event.is_set().progress_callbackaccepts 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, usepositionX = 0.5 - s*uandpositionY = 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)/WandpositionY = (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/guidereturns the plugin guide as Markdown.GET /plugins/templatereturns{"files": {"<name>": "<content>"}, "skippedNonText": [...]}.GET /plugins/listreturnspluginsDir,devPluginsDir, and one load record per plugin inplugins. Each record containsfolder,source,id,status,errors, andmanifest. Failed records havemanifest: null.POST /plugins/reloadreloads all plugins and returns the list payload plusreloadSeq. It returns HTTP 409 while a plugin run is pending or running.POST /modules/{id}/runwith{"media_ids": ["..."], "settings": {...}, "batch_id": null}submits a run.batch_idis optional. The project must be saved and at least one media ID must be valid. The response containstask_id,module,status: "submitted", andseed.GET /modules/tasks/statusreturns current and recent module tasks undertasks. Each task containstask_id,module_id,status,progress,error,media_ids,summary,settings,started_at, anderror_kind.POST /modules/tasks/{task_id}/cancelrequests 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
- Import
server.pyfrom a scratch script or pytest and callrun()with a small test file. Assert on the returnedModuleResult. - 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.
- Mock AI at the app wrapper, for example
monkeypatch.setattr("<your module>.call_llm", fake). - Run the plugin in a throwaway Solar Sailer project. For
rangesandtransforms, confirm the edit maps correctly through trims, split clips, and linked clips. Use Ctrl+Z to reset the whole applied batch. - 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.