Plugin development
[object Object]
A plugin is an npm package installed on the host that adds something danio doesn't do itself, such as posting task status to a chat group or uploading films to a cloud drive. This page is about how to write one. For installing and managing plugins, see Plugins.
You write a plugin in JavaScript or TypeScript, and Bun runs it. danio starts one Bun process on the host and loads every enabled plugin into it.
A few things to know first
- It runs only on the host. Artists' computers and render machines don't install Bun and don't run plugins.
- danio manages the life cycle. Installing, enabling, updating, settings and logs are all in danio; you don't deploy a process yourself.
- One plugin's error affects only that plugin. A plugin that fails to load is marked on its own and the rest carry on. When the Bun process exits, danio restarts it, waiting 1 second at first and at most 5 minutes. Even if every plugin crashes, rendering is not affected.
- Permissions are declared. A plugin calls the workspace API through
ctx.apiand has only the permissions it declared and an admin confirmed. - It is not a sandbox. A plugin runs on the host with danio's rights, can use the network and can read and write files. Permissions limit only what it does to the workspace.
The package
A plugin is an npm package, or a local folder or a .tgz file. The danio field in package.json describes the plugin:
{
"name": "@my-studio/danio-chat-notify",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"engines": { "danio": ">=1" },
"danio": {
"name": "Chat notifications",
"summary": { "zh": "任务完成和出错时发到聊天群", "en": "Post task results to a chat group" },
"permissions": [],
"settings": {
"type": "object",
"required": ["webhookUrl"],
"properties": {
"webhookUrl": { "type": "string", "title": "Webhook URL", "secret": true }
}
}
}
}
| Field | Description |
|---|---|
main | The entry file. When missing, module is used, and then index.js |
engines.danio | The major version of the plugin interface it needs, currently 1. You can write >=1, ^1, 1, 1.x or *. If it isn't compatible, danio doesn't install it |
danio | Required. A package without this field is not a danio plugin |
danio.name | The name shown; the package name when missing |
danio.icon | The address of an icon, such as a data: address |
danio.summary | A one-line description per language: { "zh": "…", "en": "…" } |
danio.permissions | The workspace permissions it needs, such as workspace.view. A plugin that doesn't call ctx.api can write []. For the full list of permissions, see HTTP API |
danio.settings | A JSON Schema for the settings |
Settings
danio.settings is a JSON Schema, and the admin fills in the settings in the interface according to it. The interface understands these:
- The
typeof each item inpropertiescan bestring,number,integerorboolean; with anenum, it is a drop-down. titleis the name shown in the interface; the key is shown when it is missing.- When an item in
requiredisn't filled in, the plugin's row says so. - An item with
secret: trueis write-only: its input is a password field, after saving only "set" is shown, and leaving it empty means no change. The plugin itself can read it inctx.settings.
When the settings change, the plugin restarts.
The entry
The entry file exports a function by default. Its argument is ctx, and it returns the hooks the plugin implements. The function can be async:
export default async (ctx) => {
ctx.log('plugin loaded')
return {
events: {
types: ['job.done', 'job.attention'],
handle: async (event) => {
// handle the event
},
},
}
}
If it doesn't export a function, or the function throws, the plugin fails to load and the interface shows why.
There are two hooks now: events subscribes to events and output receives films. Both are optional.
ctx
| Member | Description |
|---|---|
ctx.api | A client for the workspace API: get(path), post(path, body), put(path, body), patch(path, body), delete(path). path must start with /api/v1/, and the result is parsed JSON. What it does is recorded under "Plugin: 403 …. A call takes at most 2 minutes |
ctx.settings | The settings the admin filled in, including the secret ones |
ctx.state | The plugin's own small key-value store: get(key), set(key, value), delete(key). Values are JSON, each at most 64 KB. It is kept in the host's database and goes with backups. get returns null when the key isn't there |
ctx.log | Write to the log: ctx.log(…), and ctx.log.info, warn, error, debug. console.log in a plugin is recorded here too |
ctx.notify(title, body) | Show a notice in the danio interface, marked with the plugin it comes from |
ctx.workspace | { id, name, addresses }: the workspace ID and name, and the list of the host's addresses |
For everything else, use what Bun and npm offer, such as fetch.
events: subscribe to events
events: {
types: ['job.done', 'shot.failed'],
handle: async (event) => {
const { type, data } = event
// …
},
}
types lists the event types to subscribe to; * means all. The event that handle receives has these fields:
| Field | Description |
|---|---|
id | The event ID, for removing duplicates: the same event may arrive more than once |
type | The event type |
at | When it happened, in ISO 8601 |
data | The event's data, see the table below |
The event types and the fields in data:
| Type | When | data |
|---|---|---|
job.submitted | A new task was submitted | jobId, version, by |
shot.done | A chunk finished rendering | jobId, version, shot (which chunk), frames, device |
shot.failed | A chunk failed | The same, plus error, attempts |
job.attention | A task stopped for a person to act: repeated failures, missing files, a failed stitch | jobId, version, reason (empty when paused by hand) |
output.ready | The film is stitched | jobId, version, output |
job.done | The task is done, after output.ready | The same as output.ready |
device.joined | A device joined | deviceId, device, access |
device.removed | A device was removed | deviceId, device |
For events with a jobId, data also has job (the task name), project (the project name) and projectId.
An example that posts to a chat group when a task is done:
handle: async ({ type, data }) => {
if (type !== 'job.done') return
await fetch(ctx.settings.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `${data.project} / ${data.job} v${data.version} finished rendering` }),
})
}
Delivery
danio first stores the event in the host's database, then gives it to the plugins that subscribed to its type, so it arrives at least once:
- If
handlethrows, or doesn't return within 1 minute, danio retries, waiting 2 seconds at first, doubling each time, at most 1 hour. - After the plugin process crashes or the host restarts, events not yet delivered are delivered then.
- Events that come while a plugin is disabled are kept and delivered when it is enabled again. Deleting the plugin deletes them too.
- An event not delivered within a day is given up, and danio writes a log line.
output: deliver films
When a film is stitched, danio hands it to every plugin that implements output, which uploads it to a cloud drive or another system:
output: {
handle: async ({ job, project, version, film }) => {
const res = await fetch(`https://example.com/upload/${film.name}`, {
method: 'PUT',
body: Bun.file(film.path),
})
return { url: `https://example.com/films/${film.name}`, note: `Uploaded to ${project}` }
},
}
The argument of handle has:
| Field | Description |
|---|---|
jobId, job, project, projectId, version | The task and the version |
output | Where the film is in the store |
film.path | The path of a temporary file on the host. The film has already been fetched from the store, so the plugin needs no store credentials |
film.name | The file name: the task name, the version number and the extension, such as sh010-Main_v2.mp4 |
film.size | The size in bytes |
film.open() | Returns a stream that reads the file |
When handle returns, the temporary file is deleted.
The returned url and note are both optional. They show under Delivery in the task details, one row per version and plugin: sending, retrying, sent, failed.
When handle throws, danio retries as it does for events, waiting at most 1 hour between tries, and one try runs at most 1 hour. If it hasn't succeeded within a day, it is marked failed. After a failure, click Send again, or call POST /api/v1/jobs/{id}/handovers/{plugin}/retry, to hand it to the plugin again.
Install and debug locally
While you write a plugin you don't need to publish it to npm. Install a local folder or .tgz on the host:
# install a folder
danio plugin add ./my-plugin
# or pack it first
npm pack
danio plugin add ./my-plugin-1.0.0.tgz
danio plugin add first shows the permissions the plugin needs and enables it only after you answer y. Add --yes to skip the question.
After changing the code, run danio plugin update. danio reinstalls every plugin from where it came from, keeping settings and permissions.
| Command | What it does |
|---|---|
danio plugin [--json] | List plugins: state, version, permissions, subscribed events, the latest error |
danio plugin add <npm package | folder | .tgz> [--yes] | Install, and enable after the permissions are confirmed |
danio plugin on|off|rm <plugin> | Enable, disable, delete |
danio plugin update | Reinstall every plugin from its own source |
danio plugin config <plugin> [key=value]… | Change settings; without values it asks one by one. secret items can't be written on the command line |
For on and config, <plugin> can be the ID, the display name or the package name; off and rm accept only the ID. The ID is the package name without the @, with every character that isn't a lowercase letter, digit, ., _ or - replaced by -; for @my-studio/danio-chat-notify it is my-studio-danio-chat-notify.
These commands run on the host. In the interface, on the Plugins and integrations page, each plugin's menu has Log, which shows the plugin's log, including what console.log prints.
Where Bun comes from
danio looks in this order: the one named by the environment variable DANIO_BUN, the one on PATH, and the one downloaded earlier into runtimes/bun/ in the data directory. If there is none, it downloads the latest release from Bun's GitHub releases page, checks it against its SHASUMS256.txt and puts it there. Plugins are installed in data/plugins/<ID>/ in the data directory.