插件开发指南
面向为 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 如何进入页面
打开插件时,应用的本地页面服务会:
- 提供
/sdk.js - 在返回插件 HTML 时插入该脚本
- 去掉页面里手写的
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。自己做确认框时,不要假设系统对话框一定出现 |
在应用里运行
- 将插件打成 zip(解压后根目录就是
manifest.json),或把目录放到应用的插件目录下,文件夹名等于manifest.id - 打开 PixiuCut → 插件管理 →「从 zip 安装」,或放好目录后点「刷新」
- 确认权限后,首页会出现
slots里声明的入口 - 在插件窗口按
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.setBusyui.getAppInfolicense.isActive、license.trialDaysLeftevents.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_LARGEcopyFile/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 / 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 | 见素材 | media.render |
asr.progress | { phase, percent },phase 为 extract 或 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 | {} | 无 |
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 | 剪辑器已打开,不能入队导出 |
CONFLICT | etag 冲突,或 copyFile / rename 的目标已存在 |
VERSION_REQUIRED | writeBlob 未传 version |
INVALID_DRAFT | JSON 无法解析、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 不会出现在插件页面。
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~1position_*是相对画面的百分比。alignment为left/center/right,valign为top/middle/bottomoutline_width是向外的描边宽度。background_color的透明度为 0 时不画底框- 同一时间不要让两条字幕重叠。条目
id在该容器内保持唯一,使用稳定字符串即可 - 把每句字幕做成独立的
type: 6片段、或只在片段上写扁平的text/font_family,打开后不会显示
改写建议
- 产出新结果时用
draft.create。它不需要 etag,也不会因为剪辑器占用原稿而失败 writeBlob必须带上readBlob返回的version- 时长怎么对齐、文件怎么命名,由插件自己决定。应用不提供「按时长匹配素材」这类方法
- 旧版
format_version无法打开,也不要写迁移
七、调试
- 安装或刷新插件(见在应用里运行)
- 从首页入口打开
F12查看页面控制台、PixiuCutPlugin.log()和网络请求- 验收
media.*与export.*时,先让应用完成引擎启动。导出进度同时出现在首页任务中心 - 插件窗口打开时,不能再进剪辑器;剪辑器打开时,不能入队导出(
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 } }。