diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index cdc9737..fded67a 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -27,10 +27,16 @@ jobs:
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.14
+ - name: Block source-only Subtitle Studio releases
+ if: startsWith(github.ref_name, 'plugin-subtitle-studio-v')
+ run: |
+ echo "Subtitle Studio's reviewed native runtime bundle and packaged smoke gate are not complete." >&2
+ exit 1
- name: Install companion development dependencies
run: |
bun --cwd=tools/xiaoyunque-mcp install --frozen-lockfile
bun --cwd=tools/ffmpeg-mcp install --frozen-lockfile
+ bun --cwd=tools/subtitle-studio-mcp install --frozen-lockfile
- name: Verify official FFmpeg source
if: startsWith(github.ref_name, 'plugin-ffmpeg-tools-v')
run: |
@@ -67,6 +73,8 @@ jobs:
bun --cwd=tools/xiaoyunque-mcp test
bun --cwd=tools/ffmpeg-mcp run typecheck
bun --cwd=tools/ffmpeg-mcp test
+ bun --cwd=tools/subtitle-studio-mcp run typecheck
+ bun --cwd=tools/subtitle-studio-mcp test
bun tooling/build-companions.mjs --tag "$GITHUB_REF_NAME"
bun test
bun tooling/pack.mjs --tag "$GITHUB_REF_NAME"
@@ -92,6 +100,7 @@ jobs:
run: |
bun --cwd=tools/xiaoyunque-mcp install --frozen-lockfile
bun --cwd=tools/ffmpeg-mcp install --frozen-lockfile
+ bun --cwd=tools/subtitle-studio-mcp install --frozen-lockfile
- name: Verify official FFmpeg source
if: startsWith(github.ref_name, 'plugin-ffmpeg-tools-v')
run: |
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 1c9e779..06fcb1c 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -25,5 +25,7 @@ jobs:
run: bun --cwd=tools/xiaoyunque-mcp install --frozen-lockfile
- name: Install FFmpeg companion development dependencies
run: bun --cwd=tools/ffmpeg-mcp install --frozen-lockfile
+ - name: Install Subtitle Studio companion development dependencies
+ run: bun --cwd=tools/subtitle-studio-mcp install --frozen-lockfile
- name: Validate, test, and reproduce packages
run: bun run check
diff --git a/docs/plugin-authoring.md b/docs/plugin-authoring.md
index 54a4366..3e1d9d8 100644
--- a/docs/plugin-authoring.md
+++ b/docs/plugin-authoring.md
@@ -132,6 +132,21 @@ omit `runtime` and `contributes.generation`. Declaring a runtime does not grant
Web surface caller authority, and granting `generation.execute` does not let the
iframe start processes or send arbitrary MCP requests.
+A v3 Web surface may choose between two generic execution modes. `generation.canvas.execute`
+commits admitted output directly to Canvas. `generation.workspace.execute` keeps admitted
+output detached so the Plugin can preview it before the user explicitly publishes or
+exports it. Detached text is returned inline; detached media is represented by a short-lived
+opaque artifact id and playback URL. It never exposes a Project or native path. The owning
+frame may publish, export, release, or cancel only its own artifacts in the current
+Project/Canvas/node scope. Closing the frame, changing scope, expiry, or publishing releases
+the temporary authorization and cleans unclaimed managed assets.
+
+Workspace calls still select only a declared generation tool, use schema-validated scalar
+`toolInput`, and derive media references from direct incoming Canvas edges. They are not an
+arbitrary MCP or filesystem bridge. `generation.workspace.publish` may attach bounded SRT or
+VTT text tracks; the host validates and stores them as portable managed VTT sidecars so the
+Canvas player and downstream Project restore keep the same soft subtitles.
+
## Plugin service contribution
A v2 or v3 executable Plugin may expose bounded account/service state through the same
@@ -215,10 +230,20 @@ arrive as `{"protocol":"convax.plugin-host/1","type":"command","command":"refres
| `host.context.get` | none | current Project, Canvas, and owning node |
| `canvas.node.get` | `canvas.node.read` | owning node only |
| `canvas.node.updateState` | `canvas.node.write` | Plugin-namespaced node state |
+| `canvas.connectedMedia.list` | `canvas.connectedMedia.read` | direct incoming managed audio/video nodes |
+| `canvas.connectedMedia.playback.open` / `.close` | `canvas.connectedMedia.read` | opaque, expiring playback lease for one listed node |
| `project.file.readText` | `project.files.read` | current Project-relative text file |
| `agent.prompt` | `agent.prompt` | current Project and owning node resource |
| `generation.tools.list` | `generation.execute` | installed generation contracts in the current scope |
| `generation.canvas.execute` | `generation.execute` | shared scoped Canvas generation operation |
+| `generation.workspace.execute` / `.cancel` | `generation.execute` | detached scoped operation using direct incoming references |
+| `generation.workspace.publish` / `.export` / `.release` | `generation.execute` | owning frame's opaque detached artifact or direct source |
+| `host.file.exportText` | `host.files.save` | Main save dialog for bounded UTF-8 text; no caller path |
+
+Each `canvas.connectedMedia.list` item includes an opaque `sourceVersion`. Persist it
+with any media-derived document, clear or explicitly migrate that document when the
+version changes, and avoid reopening a playback lease when both node id and version
+are unchanged. The token is an identity signal, not a Project path or content URL.
Request the smallest set. Arguments cannot select another Project, Canvas, or node.
Treat results as untrusted structured data, bound message sizes, handle errors, and
@@ -227,8 +252,10 @@ a failed optional view effect; do not report that as a reverted mutation.
## Forbidden behavior
-No remote scripts/assets, iframe network APIs, popups, downloads, eval-generated
+No remote scripts/assets, iframe network APIs, popups, browser-initiated downloads, eval-generated
code, native/WASM executables, packaged Node servers, filesystem paths, secrets,
telemetry, service workers, or generic method forwarding. Do not edit `.convax`
files. A v2 or v3 external runtime is a separately installed and authorized tool, never a
-Plugin ZIP asset. Use host capabilities only.
+Plugin ZIP asset. A declared `host.files.save` request may invoke only the host-owned
+bounded text save dialog; it does not grant browser download or path access. Use host
+capabilities only.
diff --git a/docs/subtitle-studio.md b/docs/subtitle-studio.md
new file mode 100644
index 0000000..eaf9197
--- /dev/null
+++ b/docs/subtitle-studio.md
@@ -0,0 +1,63 @@
+# Subtitle Studio architecture
+
+Subtitle Studio is split across the public Plugin Registry and the generic Convax
+Plugin host. No Subtitle Studio UI, subtitle domain, model installer, native
+process, or hard-coded Plugin identity belongs in the Convax application.
+
+## Ownership
+
+| Layer | Repository owner | Contents |
+| --- | --- | --- |
+| Static Canvas surface | `packages/plugins/subtitle-studio/package/` | player, operation buttons, local track state, translation orchestration |
+| Portable subtitle domain | `tools/subtitle-studio-mcp/src/domain/` | document/SRT/translation/job/erase-plan validation |
+| MCP companion | `tools/subtitle-studio-mcp/` | declared tools, staged-media execution, cancellation, runtime verification |
+| Hard-erasure engine | `tools/subtitle-studio-mcp/native/subtitle-erasure/` | bounded OCR, temporal tracking, LaMa inpainting, validated remux |
+| Generic host ports | `microvoid/convax` | direct connected media, opaque playback/artifacts, text-only Agent prompt, timed-text resources |
+
+The static iframe never receives native paths, executable paths, model paths,
+Project paths, shell access, or generic MCP access. Every media source is a direct
+incoming Canvas edge and every native operation is one manifest-declared tool.
+
+## Product flow
+
+1. `canvas.connectedMedia.list` discovers the directly connected video and
+ `canvas.connectedMedia.playback.open` creates an opaque Range-capable lease. The
+ subtitle document is bound to the listed opaque `sourceVersion`, so replacing the
+ video cannot apply the previous video's tracks to the new source.
+2. Transcription calls `subtitle.transcribe`. The companion selects the video's
+ audio stream itself; importing a separate audio file is not required.
+3. Subtitle tracks remain editable soft subtitles in the Plugin node state.
+ Translation sends only cue ids and text through `agent.prompt` with
+ `mode: "text-only"`, which disables all Agent tools.
+4. Soft erasure remuxes selected embedded text streams. Tracks created or imported
+ in Subtitle Studio are not part of that inspection and are never removed by it.
+5. Hard erasure uses a normalized text-search region, detector geometry, temporal
+ tracking, and AI inpainting. It produces a detached artifact so the player can
+ switch to the exact processed result before publication.
+6. “添加到画布” publishes the player video plus validated managed VTT sidecars.
+ “导出视频” exports the same current player video, muxing the current soft tracks
+ when required. Neither action accepts a caller-selected path.
+
+## Runtime and release gate
+
+The source package currently declares only `darwin-arm64`, matching the repository's
+real companion CI coverage. The checked-in native source and protocol tests are not
+an installable AI runtime by themselves.
+
+A publishable `0.4.0` companion must still prove all of the following:
+
+- one Registry-admitted executable no larger than 128 MiB;
+- no Homebrew, PATH, ambient Python, or machine-local dynamic-library dependency;
+- pinned FFmpeg/FFprobe/Whisper/OCR/LaMa inventory with exact sizes and SHA-256;
+- complete third-party licenses and model notices;
+- signed/notarized macOS artifact and minimum-OS verification;
+- real transcription, soft-remux, preview, hard-erasure, mux, cancellation, and
+ cleanup smoke tests on packaged bytes;
+- golden videos covering motion, scene cuts, no detection, corrupt media, audio
+ preservation, and outside-mask pixel identity.
+
+Until that gate passes, the companion entrypoint fails closed instead of falling
+back to arbitrary local programs or presenting source-only code as an installed AI
+runtime. The publish workflow explicitly rejects `plugin-subtitle-studio-v*` tags;
+remove that guard only in the reviewed change that supplies and validates the full
+runtime bundle.
diff --git a/package.json b/package.json
index f64f686..14a2f13 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,6 @@
"render:showcases": "bun tooling/render-showcases.mjs",
"build:index": "bun tooling/build-index.mjs && bun tooling/build-showcase.mjs",
"build:companions": "bun tooling/build-companions.mjs",
- "check": "bun run validate && bun --cwd=tools/xiaoyunque-mcp run typecheck && bun --cwd=tools/xiaoyunque-mcp test && bun --cwd=tools/ffmpeg-mcp run typecheck && bun --cwd=tools/ffmpeg-mcp test && bun run build:companions && bun test && bun run pack && bun run build:index && bun --cwd=tools/xiaoyunque-mcp run build && bun --cwd=tools/ffmpeg-mcp run build"
+ "check": "bun run validate && bun --cwd=tools/xiaoyunque-mcp run typecheck && bun --cwd=tools/xiaoyunque-mcp test && bun --cwd=tools/ffmpeg-mcp run typecheck && bun --cwd=tools/ffmpeg-mcp test && bun --cwd=tools/subtitle-studio-mcp run typecheck && bun --cwd=tools/subtitle-studio-mcp test && bun run build:companions && bun test && bun run pack && bun run build:index && bun --cwd=tools/xiaoyunque-mcp run build && bun --cwd=tools/ffmpeg-mcp run build && bun --cwd=tools/subtitle-studio-mcp run build"
}
}
diff --git a/packages/plugins/subtitle-studio/convax-package.json b/packages/plugins/subtitle-studio/convax-package.json
new file mode 100644
index 0000000..b2e8023
--- /dev/null
+++ b/packages/plugins/subtitle-studio/convax-package.json
@@ -0,0 +1,28 @@
+{
+ "schema": "convax.package/1",
+ "kind": "plugin",
+ "id": "subtitle-studio",
+ "name": "Subtitle Studio",
+ "description": "Local-first subtitle creation, multilingual adaptation, soft-subtitle handling, and AI hard-subtitle removal for directly connected Canvas video.",
+ "version": "0.4.0",
+ "license": "MIT",
+ "compatibility": {
+ "pluginSchema": "convax.plugin/3",
+ "pluginHost": "convax.plugin-host/3"
+ },
+ "companions": [
+ {
+ "command": "convax-subtitle-studio-mcp",
+ "version": "0.4.0",
+ "source": "tools/subtitle-studio-mcp",
+ "targets": [
+ {
+ "platform": "darwin",
+ "arch": "arm64",
+ "path": "dist/darwin-arm64/convax-subtitle-studio-mcp"
+ }
+ ]
+ }
+ ],
+ "yanked": false
+}
diff --git a/packages/plugins/subtitle-studio/package/LICENSE b/packages/plugins/subtitle-studio/package/LICENSE
new file mode 100644
index 0000000..0260f10
--- /dev/null
+++ b/packages/plugins/subtitle-studio/package/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Microvoid contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/plugins/subtitle-studio/package/assets/app.js b/packages/plugins/subtitle-studio/package/assets/app.js
new file mode 100644
index 0000000..b1c8ccf
--- /dev/null
+++ b/packages/plugins/subtitle-studio/package/assets/app.js
@@ -0,0 +1,1100 @@
+const PROTOCOL = "convax.plugin-host/3"
+const CONNECTED_MEDIA_CHANGED = "canvas.connectedMedia.changed"
+const TOOL_PREFIX = "subtitle-studio/"
+const REQUEST_TIMEOUT_MS = 30_000
+const OPERATION_TIMEOUT_MS = 6 * 60 * 60 * 1_000
+const MAX_SUBTITLE_DOCUMENT_BYTES = 236 * 1024
+
+const elements = Object.fromEntries(
+ [
+ "activeTrack", "busy", "busyText", "cancel", "captions", "cueEnd", "cueIdentity", "cueStart", "cueText", "deleteCue", "empty", "emptyTrack", "eraseHard", "erasePanel",
+ "eraseSoft", "eraseSoft", "export", "exportSrt", "hardErase", "inspect", "language", "meta", "model",
+ "importSrt", "play", "preview", "previewHard", "publish", "regionHeight", "regionWidth", "regionX", "regionY", "softErase",
+ "saveCue", "sourceTrack", "srtFile", "status", "streams", "targetLanguage", "time", "title", "toast", "trackLabel", "tracks",
+ "transcribe", "translate", "translatedLabel", "video",
+ ].map((id) => [id, document.getElementById(id)]),
+)
+
+const state = {
+ activeTrackId: "",
+ artifact: null,
+ context: null,
+ document: null,
+ documentSourceVersion: "",
+ editingCue: null,
+ media: [],
+ muxedArtifact: null,
+ playback: null,
+ previewArtifact: null,
+ source: null,
+ tools: null,
+}
+
+let hostPort = null
+let requestSequence = 0
+let activeOperationRequestId = ""
+let captionResizeObserver = null
+let saveTimer = 0
+let toastTimer = 0
+const pending = new Map()
+
+function isRecord(value) {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value)
+}
+
+function errorMessage(error) {
+ return error instanceof Error ? error.message : String(error)
+}
+
+function showToast(message) {
+ window.clearTimeout(toastTimer)
+ elements.toast.textContent = message
+ elements.toast.hidden = false
+ toastTimer = window.setTimeout(() => {
+ elements.toast.hidden = true
+ }, 4_000)
+}
+
+function setBusy(label) {
+ elements.busyText.textContent = label
+ elements.cancel.hidden = true
+ elements.busy.hidden = false
+}
+
+function clearBusy() {
+ activeOperationRequestId = ""
+ elements.cancel.hidden = true
+ elements.busy.hidden = true
+}
+
+function sendRequest(method, params, timeoutMs = REQUEST_TIMEOUT_MS) {
+ if (!hostPort) return { id: "", promise: Promise.reject(new Error("插件尚未连接 Convax")) }
+ const id = `subtitle_${Date.now()}_${++requestSequence}`
+ const promise = new Promise((resolve, reject) => {
+ const timer = window.setTimeout(() => {
+ pending.delete(id)
+ reject(new Error("Convax 请求超时"))
+ }, timeoutMs)
+ pending.set(id, { reject, resolve, timer })
+ hostPort.postMessage({ id, method, ...(params === undefined ? {} : { params }), protocol: PROTOCOL, type: "request" })
+ })
+ return { id, promise }
+}
+
+function request(method, params, timeoutMs) {
+ return sendRequest(method, params, timeoutMs).promise
+}
+
+function receiveHostMessage(event) {
+ const message = event.data
+ if (!isRecord(message) || message.protocol !== PROTOCOL) return
+ if (message.type === "command") {
+ if (message.command === CONNECTED_MEDIA_CHANGED) void refreshConnectedMedia()
+ return
+ }
+ if (message.type !== "response" || typeof message.id !== "string") return
+ const entry = pending.get(message.id)
+ if (!entry) return
+ pending.delete(message.id)
+ window.clearTimeout(entry.timer)
+ if (message.ok === true) entry.resolve(message.result)
+ else entry.reject(new Error(typeof message.error === "string" ? message.error : "Convax 请求失败"))
+}
+
+function connectHost(event) {
+ const message = event.data
+ if (
+ event.source !== window.parent ||
+ !isRecord(message) ||
+ message.type !== "connect" ||
+ message.protocol !== PROTOCOL ||
+ message.pluginId !== "subtitle-studio"
+ ) return
+ const port = event.ports?.[0]
+ if (!port || hostPort) return
+ window.removeEventListener("message", connectHost)
+ hostPort = port
+ hostPort.onmessage = receiveHostMessage
+ hostPort.start()
+ void initialize()
+}
+
+window.addEventListener("message", connectHost)
+
+function nodeState(context) {
+ const metadata = context?.node?.data?.metadata
+ const value = isRecord(metadata) ? metadata.convaxPluginState : undefined
+ return isRecord(value) ? value : {}
+}
+
+function validCue(value) {
+ return isRecord(value)
+ && typeof value.id === "string"
+ && Number.isSafeInteger(value.startMs)
+ && Number.isSafeInteger(value.endMs)
+ && value.endMs > value.startMs
+ && typeof value.text === "string"
+ && value.text.trim().length > 0
+}
+
+function validTrack(value) {
+ return isRecord(value)
+ && typeof value.id === "string"
+ && typeof value.language === "string"
+ && (value.kind === "source" || value.kind === "translation")
+ && Array.isArray(value.cues)
+ && value.cues.every(validCue)
+}
+
+function hydrateDocument(value) {
+ if (!isRecord(value) || value.schema !== "convax.subtitle/1" || !Array.isArray(value.tracks)) return null
+ const tracks = value.tracks.filter(validTrack).slice(0, 32).map((track) => ({
+ cues: track.cues.slice(0, 20_000).map((cue) => ({
+ endMs: cue.endMs,
+ id: cue.id,
+ startMs: cue.startMs,
+ text: cue.text,
+ })),
+ id: track.id,
+ kind: track.kind,
+ ...(typeof track.label === "string" ? { label: track.label } : {}),
+ language: track.language,
+ ...(typeof track.sourceTrackId === "string" ? { sourceTrackId: track.sourceTrackId } : {}),
+ }))
+ return {
+ id: typeof value.id === "string" ? value.id : crypto.randomUUID(),
+ provenance: Array.isArray(value.provenance) ? value.provenance.slice(-50) : [],
+ revision: Number.isSafeInteger(value.revision) ? value.revision : 0,
+ schema: "convax.subtitle/1",
+ source: isRecord(value.source) ? value.source : { durationMs: 0, mediaName: "video" },
+ tracks,
+ }
+}
+
+function persistedState() {
+ return {
+ activeTrackId: state.activeTrackId,
+ ...(state.document ? {
+ document: state.document,
+ sourceVersion: state.documentSourceVersion,
+ } : {}),
+ }
+}
+
+function scheduleSave() {
+ window.clearTimeout(saveTimer)
+ saveTimer = window.setTimeout(async () => {
+ try {
+ const snapshot = persistedState()
+ if (new TextEncoder().encode(JSON.stringify(snapshot)).byteLength > MAX_SUBTITLE_DOCUMENT_BYTES) {
+ throw new Error("字幕文档过大,请先拆分视频或减少轨道")
+ }
+ await request("canvas.node.updateState", { state: snapshot })
+ } catch (error) {
+ showToast(`保存字幕状态失败:${errorMessage(error)}`)
+ }
+ }, 180)
+}
+
+function formatTime(seconds) {
+ if (!Number.isFinite(seconds) || seconds < 0) return "00:00"
+ const total = Math.floor(seconds)
+ const hours = Math.floor(total / 3600)
+ const minutes = Math.floor(total / 60) % 60
+ const remaining = total % 60
+ return hours
+ ? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}`
+ : `${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}`
+}
+
+function activeTrack() {
+ return state.document?.tracks.find((track) => track.id === state.activeTrackId) ?? null
+}
+
+function renderCaption() {
+ const track = activeTrack()
+ const timeMs = Math.round(elements.video.currentTime * 1_000)
+ const cue = track?.cues.find((item) => item.startMs <= timeMs && timeMs < item.endMs)
+ elements.captions.replaceChildren()
+ if (!cue) return
+ const span = document.createElement("span")
+ span.textContent = cue.text
+ elements.captions.append(span)
+}
+
+function updateCaptionGeometry() {
+ const containerWidth = elements.video.clientWidth
+ const containerHeight = elements.video.clientHeight
+ const mediaWidth = elements.video.videoWidth
+ const mediaHeight = elements.video.videoHeight
+ if (![containerWidth, containerHeight, mediaWidth, mediaHeight].every((value) => Number.isFinite(value) && value > 0)) {
+ elements.captions.removeAttribute("style")
+ return
+ }
+ const scale = Math.min(containerWidth / mediaWidth, containerHeight / mediaHeight)
+ const width = mediaWidth * scale
+ const height = mediaHeight * scale
+ const left = (containerWidth - width) / 2
+ const top = (containerHeight - height) / 2
+ elements.captions.style.bottom = `${containerHeight - top - height + height * 0.096}px`
+ elements.captions.style.fontSize = `${width * 0.022}px`
+ elements.captions.style.left = `${left + width * 0.09}px`
+ elements.captions.style.right = "auto"
+ elements.captions.style.width = `${width * 0.82}px`
+}
+
+function renderTime() {
+ elements.time.textContent = `${formatTime(elements.video.currentTime)} / ${formatTime(elements.video.duration)}`
+ elements.play.textContent = elements.video.paused ? "▶" : "Ⅱ"
+ elements.play.setAttribute("aria-label", elements.video.paused ? "播放" : "暂停")
+ renderCaption()
+}
+
+function renderDocument() {
+ const tracks = state.document?.tracks ?? []
+ if (!tracks.some((track) => track.id === state.activeTrackId)) state.activeTrackId = tracks[0]?.id ?? ""
+ elements.activeTrack.replaceChildren()
+ elements.sourceTrack.replaceChildren()
+ for (const track of tracks) {
+ const title = track.label || track.language
+ const option = document.createElement("option")
+ option.value = track.id
+ option.textContent = `${track.language} · ${title}`
+ elements.activeTrack.append(option)
+ const sourceOption = option.cloneNode(true)
+ elements.sourceTrack.append(sourceOption)
+ }
+ elements.activeTrack.value = state.activeTrackId
+ elements.sourceTrack.value = tracks.find((track) => track.kind === "source")?.id ?? tracks[0]?.id ?? ""
+ elements.activeTrack.disabled = tracks.length === 0
+ elements.translate.disabled = tracks.length === 0 || !state.source
+ elements.exportSrt.disabled = tracks.length === 0
+ elements.title.textContent = tracks.length ? `${tracks.length} 条字幕轨道` : "尚未制作字幕"
+ elements.meta.textContent = tracks.length
+ ? `${tracks.reduce((total, track) => total + track.cues.length, 0)} 条字幕 · 当前 ${activeTrack()?.language ?? "—"}`
+ : "多语言软字幕轨道会显示在这里"
+
+ elements.tracks.replaceChildren()
+ if (!tracks.length) {
+ const empty = document.createElement("p")
+ empty.textContent = "制作或翻译字幕后,轨道会出现在这里。"
+ elements.tracks.append(empty)
+ renderCaption()
+ return
+ }
+ const measuredDurationMs = Math.round(elements.video.duration * 1_000)
+ const durationMs = Math.max(1, state.document?.source?.durationMs ?? (measuredDurationMs || 1))
+ for (const track of tracks) {
+ const row = document.createElement("div")
+ row.className = `track${track.id === state.activeTrackId ? " active" : ""}`
+ row.dataset.trackId = track.id
+ const info = document.createElement("div")
+ info.className = "track-info"
+ const name = document.createElement("strong")
+ name.textContent = track.label || track.language
+ const detail = document.createElement("span")
+ detail.textContent = `${track.language} · ${track.cues.length} cues`
+ info.append(name, detail)
+ const cues = document.createElement("div")
+ cues.className = "track-cues"
+ for (const cue of track.cues) {
+ const item = document.createElement("span")
+ item.className = "cue"
+ item.title = cue.text
+ item.style.left = `${Math.max(0, Math.min(100, cue.startMs / durationMs * 100))}%`
+ item.style.width = `${Math.max(0.2, Math.min(100, (cue.endMs - cue.startMs) / durationMs * 100))}%`
+ item.addEventListener("click", (event) => {
+ event.stopPropagation()
+ openCueEditor(track, cue)
+ })
+ cues.append(item)
+ }
+ const remove = document.createElement("button")
+ remove.type = "button"
+ remove.textContent = "×"
+ remove.title = "删除轨道"
+ remove.addEventListener("click", (event) => {
+ event.stopPropagation()
+ state.document.tracks = state.document.tracks.filter((candidate) => candidate.id !== track.id && candidate.sourceTrackId !== track.id)
+ state.document.revision += 1
+ renderDocument()
+ scheduleSave()
+ })
+ const actions = document.createElement("div")
+ actions.className = "track-actions"
+ if (track.kind === "source") {
+ const add = document.createElement("button")
+ add.type = "button"
+ add.textContent = "+"
+ add.title = "在播放位置新增字幕"
+ add.addEventListener("click", (event) => {
+ event.stopPropagation()
+ createCueAtCurrentTime(track)
+ })
+ actions.append(add)
+ }
+ actions.append(remove)
+ row.append(info, cues, actions)
+ row.addEventListener("click", () => {
+ state.activeTrackId = track.id
+ renderDocument()
+ scheduleSave()
+ })
+ elements.tracks.append(row)
+ }
+ renderCaption()
+}
+
+function renderSource() {
+ const ready = Boolean(state.source)
+ const metadataReady = ready && Number.isFinite(elements.video.duration) && elements.video.duration > 0
+ elements.empty.hidden = ready
+ elements.play.disabled = !ready
+ elements.transcribe.disabled = !ready
+ elements.importSrt.disabled = !metadataReady
+ elements.emptyTrack.disabled = !metadataReady
+ elements.inspect.disabled = !ready
+ elements.previewHard.disabled = !ready
+ elements.eraseHard.disabled = !ready
+ elements.publish.disabled = !ready
+ elements.export.disabled = !ready
+ elements.status.textContent = state.artifact?.name || state.source?.name || "等待连接视频"
+}
+
+async function closePlayback() {
+ const playback = state.playback
+ state.playback = null
+ if (!playback) return
+ await request("canvas.connectedMedia.playback.close", { playbackId: playback.playbackId }).catch(() => undefined)
+}
+
+async function releaseArtifact() {
+ const artifacts = [state.artifact, state.muxedArtifact]
+ state.artifact = null
+ state.muxedArtifact = null
+ const ids = [...new Set(artifacts.map((artifact) => artifact?.artifactId).filter(Boolean))]
+ await Promise.all(ids.map((artifactId) =>
+ request("generation.workspace.release", { artifactId }).catch(() => undefined),
+ ))
+}
+
+async function releasePreviewArtifact() {
+ const artifact = state.previewArtifact
+ state.previewArtifact = null
+ if (!artifact?.artifactId) return
+ await request("generation.workspace.release", { artifactId: artifact.artifactId }).catch(() => undefined)
+}
+
+async function openSource(source) {
+ if (typeof source.sourceVersion !== "string" || !/^[a-f0-9]{64}$/u.test(source.sourceVersion)) {
+ throw new Error("Convax 返回了无效的视频来源版本")
+ }
+ await closePlayback()
+ await releaseArtifact()
+ await releasePreviewArtifact()
+ const playback = await request("canvas.connectedMedia.playback.open", { nodeId: source.nodeId })
+ if (!isRecord(playback) || typeof playback.playbackId !== "string" || typeof playback.url !== "string") {
+ throw new Error("Convax 返回了无效的视频播放句柄")
+ }
+ const sourceChanged = Boolean(state.document && state.documentSourceVersion !== source.sourceVersion)
+ if (sourceChanged) {
+ state.document = null
+ state.activeTrackId = ""
+ state.editingCue = null
+ }
+ state.documentSourceVersion = source.sourceVersion
+ state.source = source
+ state.playback = playback
+ elements.preview.hidden = true
+ elements.video.src = playback.url
+ elements.video.load()
+ if (sourceChanged) {
+ renderDocument()
+ scheduleSave()
+ showToast("已连接新视频,原视频的字幕轨道未沿用")
+ }
+ renderSource()
+}
+
+async function refreshConnectedMedia() {
+ try {
+ const result = await request("canvas.connectedMedia.list")
+ const media = isRecord(result) && Array.isArray(result.media) ? result.media.filter((item) =>
+ isRecord(item)
+ && typeof item.nodeId === "string"
+ && typeof item.mimeType === "string"
+ && item.mimeType.startsWith("video/")
+ && typeof item.sourceVersion === "string"
+ && /^[a-f0-9]{64}$/u.test(item.sourceVersion),
+ ) : []
+ state.media = media
+ if (!media[0]) {
+ await closePlayback()
+ await releaseArtifact()
+ await releasePreviewArtifact()
+ state.source = null
+ elements.video.removeAttribute("src")
+ elements.video.load()
+ renderSource()
+ return
+ }
+ const source = media.find((item) => item.nodeId === state.source?.nodeId) ?? media[0]
+ if (
+ state.playback
+ && source.nodeId === state.source?.nodeId
+ && source.sourceVersion === state.source?.sourceVersion
+ ) {
+ state.source = source
+ renderSource()
+ return
+ }
+ await openSource(source)
+ } catch (error) {
+ await closePlayback()
+ await releaseArtifact()
+ await releasePreviewArtifact()
+ state.source = null
+ elements.video.removeAttribute("src")
+ elements.video.load()
+ renderSource()
+ showToast(`无法读取画布视频:${errorMessage(error)}`)
+ }
+}
+
+async function generationTools() {
+ if (state.tools) return state.tools
+ const result = await request("generation.tools.list")
+ state.tools = isRecord(result) && Array.isArray(result.tools) ? result.tools : []
+ return state.tools
+}
+
+async function executeWorkspace(localToolId, input, label) {
+ if (!state.source) throw new Error("请先连接一个视频")
+ const tools = await generationTools()
+ const id = `${TOOL_PREFIX}${localToolId}`
+ if (!tools.some((tool) => tool?.id === id)) throw new Error(`本地工具尚未就绪:${localToolId}`)
+ setBusy(label)
+ const requestEntry = sendRequest("generation.workspace.execute", {
+ output: input.output,
+ prompt: input.prompt,
+ references: [state.artifact
+ ? { artifactId: state.artifact.artifactId, role: "reference_video" }
+ : { nodeId: state.source.nodeId, role: "reference_video" }],
+ toolId: id,
+ toolInput: input.toolInput ?? {},
+ }, OPERATION_TIMEOUT_MS)
+ activeOperationRequestId = requestEntry.id
+ elements.cancel.hidden = false
+ try {
+ const result = await requestEntry.promise
+ if (!isRecord(result) || !Array.isArray(result.outputs)) throw new Error("本地工具返回了无效结果")
+ if (Array.isArray(result.warnings)) result.warnings.forEach((warning) => showToast(String(warning)))
+ return result.outputs
+ } finally {
+ clearBusy()
+ }
+}
+
+function textOutput(outputs) {
+ const output = outputs.find((item) => isRecord(item) && item.kind === "text")
+ if (!output || typeof output.text !== "string") throw new Error("本地工具没有返回字幕文本")
+ return output.text
+}
+
+function artifactOutput(outputs, kind) {
+ const output = outputs.find((item) => isRecord(item) && item.kind === "artifact")
+ if (!output || typeof output.artifactId !== "string" || typeof output.url !== "string") {
+ throw new Error("本地工具没有返回可播放结果")
+ }
+ if (kind && typeof output.mimeType === "string" && !output.mimeType.startsWith(`${kind}/`)) {
+ throw new Error("本地工具返回了错误的媒体类型")
+ }
+ return output
+}
+
+function setArtifact(artifact) {
+ const previous = state.artifact
+ const previousMuxed = state.muxedArtifact
+ state.artifact = artifact
+ state.muxedArtifact = null
+ elements.preview.hidden = true
+ elements.video.src = artifact.url
+ elements.video.load()
+ renderSource()
+ void releasePreviewArtifact()
+ if (previous?.artifactId && previous.artifactId !== artifact.artifactId) {
+ void request("generation.workspace.release", { artifactId: previous.artifactId }).catch(() => undefined)
+ }
+ if (previousMuxed?.artifactId && previousMuxed.artifactId !== artifact.artifactId && previousMuxed.artifactId !== previous?.artifactId) {
+ void request("generation.workspace.release", { artifactId: previousMuxed.artifactId }).catch(() => undefined)
+ }
+}
+
+function ensureDocument() {
+ if (state.document) return state.document
+ if (!state.source?.sourceVersion) throw new Error("请先连接一个视频")
+ state.documentSourceVersion = state.source.sourceVersion
+ state.document = {
+ id: `document_${crypto.randomUUID()}`,
+ provenance: [],
+ revision: 0,
+ schema: "convax.subtitle/1",
+ source: {
+ durationMs: Math.max(0, Math.round((elements.video.duration || 0) * 1_000)),
+ mediaName: state.source?.name || "video",
+ },
+ tracks: [],
+ }
+ return state.document
+}
+
+function parseSrtTimestamp(value) {
+ const match = /^(\d{1,9}):(\d{2}):(\d{2})[,.](\d{3})$/u.exec(value.trim())
+ if (!match) throw new Error(`无效 SRT 时间:${value}`)
+ const [, hours, minutes, seconds, milliseconds] = match.map(Number)
+ if (minutes > 59 || seconds > 59) throw new Error(`无效 SRT 时间:${value}`)
+ const result = ((hours * 60 + minutes) * 60 + seconds) * 1_000 + milliseconds
+ if (!Number.isSafeInteger(result)) throw new Error("SRT 时间超出支持范围")
+ return result
+}
+
+function parseSrt(value) {
+ const blocks = value.replace(/^\uFEFF/u, "").replaceAll("\r\n", "\n").replaceAll("\r", "\n").trim().split(/\n[\t ]*\n+/u)
+ return blocks.filter(Boolean).map((block, index) => {
+ const lines = block.split("\n")
+ const timelineIndex = lines[0]?.includes("-->") ? 0 : 1
+ const timeline = lines[timelineIndex]?.split(/\s*-->\s*/u)
+ if (!timeline || timeline.length !== 2) throw new Error(`SRT 字幕 ${index + 1} 缺少时间轴`)
+ const startMs = parseSrtTimestamp(timeline[0].split(/\s/u, 1)[0])
+ const endMs = parseSrtTimestamp(timeline[1].split(/\s/u, 1)[0])
+ const text = lines.slice(timelineIndex + 1).join("\n").trim()
+ if (endMs <= startMs || !text || text.includes("\n\n")) throw new Error(`SRT 字幕 ${index + 1} 无效`)
+ return { endMs, id: `cue_${crypto.randomUUID()}`, startMs, text }
+ }).sort((left, right) => left.startMs - right.startMs)
+}
+
+async function importSrtFile(file) {
+ try {
+ if (!file || file.size < 1 || file.size > 2 * 1024 * 1024) throw new Error("SRT 文件必须小于 2 MiB")
+ const cues = parseSrt(await file.text())
+ if (!cues.length) throw new Error("SRT 文件没有字幕")
+ const documentValue = ensureDocument()
+ const track = {
+ cues,
+ id: `track_${crypto.randomUUID()}`,
+ kind: "source",
+ label: elements.trackLabel.value.trim() || file.name.replace(/\.srt$/iu, "") || "导入字幕",
+ language: elements.language.value === "auto" ? "und" : elements.language.value,
+ }
+ documentValue.tracks.push(track)
+ documentValue.provenance.push({ createdAt: new Date().toISOString(), mode: "imported" })
+ documentValue.revision += 1
+ state.activeTrackId = track.id
+ renderDocument()
+ scheduleSave()
+ closePanels()
+ } catch (error) {
+ showToast(`导入 SRT 失败:${errorMessage(error)}`)
+ } finally {
+ elements.srtFile.value = ""
+ }
+}
+
+function createEmptyTrack() {
+ const documentValue = ensureDocument()
+ const track = {
+ cues: [],
+ id: `track_${crypto.randomUUID()}`,
+ kind: "source",
+ label: elements.trackLabel.value.trim() || "空字幕轨道",
+ language: elements.language.value === "auto" ? "und" : elements.language.value,
+ }
+ documentValue.tracks.push(track)
+ documentValue.provenance.push({ createdAt: new Date().toISOString(), mode: "edited" })
+ documentValue.revision += 1
+ state.activeTrackId = track.id
+ renderDocument()
+ scheduleSave()
+ closePanels()
+}
+
+async function transcribe() {
+ try {
+ const outputs = await executeWorkspace("subtitle.transcribe", {
+ output: "text",
+ prompt: "Transcribe the directly connected video's audio into an editable soft-subtitle document.",
+ toolInput: { language: elements.language.value, model: elements.model.value },
+ }, "正在本机提取音轨并转写…")
+ const parsed = JSON.parse(textOutput(outputs))
+ const documentValue = hydrateDocument(parsed)
+ if (!documentValue || documentValue.tracks.length === 0) throw new Error("转写结果不包含字幕轨道")
+ documentValue.tracks[0].label = elements.trackLabel.value.trim() || documentValue.tracks[0].label || "原文"
+ state.document = documentValue
+ state.documentSourceVersion = state.source.sourceVersion
+ state.activeTrackId = documentValue.tracks[0].id
+ renderDocument()
+ scheduleSave()
+ closePanels()
+ } catch (error) {
+ showToast(`转写失败:${errorMessage(error)}`)
+ }
+}
+
+async function inspectVideo() {
+ try {
+ const outputs = await executeWorkspace("subtitle.inspect", {
+ output: "text",
+ prompt: "Inspect embedded text-subtitle and audio streams.",
+ }, "正在检查视频流…")
+ const value = JSON.parse(textOutput(outputs))
+ const streams = Array.isArray(value?.subtitleStreams) ? value.subtitleStreams : []
+ elements.streams.replaceChildren()
+ if (!streams.length) {
+ const empty = document.createElement("p")
+ empty.textContent = "原视频没有可移除的内嵌软字幕流。"
+ elements.streams.append(empty)
+ }
+ for (const stream of streams) {
+ if (!isRecord(stream) || !Number.isSafeInteger(stream.index)) continue
+ const row = document.createElement("label")
+ row.className = "stream"
+ const checkbox = document.createElement("input")
+ checkbox.type = "checkbox"
+ checkbox.value = String(stream.index)
+ checkbox.checked = true
+ const label = document.createElement("span")
+ label.textContent = `${stream.language || "und"} · ${stream.title || `字幕流 ${stream.index}`}`
+ row.append(checkbox, label)
+ elements.streams.append(row)
+ }
+ elements.eraseSoft.disabled = streams.length === 0
+ } catch (error) {
+ showToast(`检查失败:${errorMessage(error)}`)
+ }
+}
+
+function regionInput() {
+ const region = {
+ x: Number(elements.regionX.value),
+ y: Number(elements.regionY.value),
+ width: Number(elements.regionWidth.value),
+ height: Number(elements.regionHeight.value),
+ }
+ if (Object.values(region).some((value) => !Number.isFinite(value)) || region.x < 0 || region.y < 0 || region.width <= 0 || region.height <= 0 || region.x + region.width > 1 || region.y + region.height > 1) {
+ throw new Error("字幕区域必须位于视频画面内")
+ }
+ return region
+}
+
+async function eraseSoft() {
+ try {
+ const stream_indexes = [...elements.streams.querySelectorAll('input[type="checkbox"]:checked')].map((input) => Number(input.value))
+ if (!stream_indexes.length) throw new Error("请选择要移除的字幕流")
+ const outputs = await executeWorkspace("subtitle.erase-soft", {
+ output: "video",
+ prompt: "Remove the selected embedded text-subtitle streams without re-encoding the picture.",
+ toolInput: { stream_indexes_json: JSON.stringify(stream_indexes) },
+ }, "正在移除内嵌软字幕…")
+ setArtifact(artifactOutput(outputs, "video"))
+ closePanels()
+ } catch (error) {
+ showToast(`软字幕擦除失败:${errorMessage(error)}`)
+ }
+}
+
+async function previewHard() {
+ try {
+ const outputs = await executeWorkspace("subtitle.preview-hard", {
+ output: "image",
+ prompt: "Preview the bounded hard-subtitle search region.",
+ toolInput: { ...regionInput(), timestamp_ms: Math.round(elements.video.currentTime * 1_000) },
+ }, "正在生成预览…")
+ const artifact = artifactOutput(outputs, "image")
+ const previous = state.previewArtifact
+ state.previewArtifact = artifact
+ elements.preview.src = artifact.url
+ elements.preview.hidden = false
+ if (previous?.artifactId && previous.artifactId !== artifact.artifactId) {
+ void request("generation.workspace.release", { artifactId: previous.artifactId }).catch(() => undefined)
+ }
+ } catch (error) {
+ showToast(`预览失败:${errorMessage(error)}`)
+ }
+}
+
+async function eraseHard() {
+ try {
+ const outputs = await executeWorkspace("subtitle.erase-hard", {
+ output: "video",
+ prompt: "Detect and inpaint burned-in subtitles only inside the bounded search region.",
+ toolInput: regionInput(),
+ }, "正在使用本地 AI 擦除硬字幕…")
+ setArtifact(artifactOutput(outputs, "video"))
+ closePanels()
+ } catch (error) {
+ showToast(`硬字幕擦除失败:${errorMessage(error)}`)
+ }
+}
+
+function compactTrackForTranslation(track) {
+ return track.cues.map((cue) => ({ id: cue.id, text: cue.text }))
+}
+
+function translationBatches(track) {
+ const batches = []
+ let current = []
+ let size = 2
+ for (const cue of compactTrackForTranslation(track)) {
+ const serialized = JSON.stringify(cue)
+ if (current.length && (current.length >= 120 || size + serialized.length + 1 > 12_000)) {
+ batches.push(current)
+ current = []
+ size = 2
+ }
+ if (serialized.length > 12_000) throw new Error(`字幕过长,无法安全翻译:${cue.id}`)
+ current.push(cue)
+ size += serialized.length + 1
+ }
+ if (current.length) batches.push(current)
+ return batches
+}
+
+function parseTranslationResponse(text) {
+ const match = text.match(/\[[\s\S]*\]/u)
+ if (!match) throw new Error("翻译结果不是 JSON 数组")
+ const value = JSON.parse(match[0])
+ if (!Array.isArray(value)) throw new Error("翻译结果不是 JSON 数组")
+ return value
+}
+
+async function translateTrack() {
+ const source = state.document?.tracks.find((track) => track.id === elements.sourceTrack.value)
+ if (!source) return showToast("请选择源字幕轨道")
+ const targetLanguage = elements.targetLanguage.value.trim()
+ if (!targetLanguage) return showToast("请输入目标语言")
+ setBusy("正在翻译字幕…")
+ try {
+ const batches = translationBatches(source)
+ const translated = []
+ for (const [index, batch] of batches.entries()) {
+ elements.busyText.textContent = `正在翻译字幕 ${index + 1} / ${batches.length}…`
+ const result = await request("agent.prompt", {
+ mode: "text-only",
+ text: [
+ `Translate every subtitle into ${targetLanguage}.`,
+ "Return only a JSON array. Preserve each id exactly and emit objects with exactly id and text.",
+ "Do not merge, split, omit, reorder, explain, or add timestamps.",
+ JSON.stringify(batch),
+ ].join("\n\n"),
+ }, OPERATION_TIMEOUT_MS)
+ if (!isRecord(result) || typeof result.text !== "string") throw new Error("Agent 没有返回文本")
+ translated.push(...parseTranslationResponse(result.text))
+ }
+ if (!Array.isArray(translated) || translated.length !== source.cues.length) throw new Error("翻译结果改变了字幕数量")
+ const byId = new Map(translated.map((item) => [item?.id, item?.text]))
+ const cues = source.cues.map((cue) => {
+ const text = byId.get(cue.id)
+ if (typeof text !== "string" || !text.trim()) throw new Error(`翻译缺少 cue:${cue.id}`)
+ return { ...cue, text: text.trim() }
+ })
+ const track = {
+ cues,
+ id: `track_${crypto.randomUUID()}`,
+ kind: "translation",
+ label: elements.translatedLabel.value.trim() || targetLanguage,
+ language: targetLanguage,
+ sourceTrackId: source.id,
+ }
+ state.document.tracks.push(track)
+ state.document.provenance.push({ createdAt: new Date().toISOString(), engine: "Convax Agent text-only", mode: "translated" })
+ state.document.revision += 1
+ state.activeTrackId = track.id
+ renderDocument()
+ scheduleSave()
+ closePanels()
+ } catch (error) {
+ showToast(`翻译失败:${errorMessage(error)}`)
+ } finally {
+ clearBusy()
+ }
+}
+
+function srtTimestamp(milliseconds) {
+ const hours = Math.floor(milliseconds / 3_600_000)
+ const minutes = Math.floor(milliseconds / 60_000) % 60
+ const seconds = Math.floor(milliseconds / 1_000) % 60
+ const remainder = milliseconds % 1_000
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")},${String(remainder).padStart(3, "0")}`
+}
+
+function trackSrt(track) {
+ return `${track.cues.map((cue, index) => `${index + 1}\n${srtTimestamp(cue.startMs)} --> ${srtTimestamp(cue.endMs)}\n${cue.text}`).join("\n\n")}\n`
+}
+
+function textTracksPayload() {
+ return (state.document?.tracks ?? []).map((track) => ({
+ content: trackSrt(track),
+ default: track.id === state.activeTrackId,
+ format: "srt",
+ label: track.label || track.language,
+ language: track.language,
+ }))
+}
+
+async function ensureMuxedArtifact() {
+ if (!(state.document?.tracks.length)) return state.artifact
+ if (
+ state.muxedArtifact?.subtitleDocumentRevision === state.document.revision &&
+ state.muxedArtifact?.subtitleActiveTrackId === state.activeTrackId
+ ) return state.muxedArtifact
+ const tracks = [...state.document.tracks]
+ tracks.sort((left, right) => Number(right.id === state.activeTrackId) - Number(left.id === state.activeTrackId))
+ const documentForMux = { ...state.document, tracks }
+ const serializedDocument = JSON.stringify(documentForMux)
+ if (new TextEncoder().encode(serializedDocument).byteLength > MAX_SUBTITLE_DOCUMENT_BYTES) {
+ throw new Error("字幕文档过大,无法安全封装")
+ }
+ const outputs = await executeWorkspace("subtitle.mux-soft", {
+ output: "video",
+ prompt: "Package the current editable soft-subtitle tracks into the player video without burning them into pixels.",
+ toolInput: { subtitle_document_json: serializedDocument },
+ }, "正在封装软字幕…")
+ const artifact = artifactOutput(outputs, "video")
+ artifact.subtitleDocumentRevision = state.document.revision
+ artifact.subtitleActiveTrackId = state.activeTrackId
+ const previous = state.muxedArtifact
+ state.muxedArtifact = artifact
+ if (previous?.artifactId && previous.artifactId !== artifact.artifactId) {
+ void request("generation.workspace.release", { artifactId: previous.artifactId }).catch(() => undefined)
+ }
+ return artifact
+}
+
+async function publishCurrent() {
+ try {
+ const artifact = await ensureMuxedArtifact()
+ setBusy("正在添加到画布…")
+ const result = await request("generation.workspace.publish", {
+ ...(artifact ? { artifactId: artifact.artifactId } : { sourceNodeId: state.source.nodeId }),
+ textTracks: textTracksPayload(),
+ }, OPERATION_TIMEOUT_MS)
+ if (!isRecord(result) || !Array.isArray(result.createdNodeIds)) throw new Error("画布没有返回新节点")
+ showToast("已把播放器当前内容添加到画布")
+ } catch (error) {
+ showToast(`添加到画布失败:${errorMessage(error)}`)
+ } finally {
+ clearBusy()
+ }
+}
+
+async function exportCurrent() {
+ try {
+ const artifact = await ensureMuxedArtifact()
+ setBusy("正在导出视频…")
+ const result = await request("generation.workspace.export", {
+ ...(artifact ? { artifactId: artifact.artifactId } : { sourceNodeId: state.source.nodeId }),
+ suggestedName: artifact?.name || state.source.name,
+ }, OPERATION_TIMEOUT_MS)
+ if (result?.status === "saved") showToast("视频已导出")
+ } catch (error) {
+ showToast(`导出失败:${errorMessage(error)}`)
+ } finally {
+ clearBusy()
+ }
+}
+
+async function exportActiveSrt() {
+ const track = activeTrack()
+ if (!track) return
+ try {
+ await request("host.file.exportText", {
+ content: trackSrt(track),
+ mimeType: "application/x-subrip",
+ suggestedName: `${track.label || track.language}.srt`,
+ })
+ } catch (error) {
+ showToast(`导出 SRT 失败:${errorMessage(error)}`)
+ }
+}
+
+function openCueEditor(track, cue) {
+ closePanels()
+ state.editingCue = { cueId: cue.id, trackId: track.id }
+ elements.cueIdentity.textContent = `${track.label || track.language} · ${cue.id}`
+ elements.cueStart.value = String(cue.startMs)
+ elements.cueEnd.value = String(cue.endMs)
+ elements.cueText.value = cue.text
+ const translation = track.kind === "translation"
+ elements.cueStart.disabled = translation
+ elements.cueEnd.disabled = translation
+ elements.deleteCue.disabled = translation
+ document.getElementById("editPanel").hidden = false
+}
+
+function createCueAtCurrentTime(track) {
+ if (!state.document || track.kind !== "source") return
+ const measuredDurationMs = Math.round(elements.video.duration * 1_000)
+ const durationMs = Math.max(1_000, measuredDurationMs || state.document.source.durationMs || 1_000)
+ const requestedStartMs = Math.max(0, Math.round(elements.video.currentTime * 1_000))
+ const startMs = Math.min(requestedStartMs, durationMs - 1)
+ const cue = {
+ endMs: Math.min(durationMs, startMs + 2_000),
+ id: `cue_${crypto.randomUUID()}`,
+ startMs,
+ text: "新字幕",
+ }
+ track.cues.push(cue)
+ track.cues.sort((left, right) => left.startMs - right.startMs)
+ const derivedCount = state.document.tracks.filter((candidate) => candidate.sourceTrackId === track.id).length
+ if (derivedCount) {
+ state.document.tracks = state.document.tracks.filter((candidate) => candidate.sourceTrackId !== track.id)
+ showToast("源字幕结构已变化,请重新生成翻译轨道")
+ }
+ state.document.provenance.push({ createdAt: new Date().toISOString(), mode: "edited" })
+ state.document.revision += 1
+ state.activeTrackId = track.id
+ renderDocument()
+ scheduleSave()
+ openCueEditor(track, cue)
+}
+
+function editingCue() {
+ const ref = state.editingCue
+ const track = state.document?.tracks.find((candidate) => candidate.id === ref?.trackId)
+ const cue = track?.cues.find((candidate) => candidate.id === ref?.cueId)
+ return track && cue ? { cue, track } : null
+}
+
+function saveCue() {
+ const selected = editingCue()
+ if (!selected) return closePanels()
+ const text = elements.cueText.value.trim()
+ const startMs = Number(elements.cueStart.value)
+ const endMs = Number(elements.cueEnd.value)
+ if (!text || text.includes("\n\n") || !Number.isSafeInteger(startMs) || !Number.isSafeInteger(endMs) || startMs < 0 || endMs <= startMs) {
+ return showToast("字幕文本或时间无效")
+ }
+ if (selected.track.kind === "source") {
+ const index = selected.track.cues.indexOf(selected.cue)
+ const previous = selected.track.cues[index - 1]
+ const next = selected.track.cues[index + 1]
+ if ((previous && startMs < previous.startMs) || (next && startMs > next.startMs)) {
+ return showToast("调整时间不能跨过相邻字幕片段")
+ }
+ selected.cue.startMs = startMs
+ selected.cue.endMs = endMs
+ for (const track of state.document.tracks) {
+ if (track.sourceTrackId !== selected.track.id || !track.cues[index]) continue
+ track.cues[index].startMs = startMs
+ track.cues[index].endMs = endMs
+ }
+ }
+ selected.cue.text = text
+ state.document.provenance.push({ createdAt: new Date().toISOString(), mode: "edited" })
+ state.document.revision += 1
+ renderDocument()
+ scheduleSave()
+ closePanels()
+}
+
+function deleteCue() {
+ const selected = editingCue()
+ if (!selected || selected.track.kind !== "source") return
+ const index = selected.track.cues.indexOf(selected.cue)
+ selected.track.cues.splice(index, 1)
+ for (const track of state.document.tracks) {
+ if (track.sourceTrackId === selected.track.id) track.cues.splice(index, 1)
+ }
+ state.document.provenance.push({ createdAt: new Date().toISOString(), mode: "edited" })
+ state.document.revision += 1
+ renderDocument()
+ scheduleSave()
+ closePanels()
+}
+
+function closePanels() {
+ state.editingCue = null
+ elements.preview.hidden = true
+ void releasePreviewArtifact()
+ document.querySelectorAll(".popover").forEach((panel) => { panel.hidden = true })
+ document.querySelectorAll(".actions [data-panel]").forEach((button) => button.classList.remove("active"))
+}
+
+function togglePanel(name) {
+ const panel = document.getElementById(`${name}Panel`)
+ const wasHidden = panel.hidden
+ closePanels()
+ if (!wasHidden) return
+ panel.hidden = false
+ document.querySelector(`.actions [data-panel="${name}"]`)?.classList.add("active")
+}
+
+function bindEvents() {
+ document.querySelectorAll(".actions [data-panel]").forEach((button) => button.addEventListener("click", () => togglePanel(button.dataset.panel)))
+ document.querySelectorAll("[data-close]").forEach((button) => button.addEventListener("click", closePanels))
+ document.querySelectorAll("[data-mode]").forEach((button) => button.addEventListener("click", () => {
+ document.querySelectorAll("[data-mode]").forEach((candidate) => candidate.classList.toggle("active", candidate === button))
+ elements.softErase.hidden = button.dataset.mode !== "soft"
+ elements.hardErase.hidden = button.dataset.mode !== "hard"
+ }))
+ elements.play.addEventListener("click", () => elements.video.paused ? elements.video.play() : elements.video.pause())
+ elements.video.addEventListener("timeupdate", renderTime)
+ elements.video.addEventListener("durationchange", renderTime)
+ elements.video.addEventListener("loadedmetadata", () => {
+ updateCaptionGeometry()
+ renderSource()
+ })
+ elements.video.addEventListener("play", renderTime)
+ elements.video.addEventListener("pause", renderTime)
+ elements.activeTrack.addEventListener("change", () => {
+ state.activeTrackId = elements.activeTrack.value
+ renderDocument()
+ scheduleSave()
+ })
+ elements.transcribe.addEventListener("click", transcribe)
+ elements.importSrt.addEventListener("click", () => elements.srtFile.click())
+ elements.srtFile.addEventListener("change", () => void importSrtFile(elements.srtFile.files?.[0]))
+ elements.emptyTrack.addEventListener("click", createEmptyTrack)
+ elements.inspect.addEventListener("click", inspectVideo)
+ elements.eraseSoft.addEventListener("click", eraseSoft)
+ elements.previewHard.addEventListener("click", previewHard)
+ elements.eraseHard.addEventListener("click", eraseHard)
+ elements.translate.addEventListener("click", translateTrack)
+ elements.publish.addEventListener("click", publishCurrent)
+ elements.export.addEventListener("click", exportCurrent)
+ elements.exportSrt.addEventListener("click", exportActiveSrt)
+ elements.saveCue.addEventListener("click", saveCue)
+ elements.deleteCue.addEventListener("click", deleteCue)
+ if (typeof ResizeObserver === "function") {
+ captionResizeObserver = new ResizeObserver(updateCaptionGeometry)
+ captionResizeObserver.observe(elements.video)
+ }
+ elements.cancel.addEventListener("click", () => {
+ if (!activeOperationRequestId) return
+ void request("generation.workspace.cancel", { requestId: activeOperationRequestId }).catch(() => undefined)
+ elements.busyText.textContent = "正在取消…"
+ })
+ window.addEventListener("beforeunload", () => {
+ void closePlayback()
+ void releaseArtifact()
+ void releasePreviewArtifact()
+ captionResizeObserver?.disconnect()
+ })
+}
+
+async function initialize() {
+ bindEvents()
+ try {
+ state.context = await request("host.context.get")
+ const persisted = nodeState(state.context)
+ state.document = hydrateDocument(persisted.document)
+ state.documentSourceVersion = typeof persisted.sourceVersion === "string" && /^[a-f0-9]{64}$/u.test(persisted.sourceVersion)
+ ? persisted.sourceVersion
+ : ""
+ state.activeTrackId = typeof persisted.activeTrackId === "string" ? persisted.activeTrackId : ""
+ renderDocument()
+ await refreshConnectedMedia()
+ } catch (error) {
+ showToast(`Subtitle Studio 启动失败:${errorMessage(error)}`)
+ }
+}
diff --git a/packages/plugins/subtitle-studio/package/assets/styles.css b/packages/plugins/subtitle-studio/package/assets/styles.css
new file mode 100644
index 0000000..e846287
--- /dev/null
+++ b/packages/plugins/subtitle-studio/package/assets/styles.css
@@ -0,0 +1,95 @@
+:root {
+ color-scheme: dark;
+ font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: #07080a;
+ color: #f7f7f8;
+ --accent: #dcff6b;
+ --line: rgba(255, 255, 255, 0.12);
+ --panel: rgba(13, 15, 20, 0.96);
+ --muted: #979ba7;
+}
+
+* { box-sizing: border-box; }
+html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #07080a; }
+button, input, select { font: inherit; color: inherit; }
+button { border: 1px solid var(--line); background: #15171d; border-radius: 10px; cursor: pointer; }
+button:hover:not(:disabled) { border-color: rgba(220, 255, 107, 0.65); }
+button:disabled { cursor: not-allowed; opacity: 0.42; }
+button.primary { color: #11140a; background: var(--accent); border-color: var(--accent); font-weight: 700; }
+button.danger { color: #ffb0ac; border-color: rgba(255, 91, 83, 0.35); }
+
+.studio { height: 100%; display: grid; grid-template-rows: minmax(0, 1fr) 210px; position: relative; }
+.player-pane { min-height: 0; position: relative; overflow: hidden; background: #050506; }
+#video, .preview { width: 100%; height: 100%; display: block; object-fit: contain; background: #050506; }
+.preview { position: absolute; inset: 0; }
+.empty { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 10px; text-align: center; color: var(--muted); }
+.empty > span { width: 52px; height: 52px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 50%; color: var(--accent); }
+.empty strong { color: #fff; font-size: 18px; }
+.empty p { margin: 0; font-size: 13px; }
+.captions { position: absolute; display: flex; justify-content: center; color: #fff; font-weight: 650; line-height: 1.35; pointer-events: none; text-align: center; text-shadow: 0 2px 3px #000, 0 0 14px #000; white-space: pre-line; }
+
+.actions { position: absolute; z-index: 6; top: 18px; right: 18px; display: flex; gap: 2px; padding: 5px; border: 1px solid var(--line); background: rgba(11, 12, 16, 0.92); border-radius: 15px; backdrop-filter: blur(18px); }
+.actions button { min-height: 44px; padding: 0 14px; border: 0; background: transparent; white-space: nowrap; font-weight: 650; }
+.actions button:hover:not(:disabled), .actions button.active { background: #191c24; color: var(--accent); }
+
+.popover { position: absolute; z-index: 5; top: 78px; right: 18px; width: min(820px, calc(100% - 36px)); padding: 18px; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45); }
+.popover header, .popover footer, .timeline header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
+.popover header { padding-bottom: 15px; border-bottom: 1px solid var(--line); }
+.popover header div, .timeline header div { display: grid; gap: 4px; }
+.popover header span, .popover footer span, .timeline header span { color: var(--muted); font-size: 12px; }
+.popover header button { width: 34px; height: 34px; border: 0; background: transparent; font-size: 22px; color: var(--muted); }
+.popover footer { padding-top: 16px; }
+.popover footer button { min-height: 40px; padding: 0 16px; }
+.button-row { display: flex; justify-content: flex-end; gap: 8px; }
+.form { display: grid; gap: 12px; padding: 16px 0; }
+.form.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+label { display: grid; gap: 7px; color: var(--muted); font-size: 12px; }
+input, select, textarea { min-width: 0; height: 40px; padding: 0 11px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: #0d0f14; }
+input:focus, select:focus, textarea:focus { border-color: var(--accent); }
+.cue-form { grid-template-columns: 1fr 1fr; }
+.cue-form .cue-text { grid-column: 1 / -1; }
+.cue-form textarea { height: 110px; padding: 10px 11px; resize: vertical; }
+.mode-tabs { display: inline-flex; gap: 3px; margin-top: 14px; padding: 3px; border: 1px solid var(--line); border-radius: 10px; }
+.mode-tabs button { min-height: 34px; padding: 0 16px; border: 0; background: transparent; }
+.mode-tabs button.active { color: var(--accent); background: #1b1e26; }
+.erase-body { padding-top: 15px; }
+.streams { min-height: 72px; max-height: 180px; overflow: auto; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #0b0d11; }
+.streams p { color: var(--muted); margin: 10px 0; font-size: 13px; }
+.stream { display: flex; align-items: center; gap: 10px; min-height: 34px; }
+.region { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
+
+.controls { position: absolute; left: 0; right: 0; bottom: 0; height: 56px; display: flex; align-items: center; gap: 13px; padding: 0 18px; background: linear-gradient(transparent, rgba(0, 0, 0, 0.86)); }
+.controls button { width: 38px; height: 38px; border-radius: 50%; color: var(--accent); }
+.controls output { min-width: 96px; color: #d7d9df; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
+.controls span { margin-left: auto; color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.timeline { min-height: 0; border-top: 1px solid var(--line); background: #0b0d10; overflow: hidden; }
+.timeline header { height: 58px; padding: 0 15px; border-bottom: 1px solid var(--line); }
+.timeline header select { width: 200px; margin-left: auto; }
+.timeline header button { height: 38px; padding: 0 14px; }
+.tracks { height: calc(100% - 58px); overflow: auto; padding: 10px 14px 18px; }
+.tracks > p { color: var(--muted); text-align: center; margin: 42px 0; font-size: 13px; }
+.track { display: grid; grid-template-columns: 126px minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 54px; border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
+.track.active { color: var(--accent); }
+.track-info { display: grid; gap: 3px; overflow: hidden; }
+.track-info strong, .track-info span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.track-info span { color: var(--muted); font-size: 10px; }
+.track-cues { height: 32px; position: relative; border-radius: 5px; overflow: hidden; background: #161821; }
+.cue { position: absolute; top: 4px; bottom: 4px; min-width: 2px; border-radius: 3px; background: #7362a3; }
+.track:nth-child(even) .cue { background: #556ea9; }
+.track-actions { display: flex; align-items: center; }
+.track button { width: 30px; height: 30px; border: 0; color: var(--muted); background: transparent; }
+
+.busy { position: absolute; z-index: 20; inset: 0; display: grid; place-content: center; justify-items: center; gap: 13px; background: rgba(5, 6, 8, 0.76); backdrop-filter: blur(6px); }
+.busy span { width: 30px; height: 30px; border: 3px solid rgba(220, 255, 107, 0.2); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
+.busy button { padding: 7px 14px; }
+.toast { position: absolute; z-index: 30; left: 50%; bottom: 226px; transform: translateX(-50%); max-width: 70%; padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px; background: #171920; box-shadow: 0 10px 28px #0008; font-size: 13px; }
+
+@keyframes spin { to { transform: rotate(360deg); } }
+@media (max-width: 900px) {
+ .studio { grid-template-rows: minmax(0, 1fr) 184px; }
+ .actions { left: 10px; right: 10px; overflow-x: auto; }
+ .actions button { flex: 0 0 auto; padding: 0 11px; }
+ .popover { left: 10px; right: 10px; width: auto; }
+ .form.three, .region { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
diff --git a/packages/plugins/subtitle-studio/package/index.html b/packages/plugins/subtitle-studio/package/index.html
new file mode 100644
index 0000000..0b4e8e3
--- /dev/null
+++ b/packages/plugins/subtitle-studio/package/index.html
@@ -0,0 +1,97 @@
+
+
+
+
+
+ Subtitle Studio
+
+
+
+
+
+
+
+
+
+
+
▶
+
连接一个画布视频
+
转写会自动使用视频的音频轨道,无需手动导入音频。
+
+
+
+
+
+
+
+
+
+
+
+
+
检查后仅显示原视频内嵌字幕,不包含本插件制作的轨道。
+
+
+
+
+
+
+
+
+
+
+ 等待连接视频
+
+
+
+
+ 尚未制作字幕多语言软字幕轨道会显示在这里
+
+
+ 处理中…
+
+
+
+
diff --git a/packages/plugins/subtitle-studio/package/manifest.json b/packages/plugins/subtitle-studio/package/manifest.json
new file mode 100644
index 0000000..b2f7ddf
--- /dev/null
+++ b/packages/plugins/subtitle-studio/package/manifest.json
@@ -0,0 +1,75 @@
+{
+ "schema": "convax.plugin/3",
+ "id": "subtitle-studio",
+ "name": "Subtitle Studio",
+ "description": "Local-first subtitle creation, multilingual adaptation, soft-subtitle handling, and AI hard-subtitle removal for directly connected Canvas video.",
+ "version": "0.4.0",
+ "entry": "index.html",
+ "capabilities": [
+ "canvas.connectedMedia.read",
+ "canvas.node.write",
+ "agent.prompt",
+ "generation.execute",
+ "host.files.save"
+ ],
+ "contributes": {
+ "canvas": {
+ "renderer": {
+ "create": true,
+ "width": 1180,
+ "height": 780
+ }
+ },
+ "generation": {
+ "models": [],
+ "tools": [
+ {
+ "id": "subtitle.inspect",
+ "title": "Inspect video subtitles",
+ "description": "Inspect audio and embedded text-subtitle streams in one staged video.",
+ "output": "text",
+ "acceptedInputs": ["reference_video"]
+ },
+ {
+ "id": "subtitle.transcribe",
+ "title": "Transcribe video audio",
+ "description": "Extract the selected audio stream and create a timestamped soft-subtitle document locally.",
+ "output": "text",
+ "acceptedInputs": ["reference_video"]
+ },
+ {
+ "id": "subtitle.erase-soft",
+ "title": "Remove embedded soft subtitles",
+ "description": "Remux one video while excluding selected embedded text-subtitle streams.",
+ "output": "video",
+ "acceptedInputs": ["reference_video"]
+ },
+ {
+ "id": "subtitle.preview-hard",
+ "title": "Preview hard-subtitle removal",
+ "description": "Create one preview frame using the selected normalized subtitle search region.",
+ "output": "image",
+ "acceptedInputs": ["reference_video"]
+ },
+ {
+ "id": "subtitle.erase-hard",
+ "title": "Remove burned-in subtitles",
+ "description": "Use the reviewed local AI pipeline to detect and inpaint burned-in subtitles inside a bounded region.",
+ "output": "video",
+ "acceptedInputs": ["reference_video"]
+ },
+ {
+ "id": "subtitle.mux-soft",
+ "title": "Package soft subtitles",
+ "description": "Create one MP4 whose soft-subtitle streams match the current Subtitle Studio tracks.",
+ "output": "video",
+ "acceptedInputs": ["reference_video"]
+ }
+ ]
+ }
+ },
+ "runtime": {
+ "type": "mcp-stdio",
+ "command": "convax-subtitle-studio-mcp"
+ }
+}
diff --git a/registry/config.json b/registry/config.json
index f64f894..fc69b32 100644
--- a/registry/config.json
+++ b/registry/config.json
@@ -1,4 +1,4 @@
{
- "sequence": 18,
+ "sequence": 19,
"yanked": []
}
diff --git a/schemas/convax-plugin-manifest-v3.schema.json b/schemas/convax-plugin-manifest-v3.schema.json
index d5108cc..063e5a2 100644
--- a/schemas/convax-plugin-manifest-v3.schema.json
+++ b/schemas/convax-plugin-manifest-v3.schema.json
@@ -16,15 +16,17 @@
"capabilities": {
"type": "array",
"uniqueItems": true,
- "maxItems": 7,
+ "maxItems": 9,
"items": {
"enum": [
"canvas.connectedImages.read",
+ "canvas.connectedMedia.read",
"canvas.node.read",
"canvas.node.write",
"project.files.read",
"agent.prompt",
"generation.execute",
+ "host.files.save",
"ui.fullscreen"
]
}
@@ -114,7 +116,11 @@
},
{
"if": {
- "properties": { "capabilities": { "contains": { "const": "generation.execute" } } },
+ "properties": {
+ "capabilities": {
+ "contains": { "enum": ["generation.execute", "canvas.connectedMedia.read", "host.files.save"] }
+ }
+ },
"required": ["capabilities"]
},
"then": {
@@ -131,7 +137,11 @@
"anyOf": [
{ "required": ["runtime"] },
{
- "properties": { "capabilities": { "contains": { "const": "generation.execute" } } },
+ "properties": {
+ "capabilities": {
+ "contains": { "enum": ["generation.execute", "canvas.connectedMedia.read", "host.files.save"] }
+ }
+ },
"required": ["capabilities"]
}
],
diff --git a/tooling/lib.mjs b/tooling/lib.mjs
index c5ebebb..9c4b209 100644
--- a/tooling/lib.mjs
+++ b/tooling/lib.mjs
@@ -26,6 +26,11 @@ const pluginCapabilities = new Set([
"generation.execute",
"ui.fullscreen",
])
+const pluginCapabilitiesV3 = new Set([
+ ...pluginCapabilities,
+ "canvas.connectedMedia.read",
+ "host.files.save",
+])
const generationModalities = new Set(["text", "image", "video", "audio"])
const generationInputRoles = new Set([
"reference_image",
@@ -513,8 +518,8 @@ function parsePluginManifestV3(value, label) {
exactKeys(value.contributes, ["agent", "canvas", "generation", "service"], [], `${label} contributes`)
const capabilities = value.capabilities ?? []
- if (!Array.isArray(capabilities) || capabilities.length > pluginCapabilities.size ||
- capabilities.some((item) => typeof item !== "string" || !pluginCapabilities.has(item)) ||
+ if (!Array.isArray(capabilities) || capabilities.length > pluginCapabilitiesV3.size ||
+ capabilities.some((item) => typeof item !== "string" || !pluginCapabilitiesV3.has(item)) ||
new Set(capabilities).size !== capabilities.length) error(label, "invalid or duplicate capability")
const hasRuntime = value.runtime !== undefined
@@ -523,8 +528,9 @@ function parsePluginManifestV3(value, label) {
if (hasRuntime !== (hasGeneration || hasService)) {
error(label, "runtime and executable contribution must appear together")
}
- if (!hasRuntime && !capabilities.includes("generation.execute")) {
- error(label, "convax.plugin/3 must declare an executable contribution or request generation.execute")
+ const rendererCapabilities = ["generation.execute", "canvas.connectedMedia.read", "host.files.save"]
+ if (!hasRuntime && !rendererCapabilities.some((capability) => capabilities.includes(capability))) {
+ error(label, "convax.plugin/3 must declare an executable contribution or request a renderer host capability")
}
const generation = hasGeneration ? parseGenerationV3(value.contributes.generation, `${label} generation`) : undefined
@@ -545,8 +551,8 @@ function parsePluginManifestV3(value, label) {
const hasRenderer = canvas?.renderer !== undefined
const hasEntry = value.entry !== undefined
if (hasEntry !== hasRenderer) error(label, "entry and Canvas renderer must appear together")
- if (capabilities.includes("generation.execute") && !hasRenderer) {
- error(label, "generation.execute requires a sandboxed Canvas renderer")
+ if (rendererCapabilities.some((capability) => capabilities.includes(capability)) && !hasRenderer) {
+ error(label, "renderer host capabilities require a sandboxed Canvas renderer")
}
let entry
diff --git a/tooling/plugin-v3.test.js b/tooling/plugin-v3.test.js
index 368325b..157e7a5 100644
--- a/tooling/plugin-v3.test.js
+++ b/tooling/plugin-v3.test.js
@@ -213,7 +213,7 @@ describe("convax.plugin/3 declarative contributions", () => {
expect(() => parsePluginManifest(toolbarOnly)).toThrow("toolbar requires a renderer")
})
- test("pairs entry only with a renderer and keeps generation.execute renderer-scoped", () => {
+ test("pairs entry only with a renderer and keeps renderer host capabilities scoped", () => {
const renderer = manifest()
renderer.entry = "index.html"
renderer.contributes.canvas.renderer = { mimeTypes: ["video/mp4"] }
@@ -222,8 +222,20 @@ describe("convax.plugin/3 declarative contributions", () => {
const entryWithoutRenderer = manifest({ entry: "index.html" })
expect(() => parsePluginManifest(entryWithoutRenderer)).toThrow("entry and Canvas renderer")
- const capabilityWithoutRenderer = manifest({ capabilities: ["generation.execute"] })
- expect(() => parsePluginManifest(capabilityWithoutRenderer)).toThrow("sandboxed Canvas renderer")
+ for (const capability of ["generation.execute", "canvas.connectedMedia.read", "host.files.save"]) {
+ const capabilityWithoutRenderer = manifest({ capabilities: [capability] })
+ expect(() => parsePluginManifest(capabilityWithoutRenderer)).toThrow("sandboxed Canvas renderer")
+ }
+
+ for (const capability of ["canvas.connectedMedia.read", "host.files.save"]) {
+ const rendererOnly = manifest({
+ capabilities: [capability],
+ contributes: { canvas: { renderer: { create: true, height: 480, width: 640 } } },
+ entry: "index.html",
+ runtime: undefined,
+ })
+ expect(parsePluginManifest(rendererOnly).capabilities).toEqual([capability])
+ }
})
test("does not backport v3 fields into the v2 protocol", () => {
diff --git a/tooling/registry.test.js b/tooling/registry.test.js
index 9304ffa..a3222cc 100644
--- a/tooling/registry.test.js
+++ b/tooling/registry.test.js
@@ -26,6 +26,7 @@ describe("source packages", () => {
expect(packages.map((pkg) => `${pkg.metadata.kind}/${pkg.metadata.id}`)).toEqual([
"plugin/ffmpeg-tools",
"plugin/hello-convax",
+ "plugin/subtitle-studio",
"plugin/xiaoyunque-generation",
"skill/ad-idea",
"skill/audiobook",
diff --git a/tooling/subtitle-studio.test.js b/tooling/subtitle-studio.test.js
new file mode 100644
index 0000000..22cdaf8
--- /dev/null
+++ b/tooling/subtitle-studio.test.js
@@ -0,0 +1,91 @@
+import { describe, expect, test } from "bun:test"
+import { promises as fs } from "node:fs"
+import path from "node:path"
+
+import { root } from "./lib.mjs"
+
+const packageRoot = path.join(root, "packages", "plugins", "subtitle-studio", "package")
+
+describe("Subtitle Studio package boundary", () => {
+ test("uses only generic v3 host capabilities and a separately declared companion", async () => {
+ const [source, manifest] = await Promise.all([
+ fs.readFile(path.join(root, "packages", "plugins", "subtitle-studio", "convax-package.json"), "utf8").then(JSON.parse),
+ fs.readFile(path.join(packageRoot, "manifest.json"), "utf8").then(JSON.parse),
+ ])
+
+ expect(manifest.capabilities.includes("canvas.node.read")).toBe(false)
+ expect(manifest).toMatchObject({
+ capabilities: expect.arrayContaining([
+ "agent.prompt",
+ "canvas.connectedMedia.read",
+ "canvas.node.write",
+ "generation.execute",
+ "host.files.save",
+ ]),
+ runtime: { command: "convax-subtitle-studio-mcp", type: "mcp-stdio" },
+ schema: "convax.plugin/3",
+ })
+ expect(source.companions).toEqual([
+ expect.objectContaining({
+ command: "convax-subtitle-studio-mcp",
+ source: "tools/subtitle-studio-mcp",
+ targets: [{ arch: "arm64", platform: "darwin", path: "dist/darwin-arm64/convax-subtitle-studio-mcp" }],
+ }),
+ ])
+ })
+
+ test("keeps media, generation, and translation on generic host methods", async () => {
+ const application = await fs.readFile(path.join(packageRoot, "assets", "app.js"), "utf8")
+ for (const method of [
+ "canvas.connectedMedia.list",
+ "canvas.connectedMedia.playback.open",
+ "sourceVersion",
+ "generation.workspace.execute",
+ "generation.workspace.publish",
+ "generation.workspace.export",
+ 'mode: "text-only"',
+ "generation.workspace.release",
+ ]) {
+ expect(application).toContain(method)
+ }
+ for (const privateSurface of [
+ "subtitle.sources.list",
+ "subtitle.operation.run",
+ "subtitle.document.read",
+ "subtitle.translation.prepare",
+ "window.convax.subtitle",
+ "convax-subtitle-media",
+ ]) {
+ expect(application).not.toContain(privateSurface)
+ }
+ expect(application).toContain("state.documentSourceVersion !== source.sourceVersion")
+ expect(application).toContain("source.sourceVersion === state.source?.sourceVersion")
+ })
+
+ test("keeps the player fitted above a separate subtitle timeline", async () => {
+ const [html, styles] = await Promise.all([
+ fs.readFile(path.join(packageRoot, "index.html"), "utf8"),
+ fs.readFile(path.join(packageRoot, "assets", "styles.css"), "utf8"),
+ ])
+ expect(html).toContain('class="player-pane"')
+ expect(html).toContain('class="timeline"')
+ expect(html).toContain('id="editPanel"')
+ expect(html).toContain('id="saveCue"')
+ expect(styles).toContain("object-fit: contain")
+ expect(styles).toContain("grid-template-rows: minmax(0, 1fr) 210px")
+ expect(html).not.toContain('type="range"')
+ })
+
+ test("blocks publishing until the complete pinned local runtime is packaged", async () => {
+ const [workflow, entrypoint, installedRuntime] = await Promise.all([
+ fs.readFile(path.join(root, ".github", "workflows", "publish.yml"), "utf8"),
+ fs.readFile(path.join(root, "tools", "subtitle-studio-mcp", "src", "main.ts"), "utf8"),
+ fs.readFile(path.join(root, "tools", "subtitle-studio-mcp", "src", "runtime", "installed.ts"), "utf8"),
+ ])
+ expect(workflow).toContain("Block source-only Subtitle Studio releases")
+ expect(workflow).toContain("startsWith(github.ref_name, 'plugin-subtitle-studio-v')")
+ expect(entrypoint).toContain("loadInstalledSubtitleRuntime")
+ expect(installedRuntime).toContain('path.join(path.dirname(companionExecutablePath), "runtime")')
+ expect(`${entrypoint}\n${installedRuntime}`).not.toContain("process.env")
+ })
+})
diff --git a/tools/subtitle-studio-mcp/AGENTS.md b/tools/subtitle-studio-mcp/AGENTS.md
new file mode 100644
index 0000000..2468958
--- /dev/null
+++ b/tools/subtitle-studio-mcp/AGENTS.md
@@ -0,0 +1,34 @@
+# Subtitle Studio companion contract
+
+This directory owns the separately distributed Subtitle Studio companion and its
+headless subtitle domain. It never enters a Plugin ZIP and must not be imported by
+Convax application packages.
+
+## Current scope
+
+- `src/domain` owns the portable subtitle document, SRT, translation batching and
+ response validation, host-neutral job state, media inspection, and erase-plan
+ semantics.
+- Domain code must stay deterministic and side-effect free. It must not import
+ Convax packages, Electron, Node filesystem/process APIs, native paths, MCP
+ transport, model runtimes, FFmpeg, OCR, Whisper, or inpainting implementations.
+- Keep the existing versioned subtitle schemas compatible unless an explicit,
+ tested migration is added.
+
+## Runtime boundary
+
+- MCP, media-process, and native/model adapters remain outside `src/domain` and
+ consume only its exported operations.
+- The stdio server follows the repository's companion security contract:
+ bounded MCP messages on stdout, no native paths or command lines in results, no
+ shell evaluation, exact host-staged inputs, exact host-owned outputs, and
+ cancellation that terminates active work.
+- Runtime files come only from the strict sibling `runtime/inventory.json` tree.
+ Every executable and model is pinned by relative path, exact size and SHA-256,
+ stays inside the companion root, and is rechecked before use. PATH, Homebrew,
+ environment overrides and Convax Desktop resources are not fallbacks.
+- Native/model dependencies are reviewed release assets, never Plugin package
+ contents. Do not publish a tag until the release pipeline installs the complete
+ inventory and passes the package's release gate.
+
+Run `bun typecheck`, `bun test`, and the declared release build before handoff.
diff --git a/tools/subtitle-studio-mcp/LICENSE b/tools/subtitle-studio-mcp/LICENSE
new file mode 100644
index 0000000..0260f10
--- /dev/null
+++ b/tools/subtitle-studio-mcp/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Microvoid contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/tools/subtitle-studio-mcp/README.md b/tools/subtitle-studio-mcp/README.md
new file mode 100644
index 0000000..27b9780
--- /dev/null
+++ b/tools/subtitle-studio-mcp/README.md
@@ -0,0 +1,42 @@
+# Subtitle Studio companion
+
+This package is the local `mcp-stdio` runtime for the Subtitle Studio Plugin. It
+owns subtitle parsing, audio transcription, soft-subtitle remuxing, preview
+generation, and the reviewed AI hard-subtitle erasure pipeline. Convax Desktop
+only stages scoped inputs and admits outputs; it does not contain Subtitle Studio
+process, model, or product code.
+
+## Development
+
+```bash
+bun install --frozen-lockfile
+bun run typecheck
+bun test
+bun run build:release:darwin-arm64
+```
+
+Tests use fake executables and fixtures. They do not consult `PATH`, Homebrew, or
+machine-local media/model installations.
+
+## Installed runtime layout
+
+The compiled `convax-subtitle-studio-mcp` executable accepts only a fixed sibling
+runtime tree:
+
+```text
+convax-subtitle-studio-mcp
+runtime/
+ inventory.json
+ ... pinned FFmpeg, FFprobe, Whisper, models, and hard-erasure sidecar ...
+```
+
+`inventory.json` uses `convax.subtitle-runtime/1` and pins every file by portable
+relative path, exact byte size, and SHA-256. Startup verifies the complete tree;
+each operation rechecks executable identity before use. Missing, substituted,
+symlinked, incomplete, or ambient dependencies fail closed.
+
+The repository currently carries the runtime contract and native source, not a
+publishable dependency bundle. Do not create `plugin-subtitle-studio-v0.4.0` until
+the Registry release pipeline installs this complete sibling tree, satisfies the
+128 MiB companion policy (or a reviewed replacement policy), includes third-party
+notices, signs the macOS output, and passes packaged real-media smoke tests.
diff --git a/tools/subtitle-studio-mcp/bun.lock b/tools/subtitle-studio-mcp/bun.lock
new file mode 100644
index 0000000..f832522
--- /dev/null
+++ b/tools/subtitle-studio-mcp/bun.lock
@@ -0,0 +1,24 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "@microvoid/convax-subtitle-studio-mcp",
+ "devDependencies": {
+ "@types/bun": "latest",
+ "typescript": "^5.9.3",
+ },
+ },
+ },
+ "packages": {
+ "@types/bun": ["@types/bun@1.3.14", "https://bnpm.byted.org/@types/bun/-/bun-1.3.14.tgz", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
+
+ "@types/node": ["@types/node@26.1.1", "https://bnpm.byted.org/@types/node/-/node-26.1.1.tgz", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
+
+ "bun-types": ["bun-types@1.3.14", "https://bnpm.byted.org/bun-types/-/bun-types-1.3.14.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
+
+ "typescript": ["typescript@5.9.3", "https://bnpm.byted.org/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsserver": "bin/tsserver", "tsc": "bin/tsc" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@8.3.0", "https://bnpm.byted.org/undici-types/-/undici-types-8.3.0.tgz", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
+ }
+}
diff --git a/tools/subtitle-studio-mcp/native/subtitle-erasure/CMakeLists.txt b/tools/subtitle-studio-mcp/native/subtitle-erasure/CMakeLists.txt
new file mode 100644
index 0000000..08442c3
--- /dev/null
+++ b/tools/subtitle-studio-mcp/native/subtitle-erasure/CMakeLists.txt
@@ -0,0 +1,115 @@
+cmake_minimum_required(VERSION 3.25)
+
+project(
+ convax_subtitle_erasure
+ VERSION 0.1.2
+ DESCRIPTION "Local AI hard-subtitle erasure sidecar for Convax Desktop"
+ LANGUAGES CXX
+)
+
+if(WIN32)
+ message(FATAL_ERROR "The P0 subtitle-erasure sidecar is not supported on Windows yet")
+endif()
+
+find_package(
+ OpenCV 5.0 REQUIRED
+ COMPONENTS core dnn geometry imgproc video videoio
+)
+
+add_executable(
+ convax-subtitle-erasure
+ src/main.cpp
+ src/engine.cpp
+ src/process.cpp
+ src/protocol.cpp
+)
+set_target_properties(
+ convax-subtitle-erasure
+ PROPERTIES OUTPUT_NAME "subtitle-erasure"
+)
+
+target_compile_features(convax-subtitle-erasure PRIVATE cxx_std_20)
+target_compile_definitions(
+ convax-subtitle-erasure
+ PRIVATE CONVAX_SUBTITLE_ERASURE_VERSION="${PROJECT_VERSION}"
+)
+target_include_directories(
+ convax-subtitle-erasure
+ PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src"
+)
+target_link_libraries(convax-subtitle-erasure PRIVATE ${OpenCV_LIBS})
+
+if(CMAKE_CXX_COMPILER_ID MATCHES "AppleClang|Clang|GNU")
+ target_compile_options(
+ convax-subtitle-erasure
+ PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wshadow
+ )
+endif()
+
+include(GNUInstallDirs)
+install(
+ TARGETS convax-subtitle-erasure
+ RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}"
+)
+
+include(CTest)
+if(BUILD_TESTING)
+ add_executable(
+ convax-subtitle-erasure-protocol-test
+ tests/protocol_test.cpp
+ src/protocol.cpp
+ )
+ target_compile_features(convax-subtitle-erasure-protocol-test PRIVATE cxx_std_20)
+ target_include_directories(
+ convax-subtitle-erasure-protocol-test
+ PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src"
+ )
+ if(CMAKE_CXX_COMPILER_ID MATCHES "AppleClang|Clang|GNU")
+ target_compile_options(
+ convax-subtitle-erasure-protocol-test
+ PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wshadow
+ )
+ endif()
+ add_test(NAME subtitle-erasure-protocol COMMAND convax-subtitle-erasure-protocol-test)
+
+ add_executable(
+ convax-subtitle-erasure-roi-test
+ tests/roi_test.cpp
+ )
+ target_compile_features(convax-subtitle-erasure-roi-test PRIVATE cxx_std_20)
+ target_include_directories(
+ convax-subtitle-erasure-roi-test
+ PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src"
+ )
+ if(CMAKE_CXX_COMPILER_ID MATCHES "AppleClang|Clang|GNU")
+ target_compile_options(
+ convax-subtitle-erasure-roi-test
+ PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wshadow
+ )
+ endif()
+ add_test(NAME subtitle-erasure-roi COMMAND convax-subtitle-erasure-roi-test)
+
+ add_executable(
+ convax-subtitle-erasure-roi-image-test
+ tests/roi_image_test.cpp
+ )
+ target_compile_features(convax-subtitle-erasure-roi-image-test PRIVATE cxx_std_20)
+ target_include_directories(
+ convax-subtitle-erasure-roi-image-test
+ PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src"
+ )
+ target_link_libraries(convax-subtitle-erasure-roi-image-test PRIVATE ${OpenCV_LIBS})
+ if(CMAKE_CXX_COMPILER_ID MATCHES "AppleClang|Clang|GNU")
+ target_compile_options(
+ convax-subtitle-erasure-roi-image-test
+ PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wshadow
+ )
+ endif()
+ add_test(NAME subtitle-erasure-roi-image COMMAND convax-subtitle-erasure-roi-image-test)
+
+ add_test(NAME subtitle-erasure-version COMMAND convax-subtitle-erasure --version)
+ set_tests_properties(
+ subtitle-erasure-version
+ PROPERTIES PASS_REGULAR_EXPRESSION "\\\"protocolVersion\\\":1"
+ )
+endif()
diff --git a/tools/subtitle-studio-mcp/native/subtitle-erasure/README.md b/tools/subtitle-studio-mcp/native/subtitle-erasure/README.md
new file mode 100644
index 0000000..efcaf74
--- /dev/null
+++ b/tools/subtitle-studio-mcp/native/subtitle-erasure/README.md
@@ -0,0 +1,85 @@
+# Local AI hard-subtitle erasure engine
+
+This directory owns Subtitle Studio's P0 burned-in subtitle erasure engine. It
+belongs to the separately distributed `convax-subtitle-studio-mcp` companion;
+it is never part of the static Plugin ZIP and Convax Desktop does not contain
+branches, installers, models, or process code for this engine.
+
+The checked-in C++ source is not by itself a distributable runtime. A release
+target is valid only after the companion build has produced one self-contained,
+codesigned executable artifact, pinned every native dependency and model, passed
+the Registry's 128 MiB companion limit, and completed the required license and
+golden-video review. Development builds must not be published as installable
+companions.
+
+## P0 pipeline
+
+1. Convert the user's normalized rectangle into a bounded text-search ROI. The
+ rectangle itself is never used as an erase mask.
+2. Run the pinned PP-OCR detector at a bounded sampling rate and resolution.
+3. Track credible text polygons across time, expand only those polygons for glyph
+ and backing-plate coverage, and reset tracking at scene cuts.
+4. Use the pinned LaMa model on anchor frames. Propagate anchors between samples
+ with bounded bidirectional optical flow and reject inconsistent candidates.
+5. Feather changes strictly inside the accepted mask; pixels outside it remain
+ byte-for-byte sourced from the decoded frame.
+6. Produce a private lossless intermediate and remux an Electron-playable H.264
+ MP4 while preserving supported audio, metadata, chapters, and text-subtitle
+ streams.
+
+There is no classical inpainting or remote fallback. Missing models, absent
+credible masks, unsupported media, inference failure, encoder failure, and
+cancellation all fail explicitly.
+
+The reviewed upstream model families are:
+
+- PaddlePaddle PP-OCRv6 tiny detector ONNX
+- OpenCV Foundation LaMa ONNX
+
+Release metadata must pin immutable source revisions, exact sizes and SHA-256
+digests. Mutable model URLs are not a runtime contract.
+
+## Native process protocol
+
+The native engine handles one request. The MCP companion launches it without a
+shell in a private operation directory, passes host-staged input and
+companion-owned model paths, and consumes bounded NDJSON progress/result events.
+Neither paths nor command lines cross back to Plugin Web code.
+
+Input shape:
+
+```json
+{
+ "protocolVersion": 1,
+ "operation": "erase-hard-subtitles",
+ "input": { "path": "/host-staged/source.mp4", "width": 1920, "height": 1080, "durationMs": 6000 },
+ "models": { "detectorPath": "/companion/models/detector.onnx", "inpaintingPath": "/companion/models/lama.onnx" },
+ "region": { "x": 154, "y": 778, "width": 1612, "height": 238 },
+ "output": { "path": "subtitle-erased.mp4" }
+}
+```
+
+Output events are `progress`, `result`, or `error`. The engine reports monotonic
+global progress across detect, erase, remux, and validate stages. Cancellation is
+process-owned: the companion sends `SIGTERM`, waits a bounded grace period, then
+uses a hard kill and removes the private work directory.
+
+## Developer build
+
+The engine currently requires CMake 3.25+, a C++20 compiler, OpenCV 5 with
+`core`, `dnn`, `geometry`, `imgproc`, `video`, and `videoio`, plus the exact
+reviewed model files and FFmpeg/FFprobe runtime. P0 intentionally fails closed on
+Windows.
+
+```bash
+cmake -S native/subtitle-erasure -B /tmp/convax-subtitle-erasure-build \
+ -DOpenCV_DIR=/path/to/opencv-5/lib/cmake/opencv5 \
+ -DCMAKE_BUILD_TYPE=Release
+cmake --build /tmp/convax-subtitle-erasure-build --config Release
+ctest --test-dir /tmp/convax-subtitle-erasure-build --output-on-failure
+```
+
+Before release, add packaged-runtime smoke tests and golden videos covering static
+and moving backgrounds, scene cuts, multiline/vertical text, no detection,
+cancellation, corrupt tensors, outside-mask pixel identity, audio preservation,
+long inputs, and every rejected media gate.
diff --git a/tools/subtitle-studio-mcp/native/subtitle-erasure/src/engine.cpp b/tools/subtitle-studio-mcp/native/subtitle-erasure/src/engine.cpp
new file mode 100644
index 0000000..b46c9a7
--- /dev/null
+++ b/tools/subtitle-studio-mcp/native/subtitle-erasure/src/engine.cpp
@@ -0,0 +1,1565 @@
+#include "engine.hpp"
+
+#include "process.hpp"
+#include "roi.hpp"
+#include "roi_image.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include