SDK 1.0.0

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
The plugin page runs in its own window. It does not enter the editor timeline, and it does not add custom filters, transitions, or encoders. Export uses the app's existing export path. Media probe, audio extraction, batch splitting, and on-device speech recognition are also done by the app. Do not connect to the editing engine yourself.

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:

  1. Serves /sdk.js
  2. Inserts that script when it returns the plugin HTML
  3. Strips a handwritten sdk.js reference 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 seeWhat it means
Opaque origin (Origin: null)The page is not same-origin with the shell
The shell cannot read the iframe documentThe shell cannot inject a script. The SDK must be injected when the page server returns the HTML
localStorage is unavailableWrite config to fs.dataDir()
window.confirm / alert workThe sandbox includes allow-modals. If you build your own confirm UI, do not assume a system dialog will appear

Run it in the app

  1. Zip the plugin (after unzip, manifest.json is at the root), or place the folder in the app's plugin directory with the folder name equal to manifest.id
  2. Open PixiuCut → Plugin manager → Install from zip, or click Refresh after placing the folder
  3. After the user confirms permissions, the home screen shows the entry declared in slots
  4. Press F12 in 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
  }
}
FieldRequirement
idRequired. Reverse-domain, globally unique. The install folder name matches it
name / version / authorRequired. version is semver
description / homepageOptional
mainEntry HTML. Default index.html
sdkVersionTarget SDK. Write ^1.0.0
appVersionOptional. Compatible app version range
requiredFeatureOptional. Paid feature key. Omit it for a free plugin
permissionsSee Permissions. Shown at install
slotsHome 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
windowOptional. Default 800×600, resizable. The app clamps the minimum to 480×360
sdkChecksumOptional, shaped like sha256:…. Only detects an SDK copy mismatch. It is not a security boundary
mcpOptional. 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
  • name matches ^[a-z][a-z0-9_]{0,47}$ and is unique inside the plugin
  • description is required and is truncated past 512 characters
  • inputSchema.type must be object. 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 mcp block 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.

PermissionRiskMethods
draft.readLowdraft.list, draft.readBlob, and draft.changed
draft.writeMediumdraft.create, draft.writeBlob, draft.remove, draft.rename, draft.stageMedia
fs.readHighfs.readFile, fs.readFileRange, fs.readDir, fs.stat, fs.exists
fs.writeHighfs.writeFile, fs.mkdir, fs.remove, fs.copyFile, fs.rename
net.fetchHighnet.fetch, plus page fetch / WebSocket to the internet
net.localHighOn top of net.fetch, also allows loopback addresses
media.probeLowmedia.probe, media.extractAudio, media.getEncodeCaps
media.renderHighmedia.startSplitJob, media.cancelSplitJob, and media.splitJobProgress
asr.transcribeLowasr.getStatus, asr.transcribe, asr.cancel, and asr.progress
ui.notifyLowui.notify
ui.pickFilesLowui.pickFiles
ui.pickDirectoryLowui.pickDirectory
ui.shellLowui.showItemInFolder
export.renderHighexport.enqueue, export.cancel, export.getStatus, and export events
process.spawnHighprocess.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.setBusy
  • ui.getAppInfo
  • license.isActive, license.trialDaysLeft
  • events.subscribe, events.unsubscribe (the event itself still needs the permission above)
  • fs.dataDir, and read/write of that directory
  • mcp.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.

This version has no cloud-account API. Calling 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.create ignores project_info.id in the template. Keeping the source draft id mixes up the index. Use summary.id from create
  • While the editor has a draft open, writeBlob / remove / rename / stageMedia on it return DRAFT_BUSY
  • Deleting a draft also removes its media directory. stageMedia is 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 returns PATH_DENIED
  • Exception: the directory from fs.dataDir() is readable and writable without fs.read / fs.write. Plugin updates keep this directory by default
  • Writes into the app install directory are denied by default
  • readFile / writeFile are about 32MB per call. readFileRange is about 8MB. Over that is PAYLOAD_TOO_LARGE
  • copyFile / rename are not under that size cap. overwrite defaults to false; an existing target is CONFLICT. Both move files, not directory trees
  • Large video uses 'binary' or readFileRange. 'base64' inflates the payload and goes through text serialization
  • Paths are absolute

Network

Needs net.fetch. Loopback also needs net.local.

PathMethodUse
ProxyPixiuCutPlugin.net.fetch(url, opts)Ordinary requests, and targets that are hostile to CORS. The app applies a shared timeout and size cap
DirectPage fetch() / WebSocketStreaming 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
  • contentThreshold outside 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 be ok: true with silenceNotFound: 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
  • fileName is 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 config override 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 draftId already 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 }]
OptionMeaning
cwdAbsolute path. Defaults to this plugin's fs.dataDir()
envExtra 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 with fs.*
  • 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 / wait are under the 60 second SDK timeout. For a long job, use start and finish on process.exited
  • For about 60 seconds after exit, wait(handle) can still read that exit. After that it is NOT_FOUND

Events

const unsubscribe = PixiuCutPlugin.on('draft.changed', (payload) => {
  console.log(payload)
})
unsubscribe()
EventpayloadPermission
draft.changed{ id, op }, op is create / write / remove / renamedraft.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.splitJobProgressSee Mediamedia.render
asr.progress{ phase, percent }, phase is extract or transcribeasr.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 register must 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 onCall only 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 has code is 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
  }
}
CodeMeaning
PERMISSION_DENIEDNot declared, or the user did not grant it
UNKNOWN_METHODThe method or event name does not exist
NOT_FOUNDDraft, file, or process handle does not exist
DRAFT_BUSYThat draft is open in the editor
EXPORT_BUSYThe editor is open, so export cannot be queued
CONFLICTetag conflict, or the copyFile / rename target already exists
VERSION_REQUIREDwriteBlob was called without version
INVALID_DRAFTJSON cannot be parsed, format_version is not "1.2", or a clip lacks name / a finite start_time
PAYLOAD_TOO_LARGEOver the per-call size limit, including a draft blob
HOST_DENIEDThis environment rejects the call (for example stageMedia or asr.* off the desktop)
PATH_DENIEDThe path is not accessible, or the call tried to start the app or its engine
RATE_LIMITEDCalls are too frequent
ENGINE_UNAVAILABLEThe editing engine is not ready
TIMEOUTOver the default 60 second SDK timeout
INTERNALInternal 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.

Batch recognition uses this on-device path. The recognition quota included with the paid edition, and the API key the user 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. 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
  • filePath must be absolute. Files chosen with ui.pickFiles, and files in this plugin's fs.dataDir(), are allowed. An empty path, a relative path, app-private data (draft store, config, model directory), and the app install directory are PATH_DENIED. A missing file is NOT_FOUND. fs.read is 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. Show message as-is
codeWhen
no_modelOn-device model or recognizer is unavailable
busyA recognition is already running
no_audioThe file has no audio track, or extraction produced an empty file
decodeRecognition failed
cancelledThe user cancelled, or the app stopped the run because the plugin window closed
unsupported_audioAudio 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:

ValueMeaning
1Video
2Audio
3Image
4Text
5Solid
6Subtitle

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:

FieldRequiredMeaning
nameYesDisplay name. Do not write clip_name
typeYesInteger from the table above
file_pathWhen replacing mediaAbsolute file path. Do not write a directory
start_timeYesStart on the timeline (µs)
in_pointYesSource in-point (µs)
out_pointYesSource out-point (µs), greater than in_point
media_durationYesSource duration (µs), from media.probe
durationYesLength occupied on the timeline (µs)
speed_factorNoSpeed. Default 1. Suggested range 0.25–4
duration = (out_point - in_point) / speed_factor
  • Set main_track to 1 on a video track to mark the main track. One draft can start from a single main video track
  • media_files may be an empty array. When the user later opens the project, the app syncs from clip paths. Prefer draft.stageMedia for files placed into a draft, so deleting the draft also removes the media
  • The app owns thumbnails. draft.create clears project_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_family is a single family name, not a comma-separated CSS font list. Microsoft YaHei UI is a safe default. font_size is about 8–400. opacity is 0–1
  • position_* are percentages of the frame. alignment is left / center / right. valign is top / middle / bottom
  • outline_width is the outward stroke width. A background_color with zero alpha draws no box
  • Do not overlap two subtitle items. Item id stays unique inside the container. A stable string is enough
  • Separate type: 6 clips per line, or a flat text / font_family on the clip, do not show when the project opens

Rewriting

  • Use draft.create for a new result. It does not need an etag, and it does not fail because the editor holds the source draft
  • writeBlob must pass the version from readBlob
  • 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_version cannot be opened. Do not write a migration

7. Debugging

  1. Install or refresh the plugin (see Run it in the app)
  2. Open it from the home entry
  3. Press F12 for the page console, PixiuCutPlugin.log(), and network requests
  4. When checking media.* and export.*, let the app finish starting the engine first. Export progress also appears in the home task center
  5. 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 returns DRAFT_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

WayMeaning
zipPlugin manager → Installed → Install from zip
Local folderPlace a folder named manifest.id in the app plugin directory, then Refresh
DiscoverThe 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": []
}
FieldRequiredMeaning
idYesSame as manifest.id. Letters, digits, and . _ -, length 2–128. No .. and no path separators
nameYes
latestVersionYessemver
downloadUrlYeshttps URL of the zip
description / authorNoShown as an empty string when omitted
sha256No64 hex digits of the zip. A missing or mismatched value is logged and does not block install
sizeBytesNoZip size in bytes. A mismatch is logged only
homepage / icon / screenshots / categoryNoicon is an https URL or a data URI
minSdkVersion / minAppVersionNoWhen 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.id from create. Do not keep the id from the template
  • Before editing clips, have name, start_time, in_point, out_point, media_duration, and duration
  • Speed changes only speed_factor, then duration is recomputed from the formula

Paths and large files

  • Directories use ui.pickDirectory. Files use ui.pickFiles
  • If you accept a dropped folder: a sandboxed File may not have path. Try File.path, then text/uri-list, then text/plain, then fs.stat to confirm it is a directory. Do not read a dropped video into a Blob and write the whole file back
  • Scan media with fs.readDir plus an extension filter, then media.probe for duration
  • Large files use 'binary' or ranges. Mind the 32MB / 8MB caps
  • Call setBusy(true) when a long task starts and setBusy(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 } }.