Plugin Development Guide
For third-party developers building draft tools for PixiuCut. The contract is SDK 1.0.0 (manifest.sdkVersion is ^1.0.0). Implement against the method names, fields, and error codes here — you do not need to read the app source.
1. What plugins can do
A plugin is a draft transformer: it reads the user's draft and writes one or more new drafts. When a finished video is needed, it can ask the app to send a saved draft into the task center for export.
input = a draft (plus the user's own media files)
output = one or more new drafts
option = ask the app to export drafts into a directory the user chose
Good fits:
- Replace media in one template draft to produce many variants, then queue them for export
- Read video info or bring your own audio, call your own speech service, or hand the media to the app's on-device speech model, and write a new draft with subtitles
- Turn another app's project description into a PixiuCut draft
- Sync drafts to your own storage
2. Five-minute example
Minimal plugin
hello-plugin/
├── manifest.json
└── index.html
manifest.json
{
"id": "com.example.hello",
"name": "Hello Plugin",
"version": "1.0.0",
"description": "My first plugin",
"author": "Your Name",
"main": "index.html",
"sdkVersion": "^1.0.0",
"permissions": ["draft.read", "draft.write", "ui.notify"],
"slots": [
{ "id": "home.shortcut", "label": "Hello" }
],
"window": { "width": 640, "height": 480 }
}
index.html — use the global PixiuCutPlugin. Do not ship an SDK in the package, and do not write <script src="sdk.js">.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hello Plugin</title>
</head>
<body>
<h2>Hello PixiuCut</h2>
<p id="info">Loading…</p>
<button id="btn">Make a variant draft</button>
<script>
const sdk = PixiuCutPlugin
async function init() {
const drafts = await sdk.draft.list()
document.getElementById('info').textContent =
`Connected. ${drafts.length} draft(s).`
await sdk.ready()
}
document.getElementById('btn').onclick = async () => {
const drafts = await sdk.draft.list()
if (!drafts.length) {
await sdk.ui.notify('Create a draft first', 'warning')
return
}
const { blob } = await sdk.draft.readBlob(drafts[0].id)
const name = drafts[0].name + ' copy'
await sdk.draft.create(name, blob)
await sdk.ui.notify('Created: ' + name, 'success')
}
init().catch((e) => {
document.getElementById('info').textContent = 'Error: ' + e.message
})
</script>
</body>
</html>
How the SDK enters the page
When a plugin opens, the app's local page server:
- Serves
/sdk.js - Inserts that script when it returns the plugin HTML
- Strips a handwritten
sdk.jsreference so the script is not loaded twice
The page runs in a sandboxed iframe: sandbox="allow-scripts allow-forms allow-modals", without allow-same-origin.
| What you see | What it means |
|---|---|
Opaque origin (Origin: null) | The page is not same-origin with the shell |
| The shell cannot read the iframe document | The shell cannot inject a script. The SDK must be injected when the page server returns the HTML |
localStorage is unavailable | Write config to fs.dataDir() |
window.confirm / alert work | The sandbox includes allow-modals. If you build your own confirm UI, do not assume a system dialog will appear |
Run it in the app
- Zip the plugin (after unzip,
manifest.jsonis at the root), or place the folder in the app's plugin directory with the folder name equal tomanifest.id - Open PixiuCut → Plugin manager → Install from zip, or click Refresh after placing the folder
- After the user confirms permissions, the home screen shows the entry declared in
slots - Press
F12in the plugin window for the page console.PixiuCutPlugin.log()is prefixed with[plugin:your-id]
After editing HTML or scripts, close the plugin window and open it again. Each open is a fresh page. UI state that was not written to fs.dataDir() is gone. Granted permissions are not asked again.
Zip size limit: 80MB.
3. Package
Layout
com.example.myplugin/
├── manifest.json
├── index.html
└── assets/
├── icon.png
└── style.css
There is no required build tool. Plain HTML, or a single entry page built with React / Vue / Svelte, is fine. Treat PixiuCutPlugin as a global and do not bundle it. Static assets use paths relative to the plugin root, for example assets/style.css.
manifest.json
{
"id": "com.example.myplugin",
"name": "My Plugin",
"version": "1.0.0",
"description": "What this plugin does",
"author": "Author",
"homepage": "https://example.com",
"main": "index.html",
"sdkVersion": "^1.0.0",
"appVersion": ">=1.1.0",
"permissions": ["draft.read", "draft.write"],
"slots": [
{ "id": "home.shortcut", "label": "My Plugin", "icon": "assets/icon.png" }
],
"window": {
"width": 800,
"height": 600,
"resizable": true,
"minWidth": 480,
"minHeight": 360
}
}
| Field | Requirement |
|---|---|
id | Required. Reverse-domain, globally unique. The install folder name matches it |
name / version / author | Required. version is semver |
description / homepage | Optional |
main | Entry HTML. Default index.html |
sdkVersion | Target SDK. Write ^1.0.0 |
appVersion | Optional. Compatible app version range |
requiredFeature | Optional. Paid feature key. Omit it for a free plugin |
permissions | See Permissions. Shown at install |
slots | Home entry. id is fixed to home.shortcut. icon is a png / svg / jpg / webp relative to the plugin root. A missing or failed icon uses the default |
window | Optional. Default 800×600, resizable. The app clamps the minimum to 480×360 |
sdkChecksum | Optional, shaped like sha256:…. Only detects an SDK copy mismatch. It is not a security boundary |
mcp | Optional. Expose tools to the on-device assistant. See below |
Declare assistant tools
Do not listen on a port, and do not implement the MCP protocol. Declare tools in the manifest. After the user opens the plugin and registration succeeds, the app forwards calls into the page.
{
"mcp": {
"tools": [
{
"name": "sort_images",
"description": "Filter images from a source directory. Paths come from the caller.",
"inputSchema": {
"type": "object",
"required": ["sourceDir", "destDir"],
"properties": {
"sourceDir": { "type": "string" },
"destDir": { "type": "string" }
}
}
}
]
}
}
mcp.toolsFile may point at a JSON array inside the plugin root (a relative path). tools and toolsFile are mutually exclusive. The file must stay inside the plugin directory.
- At most 32 tools per plugin
namematches^[a-z][a-z0-9_]{0,47}$and is unique inside the plugindescriptionis required and is truncated past 512 charactersinputSchema.typemust beobject. The schema is at most 8KB and at most 6 levels deep- A name that collides with an existing editing command is dropped
- If there are zero valid tools, or a name is illegal, the whole
mcpblock is invalid. The plugin can still be installed; it just has no assistant tools
The app encodes the public name: _ in the plugin id becomes _u, . becomes _d, then the local name is appended.
echo_ping on com.example.hello → plugin__com_dexample_dhello__echo_ping
The encoded id must be unique across installed plugins, or that plugin's mcp block is invalid. Registration is in Assistant tools.
4. Permissions
The user sees the permission list at install. High-risk items are called out separately. Declare only what the feature needs.
| Permission | Risk | Methods |
|---|---|---|
draft.read | Low | draft.list, draft.readBlob, and draft.changed |
draft.write | Medium | draft.create, draft.writeBlob, draft.remove, draft.rename, draft.stageMedia |
fs.read | High | fs.readFile, fs.readFileRange, fs.readDir, fs.stat, fs.exists |
fs.write | High | fs.writeFile, fs.mkdir, fs.remove, fs.copyFile, fs.rename |
net.fetch | High | net.fetch, plus page fetch / WebSocket to the internet |
net.local | High | On top of net.fetch, also allows loopback addresses |
media.probe | Low | media.probe, media.extractAudio, media.getEncodeCaps |
media.render | High | media.startSplitJob, media.cancelSplitJob, and media.splitJobProgress |
asr.transcribe | Low | asr.getStatus, asr.transcribe, asr.cancel, and asr.progress |
ui.notify | Low | ui.notify |
ui.pickFiles | Low | ui.pickFiles |
ui.pickDirectory | Low | ui.pickDirectory |
ui.shell | Low | ui.showItemInFolder |
export.render | High | export.enqueue, export.cancel, export.getStatus, and export events |
process.spawn | High | process.start, process.run, process.wait, process.kill, process.list, and process events |
These are open to an enabled plugin without a permission:
plugin.ready,plugin.resize,plugin.close,plugin.log,plugin.packageDir,plugin.setBusyui.getAppInfolicense.isActive,license.trialDaysLeftevents.subscribe,events.unsubscribe(the event itself still needs the permission above)fs.dataDir, and read/write of that directorymcp.register(the tool still only has the permissions the plugin was granted)
fs.write, fs.read, net.fetch, net.local, export.render, process.spawn, and media.render are explained separately in the install confirm.
cloud.* returns UNKNOWN_METHOD.5. SDK API
Global object: window.PixiuCutPlugin. Async methods return a Promise. Failure throws PluginError (err.name === 'PluginError', err.code is the error code).
Unless named below, a single call times out after 60 seconds with TIMEOUT. media.startSplitJob, media.cancelSplitJob, asr.transcribe, and asr.cancel are not under that timeout.
Drafts
Needs draft.read or draft.write.
const drafts = await PixiuCutPlugin.draft.list()
// ProjectSummary:
// { id, name, dir, created, modified, durationUs?, thumbnail, type }
// created / modified are Unix seconds; durationUs is the timeline length in microseconds
const result = await PixiuCutPlugin.draft.readBlob(draftId)
// { blob: string, version: string } | null
// version is an etag. writeBlob uses it to detect conflicts
const summary = await PixiuCutPlugin.draft.create(name, blob?)
// no blob: empty draft (default 9:16)
// with blob: use it as a template. The app mints a new id,
// rewrites name / created / modified, and clears thumbnail
const res = await PixiuCutPlugin.draft.writeBlob(id, blob, version)
// { ok: boolean, conflict?: boolean, version?: string }
// conflict === true: the draft changed after the read. readBlob again
await PixiuCutPlugin.draft.remove(id) // soft delete, goes to the trash
await PixiuCutPlugin.draft.rename(id, newName)
// Copy a source file into that draft's media directory and return the absolute path.
// Then write the path into clip.file_path
const { path } = await PixiuCutPlugin.draft.stageMedia(draftId, srcPath)
draft.createignoresproject_info.idin the template. Keeping the source draft id mixes up the index. Usesummary.idfromcreate- While the editor has a draft open,
writeBlob/remove/rename/stageMediaon it returnDRAFT_BUSY - Deleting a draft also removes its media directory.
stageMediais desktop-only
Files
Needs fs.read or fs.write, except for the plugin's own data directory.
const text = await PixiuCutPlugin.fs.readFile(path) // utf8 by default
const text = await PixiuCutPlugin.fs.readFile(path, 'utf8')
const buf = await PixiuCutPlugin.fs.readFile(path, 'binary') // ArrayBuffer
const b64 = await PixiuCutPlugin.fs.readFile(path, 'base64') // small files only
const chunk = await PixiuCutPlugin.fs.readFileRange(path, offset, length)
await PixiuCutPlugin.fs.writeFile(path, data, encoding?)
const entries = await PixiuCutPlugin.fs.readDir(path)
// { name, isDir, size, mtimeMs }[]
await PixiuCutPlugin.fs.mkdir(path, recursive?)
await PixiuCutPlugin.fs.remove(path)
const info = await PixiuCutPlugin.fs.stat(path) // { size, mtimeMs, isDir } | null
const exists = await PixiuCutPlugin.fs.exists(path)
await PixiuCutPlugin.fs.copyFile(src, dest)
await PixiuCutPlugin.fs.copyFile(src, dest, { overwrite: true })
await PixiuCutPlugin.fs.rename(src, dest)
await PixiuCutPlugin.fs.rename(src, dest, { overwrite: true })
const dir = await PixiuCutPlugin.fs.dataDir()
- App-private data (draft store, config, login) is invisible to
fs.*. Access returnsPATH_DENIED - Exception: the directory from
fs.dataDir()is readable and writable withoutfs.read/fs.write. Plugin updates keep this directory by default - Writes into the app install directory are denied by default
readFile/writeFileare about 32MB per call.readFileRangeis about 8MB. Over that isPAYLOAD_TOO_LARGEcopyFile/renameare not under that size cap.overwritedefaults tofalse; an existing target isCONFLICT. Both move files, not directory trees- Large video uses
'binary'orreadFileRange.'base64'inflates the payload and goes through text serialization - Paths are absolute
Network
Needs net.fetch. Loopback also needs net.local.
| Path | Method | Use |
|---|---|---|
| Proxy | PixiuCutPlugin.net.fetch(url, opts) | Ordinary requests, and targets that are hostile to CORS. The app applies a shared timeout and size cap |
| Direct | Page fetch() / WebSocket | Streaming responses (for example token-by-token model output). The plugin owns timeout, retry, and tokens |
const res = await PixiuCutPlugin.net.fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' }),
responseType: 'text', // 'text' (default) | 'binary'
timeoutMs: 30000,
})
// { status, headers, body: string | ArrayBuffer }
The proxy defaults to a 30 second timeout and an 8MB body, and it rechecks redirects. Direct calls have neither limit. The app adds CORS headers for the plugin page, so most internet APIs can use fetch directly. If the target answers a preflight with 4xx, use net.fetch.
With net.local, you can reach services on 127.0.0.1, localhost, and ::1 (for example a local model). Ports the app itself is using stay denied.
Media
Probe needs media.probe. Opening a plugin from home may happen before the editing engine has started. The app waits briefly; the timeout is ENGINE_UNAVAILABLE.
const info = await PixiuCutPlugin.media.probe(filePath)
// success: { success: true, type: 'video'|'audio'|'image', fileName, filePath,
// width, height, durationUs, frameRate, codec }
// failure: { success: false, error, fileName, filePath }
Extract audio also needs media.probe. The app writes a wav to a temp path.
const result = await PixiuCutPlugin.media.extractAudio(filePath, {
format: 'wav', // wav only for now
streamIndex: 0, // audio stream index, default 0
})
// success: { success: true, outputPath, durationUs, sampleRate, channels, codec }
// failure: { success: false, error }
durationUs is microseconds. Take video and audio duration from the probe result.
Encode capabilities need media.probe:
const caps = await PixiuCutPlugin.media.getEncodeCaps()
// { splitJob, sceneSilence, gpuAvailable, recommendedH264, recommendedHevc }
Batch split needs media.render. outputDir must be absolute. Read getEncodeCaps() first: when sceneSilence === false, do not use mode: 'scene' / 'silence', and do not pass useGpu: true.
PixiuCutPlugin.on('media.splitJobProgress', (p) => {
// jobProgress, phase, fileIndex, fileCount, inputPath,
// segmentIndex, segmentCount, activeSlots, fileError, silenceNotFound
})
const result = await PixiuCutPlugin.media.startSplitJob({
files: [{ inputPath: 'D:\\a.mp4', outputDir: 'D:\\out\\a' }],
mode: 'time', // 'time' | 'count' | 'trim' | 'scene' | 'silence'
modeOptions: {
minDurationUs: 5_000_000,
maxDurationUs: 5_000_000,
// count: minCount, maxCount
// trim: startTrimUs, endTrimUs
// scene: contentThreshold (0.10–0.50, sent as-is), mergeMinDurationUs, dropShorterThanUs
// silence: noiseDb, minSilenceUs, minSegmentUs, silenceType, recombine
},
maxConcurrency: 2,
// useGpu: false,
// mutate: { enlarge, speed, mirrorAlternate, trimEndsUs, removeAudio, extractAudio }
})
// { ok, cancelled, files: [{ inputPath, ok, error, segmentCount, outputDir, usedGpu, … }], error }
await PixiuCutPlugin.media.cancelSplitJob()
- Cut points, enlarge, and speed changes happen in the app. Do not loop one call per segment
contentThresholdoutside 0.10–0.50 is rejected. The app does not clamp it- With
mode: 'silence'and no silence ranges, the whole file is copied as one segment. That file may still beok: truewithsilenceNotFound: true
On-device speech recognition is below.
UI
await PixiuCutPlugin.ui.notify('Done')
await PixiuCutPlugin.ui.notify('Something failed', 'error') // 'info' | 'success' | 'warning' | 'error'
const files = await PixiuCutPlugin.ui.pickFiles({
title: 'Choose videos',
filters: [{ name: 'Video', extensions: ['mp4', 'mov', 'mkv'] }],
multiple: true,
})
// string[]. Cancel is []
const dir = await PixiuCutPlugin.ui.pickDirectory({
title: 'Choose an output directory',
defaultPath: lastDir || undefined,
})
// string | null
const ok = await PixiuCutPlugin.ui.showItemInFolder(dirOrFilePath)
// boolean. Does not require fs.read
const info = await PixiuCutPlugin.ui.getAppInfo()
// { version: string, platform: 'electron' | 'browser' }
const active = await PixiuCutPlugin.license.isActive()
const days = await PixiuCutPlugin.license.trialDaysLeft()
Pick a directory with ui.pickDirectory and files with ui.pickFiles. ui.getAppInfo and license.* need no permission.
Delegated export
Needs export.render. This sends a draft that is already on disk into the same queue as the home task center, using the app's encoder. draft.create first, then enqueue.
const { queueId } = await PixiuCutPlugin.export.enqueue(
[
{ draftId: 'proj_…', outputDir: 'D:/exports', fileName: 'Final title' },
{ draftId: 'proj_…', outputDir: 'D:/exports' },
],
{
config: {
format: 'mp4', // mp4 | mov
vcodec: 'libx264', // libx264 | libx265 | av1
resolution: 1080, // 480 | 720 | 1080; omit to use the draft canvas
frameRate: 30, // 24 | 25 | 30 | 60; omit to use the draft canvas
bitrateMode: 'preset', // preset | cbr | crf
quality: 1, // 0 | 1 | 2, preset only
videoBitrate: 8_000_000,
crf: 23,
audioBitrate: 192000,
preset: 'medium', // veryfast | medium | slow
},
// Omit to follow the home "delete draft after export" setting.
// Pass it to override.
// deleteAfterExport: false,
},
)
PixiuCutPlugin.on('export.progress', ({ queueId, draftId, percent }) => {})
PixiuCutPlugin.on('export.jobCompleted', ({ queueId, draftId, outputFile }) => {})
PixiuCutPlugin.on('export.jobFailed', ({ queueId, draftId, message }) => {})
PixiuCutPlugin.on('export.queueIdle', ({ queueId }) => {})
await PixiuCutPlugin.export.cancel('current') // the job that is exporting
await PixiuCutPlugin.export.cancel('remaining') // queued jobs that have not started
await PixiuCutPlugin.export.cancel(queueId) // this batch only
const snap = await PixiuCutPlugin.export.getStatus(queueId)
// { queueId, running, jobs: [{ draftId, status, progress, outputFile, error }] }
- Every job in one batch must share the same
outputDir, and it must be a directory fileNameis optional and is the output name without an extension. Omit it to use the draft name. A name collision is avoided automatically- Keys present in
configoverride defaults. Format, bitrate, and codec that are absent follow the last export preference. Resolution and frame rate that are absent follow the draft canvas - If the queue is idle or paused, enqueue starts export. If export is already running, the jobs are appended
- A
draftIdalready queued or exporting is skipped. The rest of the batch still enqueues - A failure is skipped and the next job continues
- A successful cancel does not always emit a terminal event for that job. The batch is finished at
export.queueIdle - Enqueue while the editor is open returns
EXPORT_BUSY - An unknown draft id returns
NOT_FOUND - Without
export.render, the UI only promises to create drafts
External processes
Needs process.spawn. The plugin page has no Node, and it cannot assemble a shell command. file must be an existing absolute path. args must be an array of strings.
const r = await PixiuCutPlugin.process.run('C:/tools/ffmpeg.exe', ['-version'])
// { handle, pid, code, signal, stdout, stderr, killed, truncatedStdout, truncatedStderr }
const { handle, pid } = await PixiuCutPlugin.process.start(
'C:/tools/ffmpeg.exe',
['-i', src, '-y', out],
{ cwd: 'D:/work', env: { MY_FLAG: '1' } },
)
PixiuCutPlugin.on('process.stdout', ({ handle, text, truncated }) => {})
PixiuCutPlugin.on('process.stderr', ({ handle, text, truncated }) => {})
PixiuCutPlugin.on('process.exited', ({ handle, pid, code, signal, killed, truncatedStdout, truncatedStderr }) => {})
const done = await PixiuCutPlugin.process.wait(handle)
await PixiuCutPlugin.process.kill(handle)
const live = await PixiuCutPlugin.process.list()
// [{ handle, pid, file }]
| Option | Meaning |
|---|---|
cwd | Absolute path. Defaults to this plugin's fs.dataDir() |
env | Extra environment variables. Variables tied to the app runtime (including the ELECTRON_ prefix) are removed |
- You cannot start PixiuCut itself or its bundled engine. An executable inside the plugin package is allowed. Join it with
packageDir() - At most 1024 arguments. Each argument is about 32K characters
- stdout and stderr are pushed as UTF-8. Each stream is truncated after 256KB (
truncated: true). Have the child write large binary to a file, then read it withfs.* - Closing the plugin window, uninstalling the plugin, or quitting the app ends the process tree that plugin started. A long-running local service should be started by the user, then reached with
net.local run/waitare under the 60 second SDK timeout. For a long job, usestartand finish onprocess.exited- For about 60 seconds after exit,
wait(handle)can still read that exit. After that it isNOT_FOUND
Events
const unsubscribe = PixiuCutPlugin.on('draft.changed', (payload) => {
console.log(payload)
})
unsubscribe()
| Event | payload | Permission |
|---|---|---|
draft.changed | { id, op }, op is create / write / remove / rename | draft.read |
export.progress | { queueId, draftId, percent } | export.render |
export.jobCompleted | { queueId, draftId, outputFile } | export.render |
export.jobFailed | { queueId, draftId, message } | export.render |
export.queueIdle | { queueId } | export.render |
media.splitJobProgress | See Media | media.render |
asr.progress | { phase, percent }, phase is extract or transcribe | asr.transcribe |
process.stdout | { handle, text, truncated } | process.spawn |
process.stderr | { handle, text, truncated } | process.spawn |
process.exited | { handle, pid, code, signal, killed, truncatedStdout, truncatedStderr } | process.spawn |
plugin.willUnload | {} | none |
plugin.willUnload does not guarantee that an async save will finish. Write important data to fs.dataDir() after each step. Subscribing to an unlisted event name returns UNKNOWN_METHOD.
Lifecycle
const root = await PixiuCutPlugin.packageDir()
// Bundled program: process.start(root + '/vendor/tool.exe', args)
// Ship binaries inside the package. Keep preferences and cache in fs.dataDir()
await PixiuCutPlugin.setBusy(true)
// While true, closing the window asks for confirmation, then ends processes this plugin started
await PixiuCutPlugin.setBusy(false)
await PixiuCutPlugin.ready()
await PixiuCutPlugin.resize(900, 700)
await PixiuCutPlugin.close()
PixiuCutPlugin.log('info', 'button clicked', { detail: '...' })
// level: 'log' | 'info' | 'warn' | 'error'
Call ready() after init. If you declared mcp.tools, finish assistant registration after ready() and before starting a long-running child process.
Register assistant tools
await PixiuCutPlugin.ready()
PixiuCutPlugin.mcp.onCall('sort_images', async (args) => {
// args already include paths. Do not call ui.pickDirectory / ui.pickFiles here
return { ok: true, copied: 12 }
})
await PixiuCutPlugin.mcp.register([{ name: 'sort_images' }])
- The name set passed to
registermust match the valid tools in the manifest exactly. One missing or extra name fails the whole call. The result is{ ok: true, registered: string[] } - Assistant calls reach
onCallonly after the window is open and registration succeeded. The handler argument is the caller's object. The return value must be JSON-serializable. A thrown error that hascodeis passed back as-is - For a long job, return
{ ok: true, jobId }quickly and expose progress through another tool of this plugin. The app treats a single call as timed out after about 60 seconds - The assistant does not request extra plugin permissions. It can only trigger capabilities the user already granted at install
Manifest declaration is in Declare assistant tools.
Errors
try {
await PixiuCutPlugin.draft.writeBlob(id, blob, version)
} catch (err) {
if (err.name !== 'PluginError') throw err
switch (err.code) {
case 'PERMISSION_DENIED':
case 'DRAFT_BUSY':
case 'EXPORT_BUSY':
case 'CONFLICT':
case 'VERSION_REQUIRED':
case 'INVALID_DRAFT':
case 'NOT_FOUND':
case 'RATE_LIMITED':
case 'PATH_DENIED':
case 'PAYLOAD_TOO_LARGE':
case 'ENGINE_UNAVAILABLE':
case 'HOST_DENIED':
case 'TIMEOUT':
case 'INTERNAL':
break
}
}
| Code | Meaning |
|---|---|
PERMISSION_DENIED | Not declared, or the user did not grant it |
UNKNOWN_METHOD | The method or event name does not exist |
NOT_FOUND | Draft, file, or process handle does not exist |
DRAFT_BUSY | That draft is open in the editor |
EXPORT_BUSY | The editor is open, so export cannot be queued |
CONFLICT | etag conflict, or the copyFile / rename target already exists |
VERSION_REQUIRED | writeBlob was called without version |
INVALID_DRAFT | JSON cannot be parsed, format_version is not "1.2", or a clip lacks name / a finite start_time |
PAYLOAD_TOO_LARGE | Over the per-call size limit, including a draft blob |
HOST_DENIED | This environment rejects the call (for example stageMedia or asr.* off the desktop) |
PATH_DENIED | The path is not accessible, or the call tried to start the app or its engine |
RATE_LIMITED | Calls are too frequent |
ENGINE_UNAVAILABLE | The editing engine is not ready |
TIMEOUT | Over the default 60 second SDK timeout |
INTERNAL | Internal app error. message has the detail |
On-device speech recognition
Needs asr.transcribe. Pass the absolute path of the user's own video or audio to the app's on-device speech model and get subtitle text back. The app extracts audio, prepares audio for recognition, recognizes it, and turns it into cue entries. The plugin writes the returned srt to a file, or places cues into a new draft as described in Subtitles. The app does not write the user's directories, does not create a draft, and does not import a timeline. Build TXT yourself from cues.
This path is the on-device model only. The return value has no model directory, recognizer binary, activation state, engine preference from Settings, or API key. A key saved in Settings never appears in the plugin page.
media.extractAudio for a wav, then net.fetch to upload it. Do not ask the user for the key saved in Settings, and do not describe that path as the app's built-in speech recognition.Windows only for now. If the model is not downloaded, or this system has no on-device recognizer, localReady is false and transcribe returns no_model. Show the returned message and point the user at Settings to download the on-device speech model. Do not rewrite that as "please activate the paid edition" or "please enter an API key". The plugin cannot start the download for the user.
const status = await PixiuCutPlugin.asr.getStatus()
// { localReady: boolean }
PixiuCutPlugin.on('asr.progress', (p) => {
// { phase: 'extract' | 'transcribe', percent: 0..100 }
})
const result = await PixiuCutPlugin.asr.transcribe(filePath, {
streamIndex: 0, // optional, default 0
})
// success: { ok: true, srt, cues: [{ startUs, endUs, text }], durationUs }
// no recognizable speech: ok is still true, cues is [], srt is ''
// failure: { ok: false, code, message }
await PixiuCutPlugin.asr.cancel()
// { ok: true }. Cancels only the run this plugin started. Also succeeds when nothing is running
filePathmust be absolute. Files chosen withui.pickFiles, and files in this plugin'sfs.dataDir(), are allowed. An empty path, a relative path, app-private data (draft store, config, model directory), and the app install directory arePATH_DENIED. A missing file isNOT_FOUND.fs.readis not required- Pass the original video or audio. Do not extract a wav yourself and then send that to recognition
- Only one recognition runs globally at a time. If this plugin, the editor, or another caller is already recognizing, the next call returns
busy. The app does not queue. For a batch, wait for the previous call to finish - Progress: extract stays under 20%. Transcribe is 20–99. Only success reaches 100. Cancel stops further progress. Closing the plugin window cancels this run and deletes the temp audio
- Failure is in the return value, not a thrown
PluginError. Showmessageas-is
code | When |
|---|---|
no_model | On-device model or recognizer is unavailable |
busy | A recognition is already running |
no_audio | The file has no audio track, or extraction produced an empty file |
decode | Recognition failed |
cancelled | The user cancelled, or the app stopped the run because the plugin window closed |
unsupported_audio | Audio for recognition could not be prepared |
If the editing engine is down during extraction, the call throws ENGINE_UNAVAILABLE, the same as media.extractAudio. That is not no_model. Calling this group off the desktop returns HOST_DENIED.
When writing a draft, cues.startUs / endUs are already microseconds and map to a subtitle item's start_time / end_time. Do not process.start the app's bundled recognizer.
6. Draft format
A draft blob is a JSON string. format_version is fixed at "1.2". Time fields are microseconds (µs). project_info.created / modified / last_opened are Unix time in seconds.
Minimal document
{
"format_version": "1.2",
"project_info": {
"id": "proj_20240101_120000_abc12345",
"name": "My video",
"description": "",
"created": 1700000000,
"modified": 1700000000,
"last_opened": 1700000000,
"author": "",
"thumbnail": "",
"duration": 0,
"status": "editing"
},
"project_settings": {
"video": { "width": 1080, "height": 1920, "frame_rate": 30 },
"audio": { "sample_rate": 48000, "channels": 2 }
},
"tracks": [
{
"id": "track_1",
"type": "video",
"name": "Main video",
"main_track": 1,
"clips": [],
"locked": false,
"muted": false,
"visible": true
}
]
}
With no blob, draft.create writes an empty draft of the same shape.
Tracks and clips
track.type is a string: video / audio / subtitle / effect.
clip.type is an integer. Do not mix the two:
| Value | Meaning |
|---|---|
| 1 | Video |
| 2 | Audio |
| 3 | Image |
| 4 | Text |
| 5 | Solid |
| 6 | Subtitle |
Before save, the host checks that every clip has a non-empty name and a finite start_time. When the project opens, a media clip also needs:
| Field | Required | Meaning |
|---|---|---|
name | Yes | Display name. Do not write clip_name |
type | Yes | Integer from the table above |
file_path | When replacing media | Absolute file path. Do not write a directory |
start_time | Yes | Start on the timeline (µs) |
in_point | Yes | Source in-point (µs) |
out_point | Yes | Source out-point (µs), greater than in_point |
media_duration | Yes | Source duration (µs), from media.probe |
duration | Yes | Length occupied on the timeline (µs) |
speed_factor | No | Speed. Default 1. Suggested range 0.25–4 |
duration = (out_point - in_point) / speed_factor
- Set
main_trackto1on a video track to mark the main track. One draft can start from a single main video track media_filesmay be an empty array. When the user later opens the project, the app syncs from clip paths. Preferdraft.stageMediafor files placed into a draft, so deleting the draft also removes the media- The app owns thumbnails.
draft.createclearsproject_info.thumbnail. Do not write that field yourself
Subtitles
A subtitle track holds one container clip (type: 6, subtitle_container: true). Each line is an entry in subtitle_track.items, not a separate clip on the track. An item without id is ignored and does not appear on screen.
The container's start_time is 0. duration, out_point, and media_duration are the maximum end_time among the items.
Track-level style lives in subtitle_track.style. For a one-off style, set has_custom_style: true on that item and put the style object in custom_style. Items without it use the track style.
{
"id": "track_sub",
"type": "subtitle",
"name": "Subtitles",
"clips": [
{
"type": 6,
"name": "Subtitles",
"file_path": "",
"start_time": 0,
"in_point": 0,
"out_point": 4000000,
"media_duration": 4000000,
"duration": 4000000,
"subtitle_container": true,
"subtitle_track": {
"style_edit_scope": "track",
"style": {
"font_family": "Microsoft YaHei UI",
"font_size": 100,
"color": "#FFFFFFFF",
"bold": false,
"italic": false,
"underline": false,
"strikethrough": false,
"opacity": 1,
"background_color": "#00000000",
"background_padding": 20,
"outline_color": "#FF000000",
"outline_width": 0,
"letter_spacing": 0,
"rotation": 0,
"shadow_offset": 0,
"shadow_color": "#80000000",
"position_x": 7,
"position_y": 70,
"position_width": 85,
"position_height": 8,
"alignment": "center",
"valign": "middle"
},
"items": [
{
"id": "cue_1",
"text": "Hello",
"start_time": 0,
"end_time": 2000000,
"duration": 2000000
},
{
"id": "cue_2",
"text": "Second line",
"start_time": 2000000,
"end_time": 4000000,
"duration": 2000000,
"has_custom_style": true,
"custom_style": { "font_size": 80, "color": "#FFFFFF00" }
}
]
}
}
]
}
- Style fields use the snake_case names above. Colors are
#AARRGGBB(alpha included) font_familyis a single family name, not a comma-separated CSS font list.Microsoft YaHei UIis a safe default.font_sizeis about 8–400.opacityis 0–1position_*are percentages of the frame.alignmentisleft/center/right.valignistop/middle/bottomoutline_widthis the outward stroke width. Abackground_colorwith zero alpha draws no box- Do not overlap two subtitle items. Item
idstays unique inside the container. A stable string is enough - Separate
type: 6clips per line, or a flattext/font_familyon the clip, do not show when the project opens
Rewriting
- Use
draft.createfor a new result. It does not need an etag, and it does not fail because the editor holds the source draft writeBlobmust pass theversionfromreadBlob- How durations line up, and how files are named, is up to the plugin. The app has no "match media by duration" method
- An older
format_versioncannot be opened. Do not write a migration
7. Debugging
- Install or refresh the plugin (see Run it in the app)
- Open it from the home entry
- Press
F12for the page console,PixiuCutPlugin.log(), and network requests - When checking
media.*andexport.*, let the app finish starting the engine first. Export progress also appears in the home task center - While the plugin window is open, the editor cannot be entered. While the editor is open, enqueue returns
EXPORT_BUSY, and writing the draft being edited returnsDRAFT_BUSY
Do not keep UI state only in memory. Closing and reopening reloads the page.
8. Publishing
Package
One zip. After unzip, manifest.json and the entry HTML are at the root. Do not wrap them in an extra folder. Size at most 80MB.
How users install
| Way | Meaning |
|---|---|
| zip | Plugin manager → Installed → Install from zip |
| Local folder | Place a folder named manifest.id in the app plugin directory, then Refresh |
| Discover | The user installs from Plugins → Discover. Entries come from the official catalog |
After install the plugin is enabled, the home shortcut appears, and the app does not need a restart. The user sees the permissions from the manifest. An update replaces the install directory. The fs.dataDir() data directory is kept by default.
Getting into Discover
Send one record to the catalog maintainer. The catalog file itself is:
{
"schemaVersion": 1,
"plugins": []
}
| Field | Required | Meaning |
|---|---|---|
id | Yes | Same as manifest.id. Letters, digits, and . _ -, length 2–128. No .. and no path separators |
name | Yes | |
latestVersion | Yes | semver |
downloadUrl | Yes | https URL of the zip |
description / author | No | Shown as an empty string when omitted |
sha256 | No | 64 hex digits of the zip. A missing or mismatched value is logged and does not block install |
sizeBytes | No | Zip size in bytes. A mismatch is logged only |
homepage / icon / screenshots / category | No | icon is an https URL or a data URI |
minSdkVersion / minAppVersion | No | When not met, Discover shows that the current app does not support it |
Listing is done by the catalog maintainer. This app has no self-serve listing console.
9. Practices
Write config to the data directory
const dir = await PixiuCutPlugin.fs.dataDir()
const configPath = dir + '/config.json'
let config = {}
if (await PixiuCutPlugin.fs.exists(configPath)) {
config = JSON.parse(await PixiuCutPlugin.fs.readFile(configPath))
}
config.lastDir = dirPicked
await PixiuCutPlugin.fs.writeFile(configPath, JSON.stringify(config))
Save first, then notify
async function doWork() {
const result = await processData()
await saveToDataDir(result)
await PixiuCutPlugin.ui.notify('Done')
}
Do not put the only save inside plugin.willUnload.
Drafts
- New results use
draft.create - Use
summary.idfromcreate. Do not keep the id from the template - Before editing clips, have
name,start_time,in_point,out_point,media_duration, andduration - Speed changes only
speed_factor, thendurationis recomputed from the formula
Paths and large files
- Directories use
ui.pickDirectory. Files useui.pickFiles - If you accept a dropped folder: a sandboxed
Filemay not havepath. TryFile.path, thentext/uri-list, thentext/plain, thenfs.statto confirm it is a directory. Do not read a dropped video into a Blob and write the whole file back - Scan media with
fs.readDirplus an extension filter, thenmedia.probefor duration - Large files use
'binary'or ranges. Mind the 32MB / 8MB caps - Call
setBusy(true)when a long task starts andsetBusy(false)when it ends
Put a warning next to a dangerous control
An option that moves, overwrites, or deletes the user's files, or overwrites an existing draft, gets one short sentence on the same row as the control. Confirm again with your own overlay before running it.
The first open can show one dismissible note about the main path, and store "read" in fs.dataDir(). Do not pop it on every launch, and do not make it a wizard that must be clicked through.
An empty list should state three things: what to pick, what will appear in the list after the pick, and whether the main button changes disk or only creates drafts. When a scan finds nothing, switch the copy to "nothing found…".
Call rate
Do not fire a heavy call once per file inside a tight loop. draft.create and fs.writeFile are rate limited; pause between items in a batch. Very large JSON can be parsed in a Worker before it is submitted.
10. FAQ
Can I use React / Vue / Svelte?
Yes. The build output is one entry HTML. Do not bundle the SDK, and do not write a script tag for it. Mark PixiuCutPlugin as an external global.
Can I call Node.js?
No. System capabilities go through the SDK methods on this page. For ffmpeg or your own program, start an absolute path with process.spawn, or ship the program inside the plugin package.
Why can't the window shell inject a script into the page?
The sandbox has no allow-same-origin, so the shell cannot read the page document. The SDK is injected only when the page server returns the HTML.
Can plugins call each other?
No. The handoff is: plugin A creates a draft, then the user opens plugin B to read it.
Can I connect to the editing engine directly?
No. Probe, audio extract, split, export, and on-device speech recognition all go through the SDK, and the app performs them.
How long is the timeout?
60 seconds by default, code TIMEOUT. media.startSplitJob, media.cancelSplitJob, asr.transcribe, and asr.cancel are outside that limit. net.fetch defaults to 30 seconds and can be changed with timeoutMs.
Can a plugin use the app's speech recognition?
Yes, the on-device speech model only. Pass the absolute path of a video or audio file to asr.transcribe, then write the subtitle text as SRT or TXT yourself. See On-device speech recognition. The model directory, recognizer, and API key are not given to the plugin.
If the on-device model is not downloaded, the result is no_model. Show the returned message and point the user at Settings to download it. Do not rewrite that as "please activate the paid edition" or "please enter an API key".
Cloud recognition does not go through plugins. The quota included with the paid edition, and the API key saved in Settings, are used only by the editor's Smart subtitle recognition. For a cloud effect, bring your own service: media.extractAudio for a wav, then net.fetch to upload it.
How do I send a video's audio to my own recognition service?
media.extractAudio returns a wav outputPath. Read it with fs.readFile(path, 'binary') and upload it with net.fetch or a direct fetch. If the engine is not started, you get ENGINE_UNAVAILABLE. If extraction fails, use ui.pickFiles so the user can pick an audio file instead. That is your own recognition service, not the app's built-in speech recognition. The app's built-in path is only the on-device model.
Can a plugin live inside the editor?
No. A plugin is its own window, and it is mutually exclusive with the editor.
How does export relate to the task center?
They are the same queue. After export.enqueue, the user sees progress in the home task center. Closing the plugin window does not cancel jobs that are already queued.
Can I start ffmpeg?
Yes. Declare process.spawn and pass an absolute path. You cannot start PixiuCut itself or its engine. A program inside the plugin package is allowed. Processes started by the plugin are ended when the plugin window closes.
How does the on-device assistant call a plugin?
Declare tools in manifest.mcp.tools, then register with mcp.onCall and mcp.register after open. Put paths in the arguments. Do not open a file dialog inside the tool handler. See the manifest and registration.
Appendix: message format without the official SDK
Use the injected PixiuCutPlugin by default. It provides timeouts, PluginError.code, and event subscription. If you must send messages yourself, the format is below.
Request:
window.parent.postMessage(
{ type: 'request', id: 1, method: 'draft.list', params: [] },
'*',
)
id must be a number. params is an array in the same order as the method arguments. The target origin is '*' because the sandboxed page origin is null.
Response: { type: 'response', id, result } or { type: 'response', id, error: { code, message } }.
Event: { type: 'event', event, payload }. Request events.subscribe before events arrive.
Assistant calls (sent by the app only when the official SDK's mcp.onCall is in use): the page receives { type: 'mcp.invoke', id, name, args }, then replies { type: 'mcp.result', id, result } or { type: 'mcp.result', id, error: { code, message } }.