SDK 1.0.0

插件开发指南

面向为 PixiuCut 开发草稿工具的第三方开发者。协议为 SDK 1.0.0(manifest.sdkVersion 写 ^1.0.0)。按本文的方法名、字段和错误码实现即可,不必阅读应用源码。

一、插件能做什么

插件是一个草稿转换器:读取用户草稿,写出一个或多个新草稿。需要成片时,可以委托应用把已保存的草稿送进任务中心导出。

输入 = 草稿(以及用户自己的素材文件)
输出 = 新草稿(一个或多个)
可选 = 委托应用把草稿导出到用户指定的目录

适合的场景:

  • 用一条模板草稿批量替换素材,生成多个变体,再排队导出
  • 读取视频信息或自备音频,调用自己的语音识别服务,也可以把素材交给应用的本机语音模型,写回带字幕的新草稿
  • 把其它软件的工程描述转成 PixiuCut 草稿
  • 把草稿同步到自己的存储
插件页面跑在独立窗口里。它不进入剪辑器时间线,也不提供自定义滤镜、转场或编码器。导出调用的是应用已有的导出流程。媒体探测、抽音轨、批量切片和本机语音识别同样由应用代为完成,插件不要自己连接剪辑引擎。

二、五分钟示例

最小插件

hello-plugin/
├── manifest.json
└── index.html

manifest.json

{
  "id": "com.example.hello",
  "name": "Hello Plugin",
  "version": "1.0.0",
  "description": "我的第一个插件",
  "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 — 页面里直接使用全局对象 PixiuCutPlugin。插件包不要自带 SDK,HTML 里也不要写 <script src="sdk.js">。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <title>Hello Plugin</title>
</head>
<body>
  <h2>Hello PixiuCut</h2>
  <p id="info">加载中…</p>
  <button id="btn">生成变体草稿</button>
  <script>
    const sdk = PixiuCutPlugin

    async function init() {
      const drafts = await sdk.draft.list()
      document.getElementById('info').textContent =
        `已连接,当前有 ${drafts.length} 个草稿`
      await sdk.ready()
    }

    document.getElementById('btn').onclick = async () => {
      const drafts = await sdk.draft.list()
      if (!drafts.length) {
        await sdk.ui.notify('请先创建一个草稿', 'warning')
        return
      }
      const { blob } = await sdk.draft.readBlob(drafts[0].id)
      const name = drafts[0].name + '-副本'
      await sdk.draft.create(name, blob)
      await sdk.ui.notify('已生成: ' + name, 'success')
    }

    init().catch((e) => {
      document.getElementById('info').textContent = '错误: ' + e.message
    })
  </script>
</body>
</html>

SDK 如何进入页面

打开插件时,应用的本地页面服务会:

  1. 提供 /sdk.js
  2. 在返回插件 HTML 时插入该脚本
  3. 去掉页面里手写的 sdk.js 引用,避免加载两次

插件窗口里的页面运行在沙箱 iframe 中,属性为 sandbox="allow-scripts allow-forms allow-modals",不带 allow-same-origin。因此:

现象含义
页面来源是不透明源(Origin: null)与外壳页面不同源
外壳读不到 iframe 里的文档不能靠外壳往页面里插入脚本;SDK 必须由页面服务在返回 HTML 时注入
不能用 localStorage配置请写到 fs.dataDir()
window.confirm / alert 可用沙箱带了 allow-modals。自己做确认框时,不要假设系统对话框一定出现

在应用里运行

  1. 将插件打成 zip(解压后根目录就是 manifest.json),或把目录放到应用的插件目录下,文件夹名等于 manifest.id
  2. 打开 PixiuCut → 插件管理 →「从 zip 安装」,或放好目录后点「刷新」
  3. 确认权限后,首页会出现 slots 里声明的入口
  4. 在插件窗口按 F12 查看页面控制台。PixiuCutPlugin.log() 的输出带有 [plugin:你的id] 前缀

改了 HTML 或脚本后,关掉插件窗口再打开。窗口每次打开都是新页面,未写入 fs.dataDir() 的界面状态不会保留。已授予的权限不会因此重问。

zip 上限 80MB。

三、插件包

目录

com.example.myplugin/
├── manifest.json
├── index.html
└── assets/
    ├── icon.png
    └── style.css

没有规定的构建工具。原生 HTML,或 React / Vue / Svelte 构建出的单个入口页都可以。构建时把 PixiuCutPlugin 当作全局变量,不要把它打进包里。静态资源使用相对插件根的路径,例如 assets/style.css。

manifest.json

{
  "id": "com.example.myplugin",
  "name": "我的插件",
  "version": "1.0.0",
  "description": "插件功能简述",
  "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": "我的插件", "icon": "assets/icon.png" }
  ],
  "window": {
    "width": 800,
    "height": 600,
    "resizable": true,
    "minWidth": 480,
    "minHeight": 360
  }
}
字段要求
id必填。反向域名,全局唯一,安装目录名与之相同
name / version / author必填。version 为 semver
description / homepage可选
main入口 HTML,默认 index.html
sdkVersion目标 SDK,当前写 ^1.0.0
appVersion可选。兼容的应用版本范围
requiredFeature可选。付费功能键。免费插件省略
permissions见第四节。安装时展示给用户
slots首页入口。id 固定为 home.shortcut。icon 为相对插件根的 png / svg / jpg / webp;省略或加载失败时显示默认图标
window可选。默认 800×600、可调整。宽高下限由应用钳在 480×360
sdkChecksum可选,形如 sha256:…。只用于发现 SDK 副本不一致,不构成安全边界
mcp可选。向本机助手暴露工具,见下方

向本机助手声明工具

插件不要自己监听端口,也不要实现 MCP 协议。在清单里声明工具;用户打开插件并完成登记后,由应用把调用转进页面。

{
  "mcp": {
    "tools": [
      {
        "name": "sort_images",
        "description": "从源目录筛选图片。路径由调用方给出。",
        "inputSchema": {
          "type": "object",
          "required": ["sourceDir", "destDir"],
          "properties": {
            "sourceDir": { "type": "string" },
            "destDir": { "type": "string" }
          }
        }
      }
    ]
  }
}

也可以用 mcp.toolsFile 指向插件根内的 JSON 数组(相对路径)。tools 与 toolsFile 只能出现一个。文件不得越出插件目录。

  • 每个插件最多 32 个工具
  • name 匹配 ^[a-z][a-z0-9_]{0,47}$,插件内不可重复
  • description 必填,超过 512 字会被截断
  • inputSchema.type 必须是 object;模式体积不超过 8KB,嵌套不超过 6 层
  • 与应用已有剪辑命令同名的条目会被丢弃
  • 有效工具为零,或名字非法,则整份 mcp 无效(插件本身仍可安装,只是没有助手工具)

对外名称由应用编码:插件 id 里的 _ 换成 _u、. 换成 _d,再拼上本地名。

com.example.hello 的 echo_ping → plugin__com_dexample_dhello__echo_ping

编码后的 id 在已安装插件之间必须唯一,否则该插件的 mcp 无效。登记方式见助手工具。

四、权限

安装时用户会看到权限说明。标为高危的项会单独提示。只声明完成功能所需要的权限。

权限风险可调用的方法
draft.read低draft.list、draft.readBlob,以及 draft.changed
draft.write中draft.create、draft.writeBlob、draft.remove、draft.rename、draft.stageMedia
fs.read高fs.readFile、fs.readFileRange、fs.readDir、fs.stat、fs.exists
fs.write高fs.writeFile、fs.mkdir、fs.remove、fs.copyFile、fs.rename
net.fetch高net.fetch,以及页面里直接 fetch / WebSocket 访问外网
net.local高在 net.fetch 之上,额外允许访问本机回环地址
media.probe低media.probe、media.extractAudio、media.getEncodeCaps
media.render高media.startSplitJob、media.cancelSplitJob,以及 media.splitJobProgress
asr.transcribe低asr.getStatus、asr.transcribe、asr.cancel,以及 asr.progress
ui.notify低ui.notify
ui.pickFiles低ui.pickFiles
ui.pickDirectory低ui.pickDirectory
ui.shell低ui.showItemInFolder
export.render高export.enqueue、export.cancel、export.getStatus,以及导出事件
process.spawn高process.start、process.run、process.wait、process.kill、process.list,以及进程事件

以下方法对已启用的插件默认开放,不必声明权限:

  • plugin.ready、plugin.resize、plugin.close、plugin.log、plugin.packageDir、plugin.setBusy
  • ui.getAppInfo
  • license.isActive、license.trialDaysLeft
  • events.subscribe、events.unsubscribe(具体事件仍受上表权限约束)
  • fs.dataDir,以及对该目录的读写
  • mcp.register(工具本身仍受插件已获权限约束)

fs.write、fs.read、net.fetch、net.local、export.render、process.spawn、media.render 会在安装确认里单独说明。

当前版本没有云账号接口。调用 cloud.* 会得到 UNKNOWN_METHOD。

五、SDK API

全局对象:window.PixiuCutPlugin。异步方法返回 Promise。失败抛出 PluginError(err.name === 'PluginError',err.code 为错误码)。

除下面点名的方法外,单次调用默认 60 秒超时,超时码为 TIMEOUT。media.startSplitJob、media.cancelSplitJob、asr.transcribe 与 asr.cancel 不设这道超时。

草稿

需要 draft.read 或 draft.write。

const drafts = await PixiuCutPlugin.draft.list()
// ProjectSummary:
// { id, name, dir, created, modified, durationUs?, thumbnail, type }
// created / modified 为秒级 Unix 时间;durationUs 为时间线时长(微秒)

const result = await PixiuCutPlugin.draft.readBlob(draftId)
// { blob: string, version: string } | null
// version 是 etag,writeBlob 用来检测冲突

const summary = await PixiuCutPlugin.draft.create(name, blob?)
// 不传 blob:空草稿(默认 9:16)
// 传入 blob:以它为模板。应用会铸新 id,并改写 name / created / modified,清空 thumbnail

const res = await PixiuCutPlugin.draft.writeBlob(id, blob, version)
// { ok: boolean, conflict?: boolean, version?: string }
// conflict === true:读之后又被改过,应重新 readBlob

await PixiuCutPlugin.draft.remove(id)   // 软删除,进入回收站
await PixiuCutPlugin.draft.rename(id, newName)

// 把源文件复制进该草稿的媒体目录,返回绝对路径。再把路径写入 clip.file_path
const { path } = await PixiuCutPlugin.draft.stageMedia(draftId, srcPath)
  • draft.create 会忽略模板里的 project_info.id。保留源草稿 id 会造成索引串味。使用 create 返回的 summary.id
  • 编辑器正打开某个草稿时,对它 writeBlob / remove / rename / stageMedia 会得到 DRAFT_BUSY
  • 删除草稿时,其媒体目录一并清除。stageMedia 只在桌面端可用

文件

需要 fs.read 或 fs.write。插件自己的数据目录除外。

const text = await PixiuCutPlugin.fs.readFile(path)           // 默认 utf8
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') // 仅适合小文件

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()
  • 应用私有数据(草稿仓、配置、登录信息)对 fs.* 不可见,访问得到 PATH_DENIED
  • 例外:fs.dataDir() 返回的目录可自由读写,不需要 fs.read / fs.write。插件更新时这个目录默认保留
  • 应用安装目录默认拒绝写入
  • readFile / writeFile 单次约 32MB,readFileRange 单次约 8MB,超出为 PAYLOAD_TOO_LARGE
  • copyFile / rename 不受上述体积上限。overwrite 默认 false,目标已存在时为 CONFLICT。二者只处理文件,不搬目录树
  • 大视频用 'binary' 或 readFileRange。'base64' 会膨胀体积并经过文本序列化
  • 路径使用绝对路径

网络

需要 net.fetch。访问本机回环地址还要 net.local。

路径方法适用
代理PixiuCutPlugin.net.fetch(url, opts)普通请求。目标对跨域不友好时用这条。应用统一超时和体积
直连页面里的 fetch() / WebSocket流式响应(例如模型逐字输出)。超时、重试、令牌由插件自己处理
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'(默认)| 'binary'
  timeoutMs: 30000,
})
// { status, headers, body: string | ArrayBuffer }

代理默认超时 30 秒、响应体上限 8MB,并会复查重定向。直连没有这些限制。应用会为插件页面补上跨域响应头,多数外网接口可以直接 fetch。若目标对预检请求返回 4xx,改走 net.fetch。

声明 net.local 后,可以访问 127.0.0.1、localhost、::1 上的本机服务(例如本地模型)。应用自己占用的端口仍然拒绝访问。

素材

探测需要 media.probe。首页打开插件时剪辑引擎可能尚未启动,应用会短时间等待;超时为 ENGINE_UNAVAILABLE。

const info = await PixiuCutPlugin.media.probe(filePath)
// 成功: { success: true, type: 'video'|'audio'|'image', fileName, filePath,
//         width, height, durationUs, frameRate, codec }
// 失败: { success: false, error, fileName, filePath }

抽音轨同样需要 media.probe。应用把 wav 写到临时路径。

const result = await PixiuCutPlugin.media.extractAudio(filePath, {
  format: 'wav',      // 当前仅 wav
  streamIndex: 0,     // 音轨序号,默认 0
})
// 成功: { success: true, outputPath, durationUs, sampleRate, channels, codec }
// 失败: { success: false, error }

durationUs 单位是微秒。视频、音频的时长以探测结果为准。

编码能力需要 media.probe:

const caps = await PixiuCutPlugin.media.getEncodeCaps()
// { splitJob, sceneSilence, gpuAvailable, recommendedH264, recommendedHevc }

批量切片需要 media.render。outputDir 必须是绝对路径。先看 getEncodeCaps():sceneSilence === false 时不要使用 mode: 'scene' / 'silence',也不要传 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,原样发送), 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()
  • 切点、放大和变速在应用侧完成。不要按片段循环调用
  • contentThreshold 超出 0.10~0.50 会被拒绝,应用不会替你钳制
  • mode: 'silence' 且没有静音区间时,整片会复制为一段,该文件结果仍可能 ok: true,并带 silenceNotFound: true

本机语音识别见下文。

界面

await PixiuCutPlugin.ui.notify('操作完成')
await PixiuCutPlugin.ui.notify('出错了', 'error') // 'info' | 'success' | 'warning' | 'error'

const files = await PixiuCutPlugin.ui.pickFiles({
  title: '选择视频',
  filters: [{ name: '视频', extensions: ['mp4', 'mov', 'mkv'] }],
  multiple: true,
})
// string[],取消为 []

const dir = await PixiuCutPlugin.ui.pickDirectory({
  title: '选择输出目录',
  defaultPath: lastDir || undefined,
})
// string | null

const ok = await PixiuCutPlugin.ui.showItemInFolder(dirOrFilePath)
// boolean。不附带 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()

选目录用 ui.pickDirectory,选文件用 ui.pickFiles。ui.getAppInfo 与 license.* 不必声明权限。

委托导出

需要 export.render。这是把已经落盘的草稿送进首页任务中心的同一条队列,使用应用现有的编码器。先 draft.create,再入队。

const { queueId } = await PixiuCutPlugin.export.enqueue(
  [
    { draftId: 'proj_…', outputDir: 'D:/exports', fileName: '成片标题' },
    { draftId: 'proj_…', outputDir: 'D:/exports' },
  ],
  {
    config: {
      format: 'mp4',          // mp4 | mov
      vcodec: 'libx264',      // libx264 | libx265 | av1
      resolution: 1080,       // 480 | 720 | 1080;不传则用草稿画布
      frameRate: 30,          // 24 | 25 | 30 | 60;不传则用草稿画布
      bitrateMode: 'preset',  // preset | cbr | crf
      quality: 1,             // 0 | 1 | 2,仅 preset
      videoBitrate: 8_000_000,
      crf: 23,
      audioBitrate: 192000,
      preset: 'medium',       // veryfast | medium | slow,编码速度
    },
    // 省略则跟随首页「导出成功后删除草稿」;显式传入则覆盖
    // 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')     // 正在导出的一条
await PixiuCutPlugin.export.cancel('remaining')   // 队列中尚未开始的
await PixiuCutPlugin.export.cancel(queueId)       // 只取消本批

const snap = await PixiuCutPlugin.export.getStatus(queueId)
// { queueId, running, jobs: [{ draftId, status, progress, outputFile, error }] }
  • 同一批 jobs 的 outputDir 必须相同,且为目录
  • fileName 可选,作为输出文件名(不含扩展名)。省略则用草稿名。重名时应用会自动避开已有文件
  • config 里出现的键覆盖缺省。未出现的格式、码率、编码器沿用上次导出偏好;未出现的分辨率、帧率沿用草稿画布
  • 队列空闲或暂停时,入队会开始导出;已在导出则追加排队
  • 已经在排队或导出中的 draftId 会被跳过,同批其余条目照常入队
  • 失败默认跳过并继续下一条
  • 取消成功时,单条任务不一定有终态事件。批次收口看 export.queueIdle
  • 剪辑器正打开时入队得到 EXPORT_BUSY
  • 草稿 id 不存在得到 NOT_FOUND
  • 未声明 export.render 时,界面只承诺生成草稿

外部进程

需要 process.spawn。插件页面没有 Node,也不能拼一条 shell 命令。file 必须是已存在的绝对路径,args 必须是字符串数组。

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 }]
选项说明
cwd绝对路径。省略时用本插件的 fs.dataDir()
env追加的环境变量。与应用运行环境相关的变量(含 ELECTRON_ 前缀)会被去掉
  • 不能启动 PixiuCut 自身及其自带引擎。插件包内的可执行文件可以,用 packageDir() 拼绝对路径
  • 参数最多 1024 个,单个参数最长约 32K 字符
  • 标准输出和标准错误按 UTF-8 推送,每路累计超过 256KB 后截断(truncated: true)。大块二进制让子进程写文件,再用 fs.* 读取
  • 关闭插件窗口、卸载插件或退出应用时,应用会结束该插件拉起的进程树。需要长期运行的本机服务请让用户自己启动,再用 net.local 连接
  • run / wait 受 60 秒 SDK 超时限制。长任务用 start,再用 process.exited 收尾
  • 进程退出后约 60 秒内,wait(handle) 仍能取到那次的退出信息,之后为 NOT_FOUND

事件

const unsubscribe = PixiuCutPlugin.on('draft.changed', (payload) => {
  console.log(payload)
})
unsubscribe()
事件payload权限
draft.changed{ id, op },op 为 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.splitJobProgress见素材media.render
asr.progress{ phase, percent },phase 为 extract 或 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{}无

plugin.willUnload 不保证异步保存能跑完。关键数据在每一步之后写入 fs.dataDir()。订阅未列出的事件名会得到 UNKNOWN_METHOD。

生命周期

const root = await PixiuCutPlugin.packageDir()
// 包内程序:process.start(root + '/vendor/tool.exe', args)
// 发行文件放插件包内。偏好和缓存放 fs.dataDir()

await PixiuCutPlugin.setBusy(true)
// 为 true 时,用户关窗会先看到确认,确认后结束本插件拉起的进程
await PixiuCutPlugin.setBusy(false)

await PixiuCutPlugin.ready()
await PixiuCutPlugin.resize(900, 700)
await PixiuCutPlugin.close()

PixiuCutPlugin.log('info', '用户点击了按钮', { detail: '...' })
// level: 'log' | 'info' | 'warn' | 'error'

建议初始化完成后再 ready()。若声明了 mcp.tools,在 ready() 之后、拉起耗时子进程之前完成助手工具登记。

登记助手工具

await PixiuCutPlugin.ready()

PixiuCutPlugin.mcp.onCall('sort_images', async (args) => {
  // args 自带路径。这里不要再调用 ui.pickDirectory / ui.pickFiles
  return { ok: true, copied: 12 }
})

await PixiuCutPlugin.mcp.register([{ name: 'sort_images' }])
  • register 的名字集合必须与清单里的有效工具完全一致,少一条或多一条都会整次失败。返回 { ok: true, registered: string[] }
  • 助手调用在插件窗口已打开且登记成功之后才会到达 onCall。处理函数的参数是调用方传入的对象,返回值须可 JSON 序列化。抛出的错误若带 code,会原样传回
  • 耗时任务尽快返回 { ok: true, jobId },进度用本插件自己的另一条工具查询。单次调用在应用侧约 60 秒后会被判定超时
  • 助手不另申请插件权限。它只能触发用户安装时已经授权的那些能力

清单声明见向本机助手声明工具。

错误处理

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
  }
}
错误码含义
PERMISSION_DENIED未声明或用户未授予该权限
UNKNOWN_METHOD方法或事件名不存在
NOT_FOUND草稿、文件或进程句柄不存在
DRAFT_BUSY该草稿正被剪辑器打开
EXPORT_BUSY剪辑器已打开,不能入队导出
CONFLICTetag 冲突,或 copyFile / rename 的目标已存在
VERSION_REQUIREDwriteBlob 未传 version
INVALID_DRAFTJSON 无法解析、format_version 不是 "1.2",或片段缺少 name / 有限数字 start_time
PAYLOAD_TOO_LARGE超过单次体积上限(含草稿 blob)
HOST_DENIED当前环境拒绝该调用(例如非桌面端的 stageMedia,或非桌面环境下的 asr.*)
PATH_DENIED路径不可访问,或试图启动应用自身 / 其引擎
RATE_LIMITED调用过于频繁
ENGINE_UNAVAILABLE剪辑引擎未就绪
TIMEOUT超过 SDK 默认 60 秒
INTERNAL应用内部错误,message 里有说明

本机语音识别

需要 asr.transcribe。把用户自己的视频或音频的绝对路径交给应用的本机语音模型,拿回字幕文本。抽音、转成识别用的音频、识别并整理成字幕条目,都由应用完成。插件把返回的 srt 写成文件,或按字幕一节把 cues 放进新草稿。应用不写用户目录,不建草稿,也不导入时间线。TXT 由插件自己用 cues 拼。

只走本机模型。返回值里没有模型目录、识别程序、是否激活、设置里的引擎偏好,也没有 API Key。设置里保存的 Key 不会出现在插件页面。

批量识别走这条本机路径。正式版附带的识别额度,以及用户在设置里填写的 API Key,只在剪辑器的「智能识别字幕」里使用。插件要做云端效果时,自备识别服务:media.extractAudio 得到 wav,再用 net.fetch 上传。不要向用户索取他在设置里保存的 Key,也不要把这条自备路径说成应用自带的语音识别。

目前仅 Windows。模型还没下载,或当前系统没有本机识别程序时,localReady 为 false,transcribe 返回 no_model。把返回的 message 展示给用户,提示到设置里下载本机语音模型。不要改写成「请激活正式版」或「请填写 API Key」。插件不能替用户开始下载。

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, // 可选,默认 0
})
// 成功: { ok: true, srt, cues: [{ startUs, endUs, text }], durationUs }
// 没有可识别的人声:ok 仍为 true,cues 为 [],srt 为 ''
// 失败: { ok: false, code, message }

await PixiuCutPlugin.asr.cancel()
// { ok: true }。只取消本插件发起的那一次;没有进行中的识别时也返回成功
  • filePath 必须是绝对路径。用户用 ui.pickFiles 选中的素材,以及本插件 fs.dataDir() 里的文件可以。空路径、相对路径、应用私有数据(草稿仓、配置、模型目录)和应用安装目录为 PATH_DENIED。文件不存在为 NOT_FOUND。不需要另声明 fs.read
  • 把原始视频或音频交进来,不要先自己抽成 wav 再送识别
  • 同一时间全局只有一条识别。本插件、剪辑器或其他调用方正在识别时,再调用得到 busy。应用不排队。批量时等上一条结束再调下一条
  • 进度:抽音阶段停在 20% 以下;转写阶段为 20~99;只有成功才到 100。取消后不再推进度。关掉插件窗口时,应用会取消这次识别并删除临时音频
  • 失败在返回值里,不抛 PluginError。message 直接展示
code何时
no_model本机模型或识别程序不可用
busy已有识别在跑
no_audio文件没有音轨,或抽音得到空文件
decode识别失败
cancelled用户取消,或插件窗口关掉时应用中止
unsupported_audio无法准备识别用的音频

抽音时剪辑引擎没起来,抛 ENGINE_UNAVAILABLE,与 media.extractAudio 相同,不要当成 no_model。非桌面环境调用这组方法得到 HOST_DENIED。

写进草稿时,cues 的 startUs / endUs 已经是微秒,对应字幕条目的 start_time / end_time。不要用 process.start 去启动应用自带的识别程序。

六、草稿格式

草稿 blob 是 JSON 字符串,format_version 固定为 "1.2"。时间字段单位一律是微秒(µs)。project_info.created / modified / last_opened 是秒级 Unix 时间。

最小文档

{
  "format_version": "1.2",
  "project_info": {
    "id": "proj_20240101_120000_abc12345",
    "name": "我的视频",
    "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_track": 1,
      "clips": [],
      "locked": false,
      "muted": false,
      "visible": true
    }
  ]
}

不传 blob 时,draft.create 会生成一份同等结构的空草稿。

轨道与片段

track.type 是字符串:video / audio / subtitle / effect。

clip.type 是整数,二者不要混用:

值含义
1视频
2音频
3图片
4文本
5纯色
6字幕

宿主在落盘前检查:每个片段都有非空 name,且 start_time 为有限数字。打开工程时,媒体片段还需要:

字段必填说明
name是显示名。不要写 clip_name
type是上表整数
file_path替换素材时文件的绝对路径。不要写目录
start_time是时间线上的起点(µs)
in_point是素材入点(µs)
out_point是素材出点(µs),必须大于 in_point
media_duration是源文件总时长(µs),来自 media.probe
duration是时间线上占用的时长(µs)
speed_factor否倍速,缺省为 1。建议范围 0.25~4
duration = (out_point - in_point) / speed_factor
  • 视频轨上把 main_track 设为 1 表示主轨。一条草稿以一条主视频轨为起点即可
  • media_files 可以留空数组。用户以后在剪辑器里打开时,应用会按片段路径同步。替换进草稿的文件优先用 draft.stageMedia,这样删除草稿时素材会一起清掉
  • 缩略图由应用管理。draft.create 会清空 project_info.thumbnail。不要自己写这个字段

字幕

字幕轨上只放一个容器片段(type: 6,subtitle_container: true)。每一句台词是 subtitle_track.items 里的一条,而不是轨道上的多个片段。没有 id 的条目会被忽略,画面上也不会出现。

容器的 start_time 为 0。duration、out_point、media_duration 取所有条目 end_time 的最大值。

轨级样式放在 subtitle_track.style。某一句要单独样式时,该条设置 has_custom_style: true,并把样式对象放进 custom_style。未设置的句子使用轨级样式。

{
  "id": "track_sub",
  "type": "subtitle",
  "name": "字幕",
  "clips": [
    {
      "type": 6,
      "name": "字幕",
      "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": "大家好",
            "start_time": 0,
            "end_time": 2000000,
            "duration": 2000000
          },
          {
            "id": "cue_2",
            "text": "第二句",
            "start_time": 2000000,
            "end_time": 4000000,
            "duration": 2000000,
            "has_custom_style": true,
            "custom_style": { "font_size": 80, "color": "#FFFFFF00" }
          }
        ]
      }
    }
  ]
}
  • 样式字段使用上表中的蛇形命名。颜色为 #AARRGGBB(含透明度)
  • font_family 只写一个字体族名称,不要写带逗号的 CSS 字体列表;缺省可用 Microsoft YaHei UI。font_size 约为 8~400。opacity 为 0~1
  • position_* 是相对画面的百分比。alignment 为 left / center / right,valign 为 top / middle / bottom
  • outline_width 是向外的描边宽度。background_color 的透明度为 0 时不画底框
  • 同一时间不要让两条字幕重叠。条目 id 在该容器内保持唯一,使用稳定字符串即可
  • 把每句字幕做成独立的 type: 6 片段、或只在片段上写扁平的 text / font_family,打开后不会显示

改写建议

  • 产出新结果时用 draft.create。它不需要 etag,也不会因为剪辑器占用原稿而失败
  • writeBlob 必须带上 readBlob 返回的 version
  • 时长怎么对齐、文件怎么命名,由插件自己决定。应用不提供「按时长匹配素材」这类方法
  • 旧版 format_version 无法打开,也不要写迁移

七、调试

  1. 安装或刷新插件(见在应用里运行)
  2. 从首页入口打开
  3. F12 查看页面控制台、PixiuCutPlugin.log() 和网络请求
  4. 验收 media.* 与 export.* 时,先让应用完成引擎启动。导出进度同时出现在首页任务中心
  5. 插件窗口打开时,不能再进剪辑器;剪辑器打开时,不能入队导出(EXPORT_BUSY),对正在编辑的草稿写入会得到 DRAFT_BUSY

界面状态不要只放在内存里。关窗再开会重新加载页面。

八、发布

安装包

一个 zip。解压后的根上就是 manifest.json 和入口 HTML,不要多包一层多余目录。体积不超过 80MB。

用户如何安装

方式说明
zip插件管理 → 已安装 →「从 zip 安装」
本地目录以 manifest.id 为文件夹名放到应用插件目录,再点「刷新」
发现用户在「插件 → 发现」中安装。条目来自官方目录

安装后默认启用,首页出现快捷入口,不必重启应用。用户会看到清单里的权限。更新时安装目录会被替换,fs.dataDir() 对应的数据目录默认保留。

收录进发现目录

向目录维护者提供一条记录。目录文件本身为:

{
  "schemaVersion": 1,
  "plugins": []
}
字段必填说明
id是与 manifest.id 相同。字母、数字及 . _ -,长度 2~128,不能含 .. 或路径分隔符
name是
latestVersion是semver
downloadUrl是zip 的 https 地址
description / author否省略时按空字符串展示
sha256否zip 的 64 位十六进制摘要。缺失或与文件不符时只记录日志,不阻止安装
sizeBytes否zip 字节数。不符时只记录日志
homepage / icon / screenshots / category否icon 为 https 或 data URI
minSdkVersion / minAppVersion否不满足时,发现页显示当前应用不支持

收录由目录维护者操作。本应用不提供自助上架后台。

九、实践建议

把配置写到数据目录

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))

先落盘,再通知

async function doWork() {
  const result = await processData()
  await saveToDataDir(result)
  await PixiuCutPlugin.ui.notify('处理完成')
}

不要把唯一一次保存放在 plugin.willUnload 里。

草稿

  • 新结果用 draft.create
  • 使用 create 返回的 summary.id,不要继续用模板里的 id
  • 改片段前备齐 name、start_time、in_point、out_point、media_duration、duration
  • 变速只改 speed_factor,并按公式重算 duration

选路径与大文件

  • 目录用 ui.pickDirectory,文件用 ui.pickFiles
  • 需要拖入文件夹时:沙箱里的 File 不一定带有 path。依次尝试 File.path、text/uri-list、text/plain,再用 fs.stat 确认是目录。拖入的视频不要读成 Blob 再整文件写回
  • 扫描素材用 fs.readDir 加扩展名过滤,再用 media.probe 取时长
  • 大文件用 'binary' 或分片。注意 32MB / 8MB 上限
  • 长任务开始时 setBusy(true),结束时 setBusy(false)

危险操作要写在控件旁边

会移动、覆盖或删除用户文件,或覆盖已有草稿的选项,在控件同一行写一句短说明。执行前再用插件自己的遮罩确认一次。

第一次打开可以用一句可关闭的提示说明主路径,并把「已读」记在 fs.dataDir()。不要每次启动都弹出,也不要做成必须点完的向导。

空列表写清三步:选什么、选完列表里会出现什么、主按钮会改磁盘还是只生成草稿。扫描结果为空时,再换成「没有找到…」。

调用节奏

避免在循环里对每个文件各打一次很重的调用。draft.create 与 fs.writeFile 有频率限制,批量时稍作间隔。超大 JSON 可以在 Worker 里解析后再提交。

十、常见问题

可以用 React / Vue / Svelte 吗?

可以。构建产物是一个入口 HTML。不要把 SDK 打进包,也不要手写 script 引用。将 PixiuCutPlugin 标成外部全局变量。

可以调用 Node.js 吗?

不可以。系统能力都走本文的 SDK 方法。需要 ffmpeg 或自带程序时,用 process.spawn 启动绝对路径,或把程序放在插件包内。

为什么不能从窗口外壳往页面里插脚本?

沙箱没有 allow-same-origin,外壳读不到页面文档。SDK 只在页面服务返回 HTML 时注入。

插件之间能互相调用吗?

不能。交接方式是插件 A 创建草稿,用户再打开插件 B 读取它。

能直接连剪辑引擎吗?

不能。探测、抽音轨、切片、导出和本机语音识别都通过 SDK,由应用代为调用。

超时是多久?

默认 60 秒,错误码 TIMEOUT。media.startSplitJob、media.cancelSplitJob、asr.transcribe 与 asr.cancel 不受这道限制。net.fetch 默认 30 秒,可用 timeoutMs 修改。

插件可以调用应用里的语音识别吗?

可以,只调用本机语音模型。把视频或音频的绝对路径交给 asr.transcribe,拿回字幕文本后自己写成 SRT 或 TXT。见本机语音识别。模型目录、识别程序、API Key 都不交给插件。

本机模型还没下载时,结果是 no_model。把返回的 message 展示给用户,提示到设置里下载。不要写成「请激活正式版」或「请填写 API Key」。

云端识别不经过插件。正式版附带的识别额度,以及用户在设置里填写的 API Key,都只在剪辑器的「智能识别字幕」里使用。插件要做云端效果时,自备识别服务:media.extractAudio 得到 wav,再用 net.fetch 上传。

怎样把视频里的声音送给自己的识别服务?

media.extractAudio 得到 wav 的 outputPath,再用 fs.readFile(path, 'binary') 读取,经 net.fetch 或直连 fetch 上传。引擎未启动时得到 ENGINE_UNAVAILABLE。抽轨失败时,用 ui.pickFiles 让用户改选音频文件。这是插件自己的识别服务,不是应用自带的语音识别。应用自带的只有本机模型。

插件能停在剪辑器里面吗?

不能。插件是独立窗口,与剪辑器互斥。

导出和任务中心是什么关系?

同一条队列。export.enqueue 之后,用户在首页任务中心能看到进度。插件窗口关掉后,已经入队的任务不会被取消。

能启动 ffmpeg 吗?

可以。声明 process.spawn,传入绝对路径。不能启动 PixiuCut 自身及其引擎。插件包内的程序可以。关闭插件窗口后,由该插件启动的进程会被结束。

怎样让本机助手调用插件?

在 manifest.mcp.tools 里声明,打开后用 mcp.onCall 与 mcp.register 登记。参数里带齐路径,不要在工具处理函数里弹文件框。见清单声明与登记。

附录:不用官方 SDK 时的消息格式

默认请使用注入的 PixiuCutPlugin。它提供超时、PluginError.code 和事件订阅。若必须自己收发消息,格式如下。

请求:

window.parent.postMessage(
  { type: 'request', id: 1, method: 'draft.list', params: [] },
  '*',
)

id 必须是数字。params 是与方法参数顺序一致的数组。目标源使用 '*',因为沙箱页面的来源是 null。

响应:{ type: 'response', id, result } 或 { type: 'response', id, error: { code, message } }。

事件:{ type: 'event', event, payload }。收到事件前需先请求 events.subscribe。

助手调用(仅在使用官方 SDK 的 mcp.onCall 时由应用发送):页面收到 { type: 'mcp.invoke', id, name, args },再回复 { type: 'mcp.result', id, result } 或 { type: 'mcp.result', id, error: { code, message } }。