diff --git a/plugins/subtitle-workbench/CHANGELOG.md b/plugins/subtitle-workbench/CHANGELOG.md new file mode 100644 index 00000000..3437a16d --- /dev/null +++ b/plugins/subtitle-workbench/CHANGELOG.md @@ -0,0 +1,12 @@ +# 更新日志 + +## 0.1.0 + +- 本地 SRT/VTT 编辑、质检与转换。 +- 可选 FFmpeg / whisper-cli 能力诊断,不包含或上传二进制与媒体。 +- 新增面向 Agent 的内联字幕分析、纯计算转换和当前 UI 授权字幕分析工具;旧宿主会安全降级为仅界面模式。 +- 收紧 accessor/Symbol 输入,并在已授权字幕失效时撤销授权、返回稳定脱敏错误。 +- 将 UI 字幕授权绑定到 `dev`、`ino`、`size`、`mtime`、`ctime` 与同一文件描述符读取所得 SHA-256,重写或替换后 fail closed。 +- 为全部 MCP 响应增加 512 KiB 序列化硬预算;分析结果提供最多 200 条的 `hasMore` / `nextOffset` 分页,转换结果无法装入预算时稳定拒绝。 +- 为未压缩 `dist` 增加递归 14.5 MB(14,500,000 bytes)大小门禁,并在验证时打印精确字节数。 +- 将人工界面、运行状态、质检说明和面向人的错误提示统一为简体中文。 diff --git a/plugins/subtitle-workbench/README.md b/plugins/subtitle-workbench/README.md new file mode 100644 index 00000000..04739696 --- /dev/null +++ b/plugins/subtitle-workbench/README.md @@ -0,0 +1,29 @@ +# 字幕工坊 + +一个本地优先的字幕剪辑台:导入 SRT/VTT,编辑、查找替换、平移时间、按速度换算并导出。质检会标出重叠、倒序、时长异常、阅读速度和空文本。 + +媒体轨道提取只会在宿主提供 runFFmpeg 能力时调用;首版不携带 FFmpeg。Python whisper 与 whisper.cpp 的命令方言不同,v0.1 不探测 PATH、也不运行转写二进制;不会上传媒体或伪造转写结果。 + +## 验证 + +`npm test && npm run build` + +已验证 SRT/VTT 转换、三端路径形态、渲染安全、生命周期清理、源码与产物一致性,以及 Chromium 渲染。构建会递归统计未压缩的 `dist` 目录,打印精确字节数,并在超过 14.5 MB(14,500,000 字节)时失败。Windows、macOS、Linux 的真实 ZTools 宿主加载、宿主对话框及宿主提供的 FFmpeg 执行仍未验证。 + +保存通过系统保存对话框确认目标;若用户选择已有普通文件,原子重命名(`rename`)会以该确认作为覆盖授权,符号链接目标会被拒绝。 + +音轨提取先写同目录随机临时 WAV,成功后才提升为最终文件;已有最终文件会先保留备份,并在提升失败时回滚。Windows 上该覆盖不是单一步骤原子替换,但旧文件会保留到新文件提升成功。由于 FFmpeg 无法使用已打开的输入文件描述符执行,首版会在启动前后复核路径身份;同账户恶意并发替换仍是平台级边界,不能宣称完全无竞态。 + +导出为 SRT 会规范化字幕并丢弃 VTT 专属的 STYLE、REGION 与 NOTE 文档块;保留 VTT 时这些元数据会随时间变换保留。 + +## Agent / MCP + +ZTools 2.4+ 可把本插件同时提供给人和 Agent。清单短名 `analyze`、`transform`、`analyze_approved_file` 会由宿主分别暴露为 `subtitle_workbench_analyze`、`subtitle_workbench_transform`、`subtitle_workbench_analyze_approved_file`。旧宿主没有 `registerTool` 时只保留原有界面,不影响人工使用。 + +- `analyze` 对内联 SRT/VTT 做确定性质检,返回字幕条目数、时长、全量问题计数与最多 200 条分页问题;`hasMore` 和 `nextOffset` 可用于无重漏地继续读取。 +- `transform` 仅做纯计算的 shift / speed / fps / convert,既不保存文件,也不调用 FFmpeg 或转写能力;无法放入响应预算的转换结果会被稳定拒绝。 +- `analyze_approved_file` 只读取人类最近在界面中选择的字幕授权,不接受路径或 grant ID;它适合分析最多 15 MiB 的字幕,但仍只返回分页统计。选择时会通过只读文件描述符执行身份前后复验并建立 SHA-256 摘要;分析时再次在同一描述符上复验 `dev`、`ino`、`size`、`mtime`、`ctime` 和内容摘要。任一替换或重写都会撤销授权。 + +ZTools MCP 请求体上限为 1 MiB,因此两个内联工具把 UTF-8 输入收紧到 512 KiB,且所有工具的完整 JSON 序列化响应都有 512 KiB 硬上限。该上限按转义后的实际字节计算,避免控制字符把约 512 KiB 的转换文本膨胀成约 3.1 MB 响应。所有处理器会再次拒绝未知字段、`Symbol`、访问器、污染原型、非法格式、越界阈值和数值;`inputSchema` 不是安全校验的替代品。已授权字幕失效时会立即撤销授权并返回不含路径或原始内容的稳定错误,授权和读取使用的文件描述符都会关闭。Agent 只能提出或执行确定性转换,不能覆盖质检结果,也不能借此访问保存、媒体、FFmpeg 或转写接口。 + +人工界面、文件对话框标题、运行状态、错误提示和质检问题说明均使用简体中文;MCP 工具名、错误码、字段名和协议值保持稳定。 diff --git a/plugins/subtitle-workbench/index.html b/plugins/subtitle-workbench/index.html new file mode 100644 index 00000000..da71b973 --- /dev/null +++ b/plugins/subtitle-workbench/index.html @@ -0,0 +1,120 @@ + + + + + + 字幕工坊 + + + +
+
+
本地剪辑室 / V0.1

字幕工坊

+

本地授权打开字幕或媒体;不会上传内容。

+
+
+
+
+ + + +
+
+ + + + +
+ +
+ +
+
+ + + diff --git a/plugins/subtitle-workbench/logo.svg b/plugins/subtitle-workbench/logo.svg new file mode 100644 index 00000000..13c8656b --- /dev/null +++ b/plugins/subtitle-workbench/logo.svg @@ -0,0 +1 @@ + diff --git a/plugins/subtitle-workbench/package-lock.json b/plugins/subtitle-workbench/package-lock.json new file mode 100644 index 00000000..d7ab0bf3 --- /dev/null +++ b/plugins/subtitle-workbench/package-lock.json @@ -0,0 +1,7 @@ +{ + "name": "subtitle-workbench", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { "": { "name": "subtitle-workbench", "version": "0.1.0", "engines": { "node": ">=16" } } } +} diff --git a/plugins/subtitle-workbench/package.json b/plugins/subtitle-workbench/package.json new file mode 100644 index 00000000..e2e4fe76 --- /dev/null +++ b/plugins/subtitle-workbench/package.json @@ -0,0 +1,12 @@ +{ + "name": "subtitle-workbench", + "version": "0.1.0", + "private": true, + "description": "面向 ZTools 的本地优先字幕编辑工作台。", + "scripts": { + "test": "node --test tests/*.test.cjs", + "build": "npm test && node scripts/build.mjs && node scripts/verify-dist.mjs", + "verify-dist": "node scripts/verify-dist.mjs" + }, + "engines": { "node": ">=16" } +} diff --git a/plugins/subtitle-workbench/plugin.json b/plugins/subtitle-workbench/plugin.json new file mode 100644 index 00000000..82406d40 --- /dev/null +++ b/plugins/subtitle-workbench/plugin.json @@ -0,0 +1,206 @@ +{ + "name": "subtitle-workbench", + "title": "字幕工坊", + "description": "本地解析、校对、调整与导出 SRT/VTT 字幕;外部转写能力按需接入。", + "version": "0.1.0", + "author": "harris", + "main": "index.html", + "preload": "preload/services.cjs", + "logo": "logo.svg", + "platform": [ + "darwin", + "win32", + "linux" + ], + "categories": [ + "media", + "text" + ], + "features": [ + { + "code": "subtitle-workbench-open", + "explain": "编辑、校对或转换一个字幕文件", + "icon": "logo.svg", + "cmds": [ + "字幕编辑", + "SRT 转 VTT", + "字幕校对", + { + "type": "files", + "fileType": "file", + "extensions": [ + "srt", + "vtt", + "mp4", + "mov", + "mkv", + "mp3", + "wav" + ], + "minLength": 1, + "maxLength": 1, + "label": "打开一个字幕或媒体文件" + } + ] + } + ], + "tools": { + "analyze": { + "title": "分析内联字幕", + "description": "对不超过 512 KiB 的内联 SRT/VTT 做确定性质检,仅返回最多 200 条的可续分页问题与统计,序列化响应不超过 512 KiB。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "maxLength": 524288 + }, + "format": { + "type": "string", + "enum": [ + "srt", + "vtt" + ] + }, + "thresholds": { + "type": "object", + "additionalProperties": false, + "properties": { + "minDuration": { + "type": "integer", + "minimum": 0, + "maximum": 60000 + }, + "maxDuration": { + "type": "integer", + "minimum": 1, + "maximum": 600000 + }, + "maxCharsPerSecond": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1000 + } + } + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200 + } + }, + "required": [ + "content", + "format" + ] + } + }, + "transform": { + "title": "转换内联字幕", + "description": "对不超过 512 KiB 的内联字幕执行平移、速度、帧率或格式转换,不读写文件;序列化响应超过 512 KiB 时拒绝。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "maxLength": 524288 + }, + "format": { + "type": "string", + "enum": [ + "srt", + "vtt" + ] + }, + "operation": { + "type": "string", + "enum": [ + "shift", + "speed", + "fps", + "convert" + ] + }, + "milliseconds": { + "type": "integer", + "minimum": -86400000, + "maximum": 86400000 + }, + "speed": { + "type": "number", + "minimum": 0.1, + "maximum": 10 + }, + "sourceFps": { + "type": "number", + "minimum": 1, + "maximum": 240 + }, + "targetFps": { + "type": "number", + "minimum": 1, + "maximum": 240 + }, + "outputFormat": { + "type": "string", + "enum": [ + "srt", + "vtt" + ] + } + }, + "required": [ + "content", + "format", + "operation" + ] + } + }, + "analyze_approved_file": { + "title": "分析已授权字幕文件", + "description": "分析用户最近在插件界面选择且身份与摘要未变化的字幕文件;不接受路径或授权令牌,只返回最多 200 条的可续分页问题与统计。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "thresholds": { + "type": "object", + "additionalProperties": false, + "properties": { + "minDuration": { + "type": "integer", + "minimum": 0, + "maximum": 60000 + }, + "maxDuration": { + "type": "integer", + "minimum": 1, + "maximum": 600000 + }, + "maxCharsPerSecond": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1000 + } + } + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200 + } + } + } + } + } +} diff --git a/plugins/subtitle-workbench/preload/package-lock.json b/plugins/subtitle-workbench/preload/package-lock.json new file mode 100644 index 00000000..732e5f8f --- /dev/null +++ b/plugins/subtitle-workbench/preload/package-lock.json @@ -0,0 +1 @@ +{ "name": "subtitle-workbench-preload", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "subtitle-workbench-preload", "version": "0.1.0" } } } diff --git a/plugins/subtitle-workbench/preload/package.json b/plugins/subtitle-workbench/preload/package.json new file mode 100644 index 00000000..5bbefffb --- /dev/null +++ b/plugins/subtitle-workbench/preload/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/plugins/subtitle-workbench/preload/services.cjs b/plugins/subtitle-workbench/preload/services.cjs new file mode 100644 index 00000000..99bc45f7 --- /dev/null +++ b/plugins/subtitle-workbench/preload/services.cjs @@ -0,0 +1,566 @@ +'use strict' +const fs = require('node:fs/promises') +const path = require('node:path') +const crypto = require('node:crypto') +const core = require('./subtitle-core.cjs') +const MAX_SUBTITLE_BYTES = 15 * 1024 * 1024 +const MAX_MEDIA_BYTES = 4 * 1024 * 1024 * 1024 +const MAX_GRANTS = 20 +const MAX_MCP_INLINE_BYTES = 512 * 1024 +const MAX_MCP_RESPONSE_BYTES = 512 * 1024 +const MAX_MCP_FINDINGS = 200 +const SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt']) +const MEDIA_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.mp3', '.wav']) +const grants = new Map() +const entryQueue = [] +const audioJobs = new Map() +const audioCleanupFailures = new Map() +const TOOL_NAMES = Object.freeze({ analyze: 'analyze', transform: 'transform', analyzeApprovedFile: 'analyze_approved_file' }) +const registeredHosts = new WeakSet() +let activeSubtitleGrantId = null +let audioPromotionIo = null +let audioCleanupIo = null +let fileValidationIo = null +let sessionEpoch = 0 +const AUDIO_ERROR_MESSAGES = Object.freeze({ + AUDIO_OUTPUT_SYMLINK: '不能覆盖符号链接。', + AUDIO_OUTPUT_NOT_FILE: '音轨输出位置必须是普通文件,不能覆盖目录或特殊文件。', + AUDIO_OUTPUT_CHECK_FAILED: '无法安全检查音轨输出位置。', + AUDIO_TEMPORARY_CHECK_FAILED: '无法安全准备音轨临时文件。', + AUDIO_EXTRACT_START_FAILED: '无法启动音轨提取。', + AUDIO_PROMOTE_FAILED: '新音轨写入失败,原文件已恢复。', + AUDIO_BACKUP_RESTORE_FAILED: '新音轨写入失败,原文件也未能自动恢复。', + AUDIO_TEMP_CLEANUP_FAILED: '音轨临时文件未能清理,请重试。', + AUDIO_EXTRACT_FAILED: '音轨提取或写入失败。', + AUDIO_CANCELED: '音轨提取已取消。' +}) +function host() { return typeof window !== 'undefined' && window.ztools ? window.ztools : {} } +function makeId() { return crypto.randomBytes(12).toString('hex') } +function byteLength(value) { return Buffer.byteLength(String(value || ''), 'utf8') } +function dialogPath(result) { return Array.isArray(result) ? result[0] : typeof result === 'string' ? result : result && Array.isArray(result.filePaths) ? result.filePaths[0] : null } +function extension(value) { return path.extname(String(value || '')).toLowerCase() } +function revokeGrant(id) { grants.delete(id); if (activeSubtitleGrantId === id) activeSubtitleGrantId = null } +function sameFileIdentity(left, right) { return ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs'].every((key) => left[key] === right[key]) } +function sha256(value) { return crypto.createHash('sha256').update(value).digest('hex') } +function safeBaseName(value) { return String(value || '音轨.wav').split(/[\\/]/).pop().replace(/[\u0000-\u001f]/g, '_').slice(0, 180) } +function stableAudioError(code, file) { const error = new Error(AUDIO_ERROR_MESSAGES[code] || AUDIO_ERROR_MESSAGES.AUDIO_EXTRACT_FAILED); error.code = code; error.fileName = safeBaseName(file); return error } +function sessionExpired() { const error = new Error('当前插件会话已结束,请重新打开文件。'); error.code = 'SESSION_EXPIRED'; return error } +function assertSessionEpoch(epoch) { if (epoch !== sessionEpoch) throw sessionExpired() } +function assertReplaceableAudioOutput(entry, output) { + if (entry.isSymbolicLink()) throw stableAudioError('AUDIO_OUTPUT_SYMLINK', output) + if (!entry.isFile()) throw stableAudioError('AUDIO_OUTPUT_NOT_FILE', output) +} +function audioFailureDetails(failure, output, canceled) { + const requested = canceled ? 'AUDIO_CANCELED' : failure && failure.code + const code = Object.hasOwn(AUDIO_ERROR_MESSAGES, requested) ? requested : 'AUDIO_EXTRACT_FAILED' + return { code, message: AUDIO_ERROR_MESSAGES[code], fileName: safeBaseName(failure && failure.fileName || output) } +} +async function readHandleBounded(handle, limit) { + if (!Number.isSafeInteger(limit) || limit < 0 || limit > MAX_SUBTITLE_BYTES) throw new RangeError('输入超过大小限制') + const buffer = Buffer.allocUnsafe(limit + 1) + let offset = 0 + while (offset < buffer.length) { + const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null) + if (bytesRead === 0) break + offset += bytesRead + } + if (offset > limit) throw new RangeError('输入超过大小限制') + return buffer.subarray(0, offset) +} +async function cleanupTemporary(jobId, record) { + const temporary = record && record.temporary + if (!temporary) { audioCleanupFailures.delete(jobId); return { ok: true } } + if (record.cleanupPromise) return record.cleanupPromise + const disk = audioCleanupIo || fs + const pending = (async () => { + try { + await disk.rm(temporary, { force: true }) + if (record.temporary === temporary) record.temporary = null + audioCleanupFailures.delete(jobId) + if (record.warning?.code === 'AUDIO_TEMP_CLEANUP_FAILED') record.warning = null + return { ok: true, fileName: safeBaseName(temporary) } + } catch { + const warning = { code: 'AUDIO_TEMP_CLEANUP_FAILED', fileName: safeBaseName(temporary) } + record.warning = warning + audioCleanupFailures.set(jobId, record) + return { ok: false, ...warning } + } + })() + record.cleanupPromise = pending + try { return await pending } + finally { if (record.cleanupPromise === pending) record.cleanupPromise = null } +} +function clearSession() { + sessionEpoch += 1 + grants.clear(); entryQueue.length = 0; activeSubtitleGrantId = null + const cleanupRecords = new Map([...audioCleanupFailures, ...audioJobs]) + for (const [jobId, record] of cleanupRecords) { + record.state = 'canceling' + try { if (record.job && typeof record.job.quit === 'function') record.job.quit(); else if (record.job && typeof record.job.kill === 'function') record.job.kill() } catch {} + if (record.temporary) void cleanupTemporary(jobId, record) + } + audioJobs.clear() + audioPromotionIo = null + audioCleanupIo = null + fileValidationIo = null +} +async function validateFile(candidate, expectedType) { + if (typeof candidate !== 'string' || !path.isAbsolute(candidate)) throw new Error('输入文件必须使用绝对路径') + const disk = fileValidationIo || fs + const first = await disk.lstat(candidate); if (!first.isFile() || first.isSymbolicLink()) throw new Error('输入必须是普通文件,不能是符号链接') + const real = await disk.realpath(candidate); const entry = await disk.lstat(real); if (!entry.isFile() || entry.isSymbolicLink()) throw new Error('解析后的输入必须是普通文件') + const ext = extension(real); const type = SUBTITLE_EXTENSIONS.has(ext) ? 'subtitle' : MEDIA_EXTENSIONS.has(ext) ? 'media' : null + if (!type || expectedType && type !== expectedType) throw new Error('当前操作不支持此文件扩展名') + if (entry.size > (type === 'subtitle' ? MAX_SUBTITLE_BYTES : MAX_MEDIA_BYTES)) throw new RangeError('输入超过大小限制') + return { path: real, type, ext, size: entry.size, dev: entry.dev, ino: entry.ino, mtimeMs: entry.mtimeMs, ctimeMs: entry.ctimeMs } +} +async function snapshotSubtitleDigest(checked) { + let handle + try { + handle = await fs.open(checked.path, 'r') + const before = await handle.stat(); if (!sameFileIdentity(before, checked)) throw new Error('字幕在授权前已发生变化') + const bytes = await readHandleBounded(handle, checked.size) + const after = await handle.stat(); if (!sameFileIdentity(after, checked) || bytes.length !== checked.size) throw new Error('字幕在授权过程中发生变化') + return sha256(bytes) + } finally { if (handle) await handle.close() } +} +async function grantFile(candidate, epoch = sessionEpoch) { + assertSessionEpoch(epoch) + const checked = await validateFile(candidate) + assertSessionEpoch(epoch) + const digest = checked.type === 'subtitle' ? await snapshotSubtitleDigest(checked) : null + assertSessionEpoch(epoch) + if (grants.size >= MAX_GRANTS) revokeGrant(grants.keys().next().value) + const id = makeId(); grants.set(id, { ...checked, digest, expiresAt: Date.now() + 30 * 60 * 1000 }) + if (checked.type === 'subtitle') activeSubtitleGrantId = id + return { grantId: id, type: checked.type, name: path.basename(checked.path), size: checked.size, extension: checked.ext } +} +async function getGrant(id, expected) { + const grant = grants.get(id); if (!grant || grant.expiresAt < Date.now()) { revokeGrant(id); throw new Error('文件授权已过期') } + try { + const checked = await validateFile(grant.path, expected) + if (checked.path !== grant.path || checked.type !== grant.type || !sameFileIdentity(checked, grant)) throw new Error('已授权文件在选择后发生变化') + return grant + } catch (error) { revokeGrant(id); throw error } +} +async function getGrantForSession(id, expected, epoch) { + assertSessionEpoch(epoch) + try { + const grant = await getGrant(id, expected) + assertSessionEpoch(epoch) + return grant + } catch (error) { + if (epoch !== sessionEpoch) throw sessionExpired() + throw error + } +} +async function chooseInput() { + const epoch = sessionEpoch + const api = host(); if (typeof api.showOpenDialog !== 'function') return { ok: false, code: 'DIALOG_UNAVAILABLE' } + const selected = dialogPath(await api.showOpenDialog({ title: '选择一个字幕或媒体文件', properties: ['openFile'], filters: [{ name: '字幕或媒体', extensions: ['srt', 'vtt', 'mp4', 'mov', 'mkv', 'mp3', 'wav'] }] })) + if (epoch !== sessionEpoch) return { ok: false, code: 'SESSION_EXPIRED' } + if (!selected) return { ok: false, code: 'CANCELED' } + try { return { ok: true, file: await grantFile(selected, epoch) } } + catch (error) { if (error?.code === 'SESSION_EXPIRED') return { ok: false, code: error.code }; throw error } +} +async function queueEntry(payload) { + const epoch = sessionEpoch + const api = host(); const file = Array.isArray(payload) ? payload[0] : null + if (!file || typeof api.getPathForFile !== 'function') return + try { + const nativePath = await api.getPathForFile(file) + assertSessionEpoch(epoch) + const granted = await grantFile(nativePath, epoch) + assertSessionEpoch(epoch) + entryQueue.push({ ok: true, file: granted }) + } + catch (error) { if (epoch === sessionEpoch && error?.code !== 'SESSION_EXPIRED') entryQueue.push({ ok: false, code: 'ENTRY_REJECTED', message: String(error.message || error).slice(0, 160) }) } +} +function consumeEntry() { return entryQueue.shift() || { ok: false, code: 'NO_ENTRY' } } +async function readGrantedSubtitle(id, epoch = sessionEpoch) { + let grant, handle + try { + assertSessionEpoch(epoch) + grant = await getGrant(id, 'subtitle'); handle = await fs.open(grant.path, 'r') + assertSessionEpoch(epoch) + const before = await handle.stat(); if (!sameFileIdentity(before, grant)) throw new Error('已授权文件在读取前发生变化') + const bytes = await readHandleBounded(handle, grant.size) + const after = await handle.stat(); if (!sameFileIdentity(after, grant)) throw new Error('已授权文件在读取过程中发生变化') + if (bytes.length !== grant.size || sha256(bytes) !== grant.digest) throw new Error('已授权文件内容在选择后发生变化') + assertSessionEpoch(epoch) + const content = bytes.toString('utf8') + if (byteLength(content) > MAX_SUBTITLE_BYTES) throw new RangeError('输入超过文本大小限制') + return { name: path.basename(grant.path), format: grant.ext.slice(1), content } + } catch (error) { revokeGrant(id); throw error } + finally { if (handle) await handle.close() } +} +function analyze(content, format, options) { + if (byteLength(content) > MAX_SUBTITLE_BYTES) throw new RangeError('字幕文本超过大小限制') + const normalized = format === 'vtt' ? 'vtt' : 'srt'; const cues = core.parse(content, normalized) + return { cues, findings: core.qualityCheck(cues, options || {}) } +} +function transform(content, format, operation) { + const input = operation && typeof operation === 'object' ? operation : {}; const result = analyze(content, format) + let cues = result.cues + if (input.type === 'shift') cues = core.shift(cues, Number(input.milliseconds)) + else if (input.type === 'speed') cues = core.scale(cues, Number(input.speed)) + else if (input.type === 'fps') { const source = Number(input.sourceFps); const target = Number(input.targetFps); if (!Number.isFinite(source) || !Number.isFinite(target) || source <= 0 || target <= 0 || source > 240 || target > 240) throw new RangeError('帧率无效'); cues = core.scale(cues, target / source) } + else if (input.type !== 'convert') throw new Error('未知的字幕转换操作') + const outputFormat = input.format || format + return outputFormat === 'vtt' ? core.toVtt(cues) : core.toSrt(cues) +} +function assertReplaceableSubtitleOutput(entry) { + if (entry.isSymbolicLink()) throw new Error('不能覆盖符号链接') + if (!entry.isFile()) throw new Error('字幕输出位置必须是普通文件,不能覆盖目录或特殊文件') +} +const SAFE_SUBTITLE_SAVE_MESSAGES = new Set([ + '不能覆盖符号链接', + '字幕输出位置必须是普通文件,不能覆盖目录或特殊文件', + '字幕输出位置在保存过程中发生变化' +]) +function stableSubtitleSaveError(error) { + if (error?.code === 'SESSION_EXPIRED' || error?.code === 'SUBTITLE_SAVE_ROLLBACK_FAILED') return error + if (SAFE_SUBTITLE_SAVE_MESSAGES.has(error?.message)) return error + const failure = new Error('字幕保存失败;请检查目标位置是否可写且未被占用。') + failure.code = 'SUBTITLE_SAVE_FAILED' + return failure +} +async function subtitleOutputIdentity(destination) { + try { + const entry = await fs.lstat(destination) + assertReplaceableSubtitleOutput(entry) + return { dev: entry.dev, ino: entry.ino, size: entry.size, mtimeMs: entry.mtimeMs, ctimeMs: entry.ctimeMs } + } catch (error) { + if (error?.code === 'ENOENT') return null + throw error + } +} +async function assertSubtitleOutputUnchanged(destination, approved) { + const current = await subtitleOutputIdentity(destination) + if (!approved && !current) return + if (!approved || !current || !sameFileIdentity(approved, current)) throw new Error('字幕输出位置在保存过程中发生变化') +} +async function writeAtomically(destination, content, approvedDestination, epoch = sessionEpoch) { + const nonce = crypto.randomBytes(6).toString('hex') + const temporary = destination + '.ztools-' + nonce + '.tmp' + const backup = destination + '.ztools-' + nonce + '.backup' + let movedOriginal = false + let installedFinal = false + let rollbackFailed = false + try { + assertSessionEpoch(epoch) + await fs.writeFile(temporary, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' }) + assertSessionEpoch(epoch) + await assertSubtitleOutputUnchanged(destination, approvedDestination) + assertSessionEpoch(epoch) + if (approvedDestination) { + await fs.rename(destination, backup) + movedOriginal = true + assertSessionEpoch(epoch) + } + assertSessionEpoch(epoch) + await fs.rename(temporary, destination) + installedFinal = true + assertSessionEpoch(epoch) + if (movedOriginal) { + // The final rename plus the epoch check above is the commit point. Once + // backup cleanup starts, a later plugin-out must not turn a completed + // save into a rejected promise after the original has been removed. + await fs.rm(backup, { force: true }) + movedOriginal = false + } + } + catch (error) { + try { + if (installedFinal) await fs.rm(destination, { force: true }) + if (movedOriginal) { + await fs.rename(backup, destination) + movedOriginal = false + } + } catch { + rollbackFailed = true + } + if (rollbackFailed) { + const failure = new Error('字幕保存已取消,但原文件恢复失败;请检查同目录的恢复副本。') + failure.code = 'SUBTITLE_SAVE_ROLLBACK_FAILED' + throw failure + } + throw error + } + finally { + await fs.rm(temporary, { force: true }).catch(() => {}) + if (!rollbackFailed && movedOriginal) await fs.rm(backup, { force: true }).catch(() => {}) + } +} +async function saveSubtitle(content, format, name) { + if (byteLength(content) > MAX_SUBTITLE_BYTES) throw new RangeError('字幕文本超过大小限制') + const epoch = sessionEpoch + assertSessionEpoch(epoch) + const kind = format === 'vtt' ? 'vtt' : 'srt'; const api = host(); if (typeof api.showSaveDialog !== 'function') throw new Error('保存对话框不可用') + const fallback = String(name || '字幕').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\.(?:srt|vtt)$/i, '') + '.' + kind + try { + const chosen = await api.showSaveDialog({ title: '保存字幕', defaultPath: fallback, filters: [{ name: kind.toUpperCase(), extensions: [kind] }] }) + assertSessionEpoch(epoch) + const destination = typeof chosen === 'string' ? chosen : chosen && !chosen.canceled ? chosen.filePath : null + if (!destination || !path.isAbsolute(destination)) return { canceled: true } + const prior = await subtitleOutputIdentity(destination) + assertSessionEpoch(epoch) + await writeAtomically(destination, content, prior, epoch) + return { canceled: false, fileName: path.basename(destination) } + } catch (error) { + throw stableSubtitleSaveError(error) + } +} +function getCapabilities() { + const api = host() + return { ffmpeg: typeof api.runFFmpeg === 'function', ffmpegCancel: false, whisper: { verified: false, runnable: false }, transcription: 'v0.1 不探测 PATH,也不运行转写二进制;后续版本需通过明确适配器配置。' } +} +async function promoteAudio(temporary, output, io) { + const disk = io || fs + let backup = null + try { + const existing = await disk.lstat(output) + assertReplaceableAudioOutput(existing, output) + backup = output + '.ztools-backup-' + makeId() + await disk.rename(output, backup) + } catch (error) { if (error && error.code !== 'ENOENT') throw error } + try { + await disk.rename(temporary, output) + } + catch (error) { + if (backup) { + try { await disk.rename(backup, output) } + catch { throw stableAudioError('AUDIO_BACKUP_RESTORE_FAILED', backup) } + } + throw stableAudioError('AUDIO_PROMOTE_FAILED', output) + } + if (!backup) return { warning: null } + try { await disk.rm(backup, { force: true }); return { warning: null } } + catch { return { warning: { code: 'AUDIO_BACKUP_CLEANUP_FAILED', fileName: safeBaseName(backup) } } } +} +async function startAudioExtract(id) { + const epoch = sessionEpoch + let grant = await getGrantForSession(id, 'media', epoch); const api = host(); if (typeof api.runFFmpeg !== 'function') return { ok: false, code: 'FFMPEG_UNAVAILABLE' } + if (typeof api.showSaveDialog !== 'function') throw new Error('保存对话框不可用') + const chosen = await api.showSaveDialog({ title: '导出音轨', defaultPath: path.basename(grant.path, grant.ext) + '.wav', filters: [{ name: 'WAV', extensions: ['wav'] }] }) + assertSessionEpoch(epoch) + const output = typeof chosen === 'string' ? chosen : chosen && !chosen.canceled ? chosen.filePath : null + if (!output || !path.isAbsolute(output)) return { ok: false, code: 'CANCELED' } + try { const prior = await fs.lstat(output); assertReplaceableAudioOutput(prior, output) } + catch (error) { if (error && error.code !== 'ENOENT') { if (Object.hasOwn(AUDIO_ERROR_MESSAGES, error.code)) throw error; throw stableAudioError('AUDIO_OUTPUT_CHECK_FAILED', output) } } + assertSessionEpoch(epoch) + grant = await getGrantForSession(id, 'media', epoch) + if (path.resolve(output) === path.resolve(grant.path)) throw new Error('音轨输出文件不能与输入文件相同') + if (new Set([...audioJobs.keys(), ...audioCleanupFailures.keys()]).size >= MAX_GRANTS) { + for (const [key, value] of audioJobs) if (value.state !== 'running' && value.state !== 'canceling' && !audioCleanupFailures.has(key)) audioJobs.delete(key) + if (new Set([...audioJobs.keys(), ...audioCleanupFailures.keys()]).size >= MAX_GRANTS) throw new Error('保留的音轨任务过多,请先清理临时音轨') + } + const suffix = path.extname(output) || '.wav'; const temporary = path.join(path.dirname(output), '.' + path.basename(output, suffix) + '.ztools-' + makeId() + suffix) + try { await fs.lstat(temporary); throw stableAudioError('AUDIO_TEMPORARY_CHECK_FAILED', temporary) } + catch (error) { if (error && error.code !== 'ENOENT') { if (Object.hasOwn(AUDIO_ERROR_MESSAGES, error.code)) throw error; throw stableAudioError('AUDIO_TEMPORARY_CHECK_FAILED', temporary) } } + assertSessionEpoch(epoch) + const idValue = makeId(); let job + try { job = api.runFFmpeg(['-n', '-i', grant.path, '-vn', '-acodec', 'pcm_s16le', temporary], undefined) } + catch { throw stableAudioError('AUDIO_EXTRACT_START_FAILED', output) } + const record = { job, temporary, output, state: 'running', code: null, message: '', warning: null, fileName: safeBaseName(output), cleanupPromise: null }; audioJobs.set(idValue, record) + const finish = async (error) => { + try { + if (error || record.state === 'canceling') throw error || new Error('音轨提取已取消') + const promoted = await promoteAudio(temporary, output, audioPromotionIo || undefined); record.state = 'completed'; record.warning = promoted && promoted.warning || null + } catch (failure) { const canceled = record.state === 'canceling'; const detail = audioFailureDetails(failure, output, canceled); record.state = canceled ? 'canceled' : 'failed'; record.code = detail.code; record.message = detail.message; record.fileName = detail.fileName; await cleanupTemporary(idValue, record) } + const cleanupTimer = setTimeout(() => audioJobs.delete(idValue), 5 * 60 * 1000); if (typeof cleanupTimer.unref === 'function') cleanupTimer.unref() + } + Promise.resolve(job).then(() => finish(), error => finish(error)) + return { ok: true, jobId: idValue, cancelSupported: Boolean(job && (typeof job.quit === 'function' || typeof job.kill === 'function')) } +} +function audioJobStatus(jobId) { const record = audioJobs.get(jobId); if (!record) return { state: 'unknown' }; return { state: record.state, code: record.code, message: record.message, warning: record.warning ? { code: record.warning.code, fileName: record.warning.fileName } : null, fileName: record.fileName } } +function pendingAudioCleanups() { + return [...audioCleanupFailures].slice(0, MAX_GRANTS).map(([jobId, record]) => ({ jobId, code: 'AUDIO_TEMP_CLEANUP_FAILED', fileName: safeBaseName(record.temporary) })) +} +async function retryAudioCleanup(jobId) { + const id = typeof jobId === 'string' ? jobId : '' + const record = audioCleanupFailures.get(id) + if (!record) return { ok: false, code: 'AUDIO_CLEANUP_NOT_PENDING' } + return cleanupTemporary(id, record) +} +async function cancelAudio(jobId) { + const record = audioJobs.get(jobId) + if (!record) throw new Error('未知的音轨任务') + const cancel = record.job && (typeof record.job.quit === 'function' ? record.job.quit : typeof record.job.kill === 'function' ? record.job.kill : null) + if (!cancel) return { ok: false, code: 'CANCEL_UNAVAILABLE' } + if (record.state === 'canceled' || record.state === 'completed' || record.state === 'failed') return { ok: false, code: 'CANCEL_UNAVAILABLE' } + record.state = 'canceling' + await cancel.call(record.job) + return { ok: true } +} +async function startTranscription() { return { ok: false, code: 'TRANSCRIPTION_UNSUPPORTED', message: 'v0.1 不探测 PATH,也不执行 Python whisper 或 whisper.cpp。' } } +function invalidTool(message) { const error = new Error(message); error.code = 'INVALID_TOOL_INPUT'; throw error } +function validatePlainObject(input, allowed, label) { + if (!input || typeof input !== 'object' || Array.isArray(input)) invalidTool(label + '必须是对象。') + let prototype, keys + try { prototype = Object.getPrototypeOf(input); keys = Reflect.ownKeys(input) } catch { invalidTool(label + '结构无效。') } + if (prototype !== Object.prototype && prototype !== null) invalidTool(label + '原型无效。') + const values = Object.create(null) + for (const key of keys) { + if (typeof key !== 'string' || key === '__proto__' || key === 'prototype' || key === 'constructor' || !allowed.has(key)) invalidTool(label + '包含未允许字段。') + let descriptor + try { descriptor = Object.getOwnPropertyDescriptor(input, key) } catch { invalidTool(label + '字段无效。') } + if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) invalidTool(label + '只允许数据字段。') + values[key] = descriptor.value + } + return values +} +function validateFormat(value) { if (!['srt', 'vtt'].includes(value)) invalidTool('format 必须是 srt 或 vtt。'); return value } +function validatePage(input) { + const offset = input.offset === undefined ? 0 : input.offset + const limit = input.limit === undefined ? 100 : input.limit + if (!Number.isSafeInteger(offset) || offset < 0) invalidTool('offset 必须是非负安全整数。') + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_MCP_FINDINGS) invalidTool('limit 必须是 1—200 的安全整数。') + return { offset, limit } +} +function validateThresholds(value) { + if (value === undefined) return {} + value = validatePlainObject(value, new Set(['minDuration', 'maxDuration', 'maxCharsPerSecond']), 'thresholds') + const output = {} + if (value.minDuration !== undefined) { + if (!Number.isSafeInteger(value.minDuration) || value.minDuration < 0 || value.minDuration > 60000) invalidTool('minDuration 必须是 0—60000 的整数。') + output.minDuration = value.minDuration + } + if (value.maxDuration !== undefined) { + if (!Number.isSafeInteger(value.maxDuration) || value.maxDuration < 1 || value.maxDuration > 600000) invalidTool('maxDuration 必须是 1—600000 的整数。') + output.maxDuration = value.maxDuration + } + if (value.maxCharsPerSecond !== undefined) { + if (!Number.isFinite(value.maxCharsPerSecond) || value.maxCharsPerSecond <= 0 || value.maxCharsPerSecond > 1000) invalidTool('maxCharsPerSecond 必须大于 0 且不超过 1000。') + output.maxCharsPerSecond = value.maxCharsPerSecond + } + const min = output.minDuration === undefined ? 500 : output.minDuration + const max = output.maxDuration === undefined ? 8000 : output.maxDuration + if (max < min) invalidTool('maxDuration 不能小于 minDuration。') + return output +} +function validateInlineContent(value) { + if (typeof value !== 'string') invalidTool('content 必须是字符串。') + if (byteLength(value) > MAX_MCP_INLINE_BYTES) invalidTool('内联字幕不得超过 512 KiB UTF-8。') + return value +} +function summarizeAnalysis(result, page) { + let startMs = 0 + let endMs = 0 + if (result.cues.length) { + startMs = result.cues[0].start + endMs = result.cues[0].end + for (let index = 1; index < result.cues.length; index += 1) { + const cue = result.cues[index] + if (cue.start < startMs) startMs = cue.start + if (cue.end > endMs) endMs = cue.end + } + } + const byCode = Object.create(null) + for (const finding of result.findings) byCode[finding.code] = (byCode[finding.code] || 0) + 1 + const findings = result.findings.slice(page.offset, page.offset + page.limit).map((item) => ({ index: item.index, code: item.code })) + const hasMore = page.offset + findings.length < result.findings.length + return { + cueCount: result.cues.length, + duration: { startMs, endMs, spanMs: Math.max(0, endMs - startMs) }, + findings, + summary: { totalFindings: result.findings.length, byCode, offset: page.offset, limit: page.limit, returned: findings.length, hasMore, nextOffset: hasMore ? page.offset + findings.length : null, truncated: hasMore } + } +} +function validateAnalyzeToolInput(input, inline) { + const allowed = new Set(inline ? ['content', 'format', 'thresholds', 'offset', 'limit'] : ['thresholds', 'offset', 'limit']) + input = validatePlainObject(input, allowed, '分析参数') + return { ...(inline ? { content: validateInlineContent(input.content), format: validateFormat(input.format) } : {}), thresholds: validateThresholds(input.thresholds), ...validatePage(input) } +} +function validateTransformToolInput(input) { + input = validatePlainObject(input, new Set(['content', 'format', 'operation', 'milliseconds', 'speed', 'sourceFps', 'targetFps', 'outputFormat']), '转换参数') + const value = { content: validateInlineContent(input.content), format: validateFormat(input.format), operation: input.operation, outputFormat: input.outputFormat === undefined ? input.format : validateFormat(input.outputFormat) } + if (!['shift', 'speed', 'fps', 'convert'].includes(value.operation)) invalidTool('operation 无效。') + const allowedForOperation = { + shift: new Set(['content', 'format', 'operation', 'milliseconds', 'outputFormat']), + speed: new Set(['content', 'format', 'operation', 'speed', 'outputFormat']), + fps: new Set(['content', 'format', 'operation', 'sourceFps', 'targetFps', 'outputFormat']), + convert: new Set(['content', 'format', 'operation', 'outputFormat']) + }[value.operation] + if (Object.keys(input).some((key) => !allowedForOperation.has(key))) invalidTool('operation 包含不适用字段。') + if (value.operation === 'convert' && input.outputFormat === undefined) invalidTool('convert 必须显式提供 outputFormat。') + if (value.operation === 'shift') { + if (!Number.isSafeInteger(input.milliseconds) || Math.abs(input.milliseconds) > 86400000) invalidTool('milliseconds 必须是 ±86400000 内的整数。') + value.milliseconds = input.milliseconds + } else if (value.operation === 'speed') { + if (!Number.isFinite(input.speed) || input.speed < 0.1 || input.speed > 10) invalidTool('speed 必须在 0.1—10。') + value.speed = input.speed + } else if (value.operation === 'fps') { + if (!Number.isFinite(input.sourceFps) || input.sourceFps < 1 || input.sourceFps > 240 || !Number.isFinite(input.targetFps) || input.targetFps < 1 || input.targetFps > 240) invalidTool('帧率必须在 1—240。') + value.sourceFps = input.sourceFps; value.targetFps = input.targetFps + } + return value +} +function stableToolError(code, message) { const error = new Error(message); error.code = code; return error } +function boundedToolResponse(value) { + let size + try { size = Buffer.byteLength(JSON.stringify(value), 'utf8') } catch { size = Infinity } + if (size > MAX_MCP_RESPONSE_BYTES) throw stableToolError('MCP_RESPONSE_TOO_LARGE', 'MCP 响应超过 512 KiB,请缩小分页或改用界面处理。') + return value +} +function createToolHandlers() { + return Object.freeze({ + analyze(input) { + const value = validateAnalyzeToolInput(input, true) + try { return boundedToolResponse(summarizeAnalysis(analyze(value.content, value.format, value.thresholds), value)) } + catch (error) { if (error?.code === 'INVALID_TOOL_INPUT' || error?.code === 'MCP_RESPONSE_TOO_LARGE') throw error; throw stableToolError('SUBTITLE_ANALYSIS_FAILED', '字幕解析或分析失败,请检查格式与安全限制。') } + }, + transform(input) { + const value = validateTransformToolInput(input) + try { + const before = analyze(value.content, value.format) + const content = transform(value.content, value.format, { type: value.operation, milliseconds: value.milliseconds, speed: value.speed, sourceFps: value.sourceFps, targetFps: value.targetFps, format: value.outputFormat }) + if (byteLength(content) > MAX_MCP_INLINE_BYTES) invalidTool('转换结果超过 512 KiB UTF-8,请改用界面处理。') + const warnings = [] + if (value.format === 'vtt' && value.outputFormat === 'srt') warnings.push('VTT STYLE、REGION、NOTE 与 cue 设置不会写入 SRT。') + return boundedToolResponse({ outputFormat: value.outputFormat, content, cueCount: before.cues.length, warnings }) + } catch (error) { if (error?.code === 'INVALID_TOOL_INPUT' || error?.code === 'MCP_RESPONSE_TOO_LARGE') throw error; throw stableToolError('SUBTITLE_TRANSFORM_FAILED', '字幕解析或转换失败,请检查格式与安全限制。') } + }, + async analyzeApprovedFile(input) { + const value = validateAnalyzeToolInput(input, false) + if (!activeSubtitleGrantId) { const error = new Error('请先在插件界面选择一个字幕文件。'); error.code = 'UI_APPROVAL_REQUIRED'; throw error } + const epoch = sessionEpoch + const id = activeSubtitleGrantId + try { + const loaded = await readGrantedSubtitle(id, epoch) + const response = boundedToolResponse(summarizeAnalysis(analyze(loaded.content, loaded.format, value.thresholds), value)) + assertSessionEpoch(epoch) + if (activeSubtitleGrantId !== id) throw sessionExpired() + return response + } catch (error) { + revokeGrant(id) + if (error?.code === 'MCP_RESPONSE_TOO_LARGE') throw error + throw stableToolError('APPROVED_SUBTITLE_FAILED', '已授权字幕不可用或分析失败,请在插件界面重新选择。') + } + } + }) +} +function registerTools(api, handlers) { + if (!api || typeof api.registerTool !== 'function') return false + if (registeredHosts.has(api)) return false + let registered = 0 + for (const [name, handler] of [ + [TOOL_NAMES.analyze, (input) => handlers.analyze(input)], + [TOOL_NAMES.transform, (input) => handlers.transform(input)], + [TOOL_NAMES.analyzeApprovedFile, (input) => handlers.analyzeApprovedFile(input)] + ]) { + try { api.registerTool.call(api, name, handler); registered += 1 } catch {} + } + registeredHosts.add(api) + return registered > 0 +} +function registerLifecycle() { + const api = host() + if (typeof api.onPluginEnter === 'function') api.onPluginEnter(({ type, payload } = {}) => { if (type === 'files') void queueEntry(payload) }) + if (typeof api.onPluginOut === 'function') api.onPluginOut(clearSession) +} +registerLifecycle() +const toolHandlers = createToolHandlers() +registerTools(host(), toolHandlers) +window.subtitleWorkbench = Object.freeze({ consumeEntry, chooseInput, readGrantedSubtitle, analyze, transform, saveSubtitle, getCapabilities, startAudioExtract, audioJobStatus, pendingAudioCleanups, retryAudioCleanup, cancelAudio, startTranscription }) +module.exports = Object.freeze({ TOOL_NAMES, MAX_MCP_RESPONSE_BYTES, validateAnalyzeToolInput, validateTransformToolInput, createToolHandlers, registerTools, __test: { readHandleBounded, summarizeAnalysis, promoteAudio, assertReplaceableSubtitleOutput, clearSession, activeSubtitleGrantId: () => activeSubtitleGrantId, sessionEpoch: () => sessionEpoch, audioJobCount: () => audioJobs.size, pendingAudioCleanups, setAudioPromotionIo: (value) => { audioPromotionIo = value || null }, setAudioCleanupIo: (value) => { audioCleanupIo = value || null }, setFileValidationIo: (value) => { fileValidationIo = value || null } } }) diff --git a/plugins/subtitle-workbench/preload/subtitle-core.cjs b/plugins/subtitle-workbench/preload/subtitle-core.cjs new file mode 100644 index 00000000..44d3e9bf --- /dev/null +++ b/plugins/subtitle-workbench/preload/subtitle-core.cjs @@ -0,0 +1,28 @@ +'use strict' +const MAX_CUES = 20000 +function pad(value, width) { return String(Math.max(0, Math.floor(value))).padStart(width, '0') } +function parseTime(value) { const matched = String(value || '').trim().match(/^(?:(\d{1,3}):)?([0-5]\d):([0-5]\d)[,.](\d{1,3})$/); if (!matched) throw new Error('字幕时间格式无效:' + value); return (((Number(matched[1] || 0) * 3600) + Number(matched[2]) * 60 + Number(matched[3])) * 1000) + Number(matched[4].padEnd(3, '0')) } +function formatTime(milliseconds, separator) { const total = Math.max(0, Math.round(Number(milliseconds) || 0)); return pad(Math.floor(total / 3600000), 2) + ':' + pad(Math.floor(total % 3600000 / 60000), 2) + ':' + pad(Math.floor(total % 60000 / 1000), 2) + (separator || ',') + pad(total % 1000, 3) } +function normalizedLines(input) { return String(input || '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').split('\n') } +function attachVttMeta(cues, meta) { Object.defineProperty(cues, 'vttMeta', { value: meta, enumerable: false, configurable: true }); return cues } +function propagateVttMeta(source, target) { if (source && source.vttMeta) attachVttMeta(target, source.vttMeta); return target } +function parseSrt(input) { const blocks = normalizedLines(input).join('\n').trim().split(/\n{2,}/).filter(Boolean); if (blocks.length > MAX_CUES) throw new RangeError('字幕条目过多'); return blocks.map((block, position) => { const lines = block.split('\n'); const timeIndex = lines.findIndex(line => line.includes('-->')); if (timeIndex < 0) throw new Error('第 ' + (position + 1) + ' 条字幕缺少时间范围'); const range = lines[timeIndex].split('-->').map(part => part.trim()); if (range.length !== 2) throw new Error('字幕时间范围无效'); return { id: lines.slice(0, timeIndex).join(' ').trim() || String(position + 1), start: parseTime(range[0]), end: parseTime(range[1]), text: lines.slice(timeIndex + 1).join('\n').trim() } }) } +function parseVtt(input) { + const lines = normalizedLines(input); if (!/^WEBVTT(?:\s|$)/.test(lines[0] || '')) throw new Error('缺少 WEBVTT 文件头') + const separator = lines.indexOf(''); const headerEnd = separator < 0 ? 1 : separator; const header = lines.slice(0, headerEnd).join('\n'); const blocks = (separator < 0 ? [] : lines.slice(separator + 1).join('\n').trim().split(/\n{2,}/)).filter(Boolean) + const docs = []; const cues = []; const sequence = [] + for (const block of blocks) { + if (/^(?:NOTE(?:[ \t].*)?|STYLE|REGION)(?:\n|$)/.test(block)) { docs.push(block); sequence.push({ type: 'doc', value: block }); continue } + const cueLines = block.split('\n'); const timeIndex = cueLines.findIndex(line => line.includes('-->')); if (timeIndex < 0) throw new Error('第 ' + (cues.length + 1) + ' 条字幕缺少时间范围') + const pieces = cueLines[timeIndex].split('-->'); if (pieces.length !== 2) throw new Error('VTT 时间范围无效'); const right = pieces[1].trim().match(/^(\S+)(?:\s+(.*))?$/); if (!right) throw new Error('VTT 结束时间无效') + cues.push({ id: cueLines.slice(0, timeIndex).join(' ').trim() || null, start: parseTime(pieces[0].trim()), end: parseTime(right[1]), settings: right[2] || '', text: cueLines.slice(timeIndex + 1).join('\n').trim() }); sequence.push({ type: 'cue', index: cues.length - 1 }) + } + if (cues.length > MAX_CUES) throw new RangeError('字幕条目过多'); return attachVttMeta(cues, { header, docs, sequence }) +} +function parse(input, format) { return format === 'vtt' ? parseVtt(input) : parseSrt(input) } +function toSrt(cues) { return cues.map((cue, index) => (index + 1) + '\n' + formatTime(cue.start, ',') + ' --> ' + formatTime(cue.end, ',') + '\n' + String(cue.text || '').trim()).join('\n\n') + '\n' } +function toVtt(cues) { const meta = cues && cues.vttMeta; const header = meta && meta.header || 'WEBVTT'; const renderCue = cue => (cue.id ? String(cue.id).trim() + '\n' : '') + formatTime(cue.start, '.') + ' --> ' + formatTime(cue.end, '.') + (cue.settings ? ' ' + cue.settings : '') + '\n' + String(cue.text || '').trim(); const items = meta && meta.sequence ? meta.sequence.map(item => item.type === 'doc' ? item.value : renderCue(cues[item.index])).filter(Boolean) : cues.map(renderCue); return header + '\n\n' + items.join('\n\n') + '\n' } +function shift(cues, delta) { const next = cues.map(cue => { const duration = Math.max(0, cue.end - cue.start); const start = Math.max(0, cue.start + Number(delta || 0)); return { ...cue, start, end: Math.max(start, start + duration) } }); return propagateVttMeta(cues, next) } +function scale(cues, speed) { const factor = Number(speed); if (!Number.isFinite(factor) || factor <= 0 || factor > 100) throw new RangeError('速度倍率无效'); return propagateVttMeta(cues, cues.map(cue => ({ ...cue, start: Math.round(cue.start / factor), end: Math.round(cue.end / factor) }))) } +function qualityCheck(cues, options) { const min = Number.isFinite(options && options.minDuration) ? options.minDuration : 500; const max = Number.isFinite(options && options.maxDuration) ? options.maxDuration : 8000; const cps = Number.isFinite(options && options.maxCharsPerSecond) ? options.maxCharsPerSecond : 22; if (min < 0 || max <= 0 || cps <= 0 || max < min) throw new RangeError('质检阈值无效'); const findings = []; cues.forEach((cue,index) => { const duration = cue.end-cue.start; const text=String(cue.text||'').trim(); if(!text)findings.push({index,code:'EMPTY_TEXT'}); if(cue.endmax)findings.push({index,code:'TOO_LONG'}); if(duration>0&&text.replace(/\s/g,'').length/(duration/1000)>cps)findings.push({index,code:'FAST_READING'}); if(index&&cue.start MAX_DIST_BYTES) throw new Error(`${label} is ${bytes} bytes, exceeding the ${MAX_DIST_BYTES}-byte limit`) + return bytes +} diff --git a/plugins/subtitle-workbench/scripts/verify-dist.mjs b/plugins/subtitle-workbench/scripts/verify-dist.mjs new file mode 100644 index 00000000..3f55c923 --- /dev/null +++ b/plugins/subtitle-workbench/scripts/verify-dist.mjs @@ -0,0 +1,25 @@ +import { access, readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { assertDistSize, MAX_DIST_BYTES, measureDirectoryBytes } from './dist-size.mjs' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const dist = path.join(root, 'dist') +const manifest = JSON.parse(await readFile(path.join(dist, 'plugin.json'), 'utf8')) +if (manifest.development) throw new Error('dist manifest must not contain development') +const sourceManifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8')) +delete sourceManifest.development +if (JSON.stringify(sourceManifest) !== JSON.stringify(manifest)) throw new Error('dist manifest is stale') +for (const file of [manifest.main, manifest.logo, manifest.preload, 'preload/subtitle-core.cjs']) await access(path.join(dist, file)) +await access(path.join(dist, 'preload/whisper-runner.cjs')).then(() => { throw new Error('v0.1 must not package whisper runner') }, () => {}) +const declared = Object.keys(manifest.tools || {}).sort() +if (JSON.stringify(declared) !== JSON.stringify(['analyze', 'analyze_approved_file', 'transform'])) throw new Error('dist MCP tool declarations are incomplete') +const preload = await readFile(path.join(dist, manifest.preload), 'utf8') +for (const name of declared) if (!preload.includes(`'${name}'`)) throw new Error(`dist preload does not register ${name}`) +for (const file of ['README.md', 'preload/services.cjs', 'preload/subtitle-core.cjs']) { + if (await readFile(path.join(root, file), 'utf8') !== await readFile(path.join(dist, file), 'utf8')) throw new Error(`dist ${file} is stale`) +} +const distBytes = await measureDirectoryBytes(dist) +console.log(`subtitle-workbench dist size: ${distBytes} bytes (limit ${MAX_DIST_BYTES} bytes)`) +assertDistSize(distBytes, 'subtitle-workbench dist') +console.log('Verified subtitle-workbench dist') diff --git a/plugins/subtitle-workbench/tests/dist-size.test.cjs b/plugins/subtitle-workbench/tests/dist-size.test.cjs new file mode 100644 index 00000000..f7ceb981 --- /dev/null +++ b/plugins/subtitle-workbench/tests/dist-size.test.cjs @@ -0,0 +1,22 @@ +'use strict' +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs/promises') +const os = require('node:os') +const path = require('node:path') + +test('dist size gate recursively counts exact bytes against the decimal 14.5 MB limit', async () => { + const { assertDistSize, MAX_DIST_BYTES, measureDirectoryBytes } = await import('../scripts/dist-size.mjs') + assert.equal(MAX_DIST_BYTES, 14_500_000) + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subtitle-dist-size-')) + try { + await fs.mkdir(path.join(directory, 'nested')) + await fs.writeFile(path.join(directory, 'root.bin'), Buffer.alloc(7)) + await fs.writeFile(path.join(directory, 'nested', 'child.bin'), Buffer.alloc(11)) + assert.equal(await measureDirectoryBytes(directory), 18) + assert.equal(assertDistSize(MAX_DIST_BYTES), MAX_DIST_BYTES) + assert.throws(() => assertDistSize(MAX_DIST_BYTES + 1), /14500001 bytes.*14500000-byte limit/) + } finally { + await fs.rm(directory, { recursive: true, force: true }) + } +}) diff --git a/plugins/subtitle-workbench/tests/services.test.cjs b/plugins/subtitle-workbench/tests/services.test.cjs new file mode 100644 index 00000000..0fcdf2c7 --- /dev/null +++ b/plugins/subtitle-workbench/tests/services.test.cjs @@ -0,0 +1,716 @@ +'use strict' +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs/promises') +const os = require('node:os') +const path = require('node:path') +const { spawnSync } = require('node:child_process') +function loadWithHost(ztools) { + global.window = { ztools } + delete require.cache[require.resolve('../preload/services.cjs')] + return require('../preload/services.cjs') +} +function manyFindingsSrt(count = 150) { + return Array.from({ length: count }, (_, index) => `${index + 1}\n00:00:00,000 --> 00:00:00,100\nFast subtitle ${index}`).join('\n\n') + '\n' +} +test('manifest declarations and preload registrations use the same short MCP names', () => { + const calls = new Map() + const service = loadWithHost({ registerTool(name, handler) { calls.set(name, handler) } }) + const manifest = require('../plugin.json') + assert.deepEqual(Object.keys(manifest.tools).sort(), Object.values(service.TOOL_NAMES).sort()) + assert.deepEqual([...calls.keys()].sort(), Object.values(service.TOOL_NAMES).sort()) + assert.ok([...calls.values()].every((handler) => typeof handler === 'function')) + delete global.window +}) +test('legacy hosts without registerTool keep the renderer bridge', () => { + loadWithHost({}) + assert.equal(typeof global.window.subtitleWorkbench.analyze, 'function') + assert.equal(typeof global.window.subtitleWorkbench.transform, 'function') + delete global.window +}) +test('one registerTool failure does not block the UI or remaining MCP tools', () => { + const registered = [] + const service = loadWithHost({ registerTool(name) { if (name === 'analyze') throw new Error('one failure'); registered.push(name) } }) + assert.deepEqual(registered.sort(), [service.TOOL_NAMES.analyzeApprovedFile, service.TOOL_NAMES.transform].sort()) + assert.equal(typeof global.window.subtitleWorkbench.analyze, 'function') + delete global.window +}) +test('inline MCP handlers execute core analysis and transformations with bounded output', async () => { + const calls = new Map() + const service = loadWithHost({ registerTool(name, handler) { calls.set(name, handler) } }) + const source = '1\n00:00:01,000 --> 00:00:01,100\nFast text here\n\n2\n00:00:02,000 --> 00:00:03,000\nOK\n' + const analyzed = await calls.get(service.TOOL_NAMES.analyze)({ content: source, format: 'srt', thresholds: { minDuration: 500 }, offset: 0, limit: 1 }) + assert.equal(analyzed.cueCount, 2) + assert.equal(analyzed.findings.length, 1) + assert.ok(analyzed.summary.totalFindings >= 1) + assert.equal(analyzed.duration.endMs, 3000) + const shifted = await calls.get(service.TOOL_NAMES.transform)({ content: source, format: 'srt', operation: 'shift', milliseconds: 500, outputFormat: 'vtt' }) + assert.equal(shifted.outputFormat, 'vtt') + assert.equal(shifted.cueCount, 2) + assert.match(shifted.content, /^WEBVTT/) + assert.match(shifted.content, /00:00:01\.500/) + assert.ok(Buffer.byteLength(JSON.stringify(analyzed), 'utf8') <= service.MAX_MCP_RESPONSE_BYTES) + assert.ok(Buffer.byteLength(JSON.stringify(shifted), 'utf8') <= service.MAX_MCP_RESPONSE_BYTES) + delete global.window +}) +test('analysis summary handles very large cue arrays without argument spreading', () => { + const service = loadWithHost({}) + const cues = Array.from({ length: 150000 }, (_, index) => ({ start: index + 10, end: index + 1010 })) + const result = service.__test.summarizeAnalysis({ cues, findings: [] }, { offset: 0, limit: 100 }) + assert.equal(result.cueCount, 150000) + assert.deepEqual(result.duration, { startMs: 10, endMs: 151009, spanMs: 150999 }) + delete global.window +}) +test('subtitle handle reads enforce the approved byte count before allocating more input', async () => { + const service = loadWithHost({}) + const fakeHandle = (value) => { + let cursor = 0 + return { async read(target, offset, length) { const bytesRead = Math.min(length, value.length - cursor); if (bytesRead > 0) value.copy(target, offset, cursor, cursor + bytesRead); cursor += bytesRead; return { bytesRead } } } + } + assert.equal((await service.__test.readHandleBounded(fakeHandle(Buffer.from('abc')), 3)).toString(), 'abc') + await assert.rejects(service.__test.readHandleBounded(fakeHandle(Buffer.from('abcd')), 3), /大小限制/) + delete global.window +}) +test('registered MCP analysis paginates within budget and transform rejects JSON escape inflation', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subtitle-mcp-budget-')) + const approvedFile = path.join(directory, 'many-findings.srt') + const source = manyFindingsSrt() + await fs.writeFile(approvedFile, source) + const calls = new Map() + const service = loadWithHost({ showOpenDialog: async () => [approvedFile], registerTool(name, handler) { calls.set(name, handler) } }) + await global.window.subtitleWorkbench.chooseInput() + async function collectPages(invoke) { + let offset = 0; let total = null; const evidence = [] + while (true) { + const result = await invoke(offset) + assert.ok(Buffer.byteLength(JSON.stringify(result), 'utf8') <= service.MAX_MCP_RESPONSE_BYTES) + assert.equal(result.summary.offset, offset) + assert.equal(result.summary.returned, result.findings.length) + assert.ok(result.findings.length <= 200) + if (total === null) total = result.summary.totalFindings + else assert.equal(result.summary.totalFindings, total) + evidence.push(...result.findings.map((item) => `${item.index}:${item.code}`)) + if (!result.summary.hasMore) { assert.equal(result.summary.nextOffset, null); break } + assert.equal(result.summary.nextOffset, offset + result.summary.returned) + assert.ok(result.summary.nextOffset > offset) + offset = result.summary.nextOffset + } + assert.equal(evidence.length, total) + assert.equal(new Set(evidence).size, evidence.length) + return { total, evidence } + } + const inline = await collectPages((offset) => calls.get(service.TOOL_NAMES.analyze)({ content: source, format: 'srt', offset, limit: 200 })) + const approved = await collectPages((offset) => calls.get(service.TOOL_NAMES.analyzeApprovedFile)({ offset, limit: 200 })) + assert.equal(approved.total, inline.total) + assert.deepEqual(approved.evidence, inline.evidence) + const inflatedSource = `1\n00:00:01,000 --> 00:00:02,000\n${'\u0001'.repeat(520000)}\n` + assert.ok(Buffer.byteLength(inflatedSource, 'utf8') < 512 * 1024) + const rawContent = global.window.subtitleWorkbench.transform(inflatedSource, 'srt', { type: 'convert', format: 'srt' }) + assert.ok(Buffer.byteLength(JSON.stringify({ outputFormat: 'srt', content: rawContent, cueCount: 1, warnings: [] }), 'utf8') > 3000000) + assert.throws(() => calls.get(service.TOOL_NAMES.transform)({ content: inflatedSource, format: 'srt', operation: 'convert', outputFormat: 'srt' }), (error) => error.code === 'MCP_RESPONSE_TOO_LARGE' && !error.message.includes('\u0001')) + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('MCP validators reject unknown, hostile, oversized, and operation-mismatched input before parsing', async () => { + const calls = new Map() + const service = loadWithHost({ registerTool(name, handler) { calls.set(name, handler) } }) + const analyzeTool = calls.get(service.TOOL_NAMES.analyze) + assert.throws(() => analyzeTool({ content: '', format: 'srt', command: 'write' }), (error) => error.code === 'INVALID_TOOL_INPUT') + const hostile = JSON.parse('{"content":"","format":"srt","__proto__":{"polluted":true}}') + assert.throws(() => analyzeTool(hostile), (error) => error.code === 'INVALID_TOOL_INPUT') + assert.throws(() => analyzeTool({ content: '你'.repeat(200000), format: 'srt' }), /512 KiB/) + assert.throws(() => calls.get(service.TOOL_NAMES.transform)({ content: '', format: 'srt', operation: 'convert', speed: 2 }), /不适用字段/) + assert.throws(() => calls.get(service.TOOL_NAMES.transform)({ content: '', format: 'srt', operation: 'convert' }), /outputFormat/) + assert.throws(() => analyzeTool({ content: '', format: 'srt', thresholds: { minDuration: 1000, maxDuration: 999 } }), /不能小于/) + let getterCalled = false + const accessor = { format: 'srt' } + Object.defineProperty(accessor, 'content', { enumerable: true, get() { getterCalled = true; return '' } }) + assert.throws(() => analyzeTool(accessor), (error) => error.code === 'INVALID_TOOL_INPUT') + assert.equal(getterCalled, false) + const symbolInput = { content: '', format: 'srt' }; symbolInput[Symbol('hidden')] = true + assert.throws(() => analyzeTool(symbolInput), (error) => error.code === 'INVALID_TOOL_INPUT') + delete global.window +}) +test('approved-file MCP handler uses only the latest subtitle chosen in the UI', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subtitle-mcp-approved-')) + const file = path.join(directory, 'approved.srt') + await fs.writeFile(file, '1\n00:00:01,000 --> 00:00:02,000\nApproved\n') + const calls = new Map() + const service = loadWithHost({ showOpenDialog: async () => [file], registerTool(name, handler) { calls.set(name, handler) } }) + await assert.rejects(() => calls.get(service.TOOL_NAMES.analyzeApprovedFile)({}), (error) => error.code === 'UI_APPROVAL_REQUIRED') + const originalOpen = fs.open; const opened = [] + fs.open = async (...args) => { const handle = await originalOpen(...args); opened.push(handle); return handle } + let result + try { + await global.window.subtitleWorkbench.chooseInput() + result = await calls.get(service.TOOL_NAMES.analyzeApprovedFile)({ offset: 0, limit: 10 }) + } + finally { fs.open = originalOpen } + assert.equal(result.cueCount, 1) + assert.equal(result.summary.totalFindings, 0) + assert.ok(Buffer.byteLength(JSON.stringify(result), 'utf8') <= service.MAX_MCP_RESPONSE_BYTES) + assert.equal(opened.length, 2) + for (const handle of opened) await assert.rejects(handle.stat(), (error) => error.code === 'EBADF') + await assert.rejects(() => calls.get(service.TOOL_NAMES.analyzeApprovedFile)({ path: file }), (error) => error.code === 'INVALID_TOOL_INPUT') + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('delayed file dialog cannot create a subtitle grant after plugin exit', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subtitle-dialog-exit-')) + const file = path.join(directory, 'late.srt') + await fs.writeFile(file, '1\n00:00:01,000 --> 00:00:02,000\nLate\n') + let resolveDialog, onOut + const handlers = new Map() + const service = loadWithHost({ + showOpenDialog: () => new Promise((resolve) => { resolveDialog = resolve }), + onPluginOut(callback) { onOut = callback }, + registerTool(name, handler) { handlers.set(name, handler) } + }) + const pending = global.window.subtitleWorkbench.chooseInput() + onOut() + resolveDialog([file]) + assert.deepEqual(await pending, { ok: false, code: 'SESSION_EXPIRED' }) + assert.equal(service.__test.activeSubtitleGrantId(), null) + await assert.rejects(handlers.get(service.TOOL_NAMES.analyzeApprovedFile)({}), { code: 'UI_APPROVAL_REQUIRED' }) + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('delayed entry path cannot enqueue or activate a grant after plugin exit', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subtitle-entry-exit-')) + const file = path.join(directory, 'late.srt') + await fs.writeFile(file, '1\n00:00:01,000 --> 00:00:02,000\nLate\n') + let resolvePath, onEnter, onOut + const handlers = new Map() + const service = loadWithHost({ + getPathForFile: () => new Promise((resolve) => { resolvePath = resolve }), + onPluginEnter(callback) { onEnter = callback }, + onPluginOut(callback) { onOut = callback }, + registerTool(name, handler) { handlers.set(name, handler) } + }) + onEnter({ type: 'files', payload: [{}] }) + onOut() + resolvePath(file) + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(global.window.subtitleWorkbench.consumeEntry(), { ok: false, code: 'NO_ENTRY' }) + assert.equal(service.__test.activeSubtitleGrantId(), null) + await assert.rejects(handlers.get(service.TOOL_NAMES.analyzeApprovedFile)({}), { code: 'UI_APPROVAL_REQUIRED' }) + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('approved-file MCP fails closed when plugin exit occurs during handle read', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subtitle-mcp-exit-read-')) + const file = path.join(directory, 'approved.srt') + await fs.writeFile(file, '1\n00:00:01,000 --> 00:00:02,000\nApproved\n') + let onOut + const handlers = new Map() + const service = loadWithHost({ showOpenDialog: async () => [file], onPluginOut(callback) { onOut = callback }, registerTool(name, handler) { handlers.set(name, handler) } }) + await global.window.subtitleWorkbench.chooseInput() + const originalOpen = fs.open + let releaseRead, announceRead + const readStarted = new Promise((resolve) => { announceRead = resolve }) + const readGate = new Promise((resolve) => { releaseRead = resolve }) + fs.open = async (...args) => { + const handle = await originalOpen(...args) + return { + stat: (...statArgs) => handle.stat(...statArgs), + async read(...readArgs) { announceRead(); await readGate; return handle.read(...readArgs) }, + close: () => handle.close() + } + } + try { + const pending = handlers.get(service.TOOL_NAMES.analyzeApprovedFile)({}) + await readStarted + onOut() + releaseRead() + await assert.rejects(pending, (error) => error.code === 'APPROVED_SUBTITLE_FAILED') + assert.equal(service.__test.activeSubtitleGrantId(), null) + } finally { + fs.open = originalOpen + delete global.window + await fs.rm(directory, { recursive: true, force: true }) + } +}) +test('approved-file analysis never calls unbounded readFile when the selected file grows', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subtitle-mcp-growth-')) + const file = path.join(directory, 'growth.srt') + const source = Buffer.from('1\n00:00:01,000 --> 00:00:02,000\nApproved\n') + await fs.writeFile(file, source) + const calls = new Map() + const service = loadWithHost({ showOpenDialog: async () => [file], registerTool(name, handler) { calls.set(name, handler) } }) + await global.window.subtitleWorkbench.chooseInput() + const originalOpen = fs.open + let readFileCalled = false + fs.open = async (...args) => { + const real = await originalOpen(...args) + const expanded = Buffer.concat([source, Buffer.from('x')]) + let cursor = 0 + return { + stat: (...statArgs) => real.stat(...statArgs), + async read(target, offset, length) { const bytesRead = Math.min(length, expanded.length - cursor); if (bytesRead > 0) expanded.copy(target, offset, cursor, cursor + bytesRead); cursor += bytesRead; return { bytesRead } }, + async readFile() { readFileCalled = true; return expanded }, + close: () => real.close() + } + } + try { + await assert.rejects(calls.get(service.TOOL_NAMES.analyzeApprovedFile)({}), (error) => error.code === 'APPROVED_SUBTITLE_FAILED') + assert.equal(readFileCalled, false) + } finally { + fs.open = originalOpen + delete global.window + await fs.rm(directory, { recursive: true, force: true }) + } +}) +test('approved-file MCP revokes a same-inode rewrite even when size and mtime are restored', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subtitle-mcp-rewrite-')) + const file = path.join(directory, 'rewritten.srt') + const original = '1\n00:00:01,000 --> 00:00:02,000\nORIGINAL\n' + const replacement = '1\n00:00:01,000 --> 00:00:02,000\nREPLACED\n' + assert.equal(Buffer.byteLength(original), Buffer.byteLength(replacement)) + await fs.writeFile(file, original) + const fixedSeconds = 1700000000 + await fs.utimes(file, fixedSeconds, fixedSeconds) + const before = await fs.stat(file) + const calls = new Map() + const service = loadWithHost({ showOpenDialog: async () => [file], registerTool(name, handler) { calls.set(name, handler) } }) + await global.window.subtitleWorkbench.chooseInput() + await new Promise((resolve) => setTimeout(resolve, 20)) + await fs.writeFile(file, replacement) + await fs.utimes(file, fixedSeconds, fixedSeconds) + const after = await fs.stat(file) + assert.equal(after.dev, before.dev) + assert.equal(after.ino, before.ino) + assert.equal(after.size, before.size) + assert.equal(after.mtimeMs, before.mtimeMs) + assert.notEqual(after.ctimeMs, before.ctimeMs) + const originalLstat = fs.lstat; const originalOpen = fs.open; let opened + fs.lstat = async (...args) => { const stat = await originalLstat(...args); stat.ctimeMs = before.ctimeMs; return stat } + fs.open = async (...args) => { + const handle = await originalOpen(...args); opened = handle + return { + async stat() { const stat = await handle.stat(); stat.ctimeMs = before.ctimeMs; return stat }, + read: (...readArgs) => handle.read(...readArgs), + close: () => handle.close() + } + } + try { + await assert.rejects(calls.get(service.TOOL_NAMES.analyzeApprovedFile)({ offset: 0, limit: 10 }), (error) => { + assert.equal(error.code, 'APPROVED_SUBTITLE_FAILED') + assert.equal(error.message.includes(directory), false) + assert.equal(error.message.includes(file), false) + assert.equal(error.message.includes('REPLACED'), false) + return true + }) + } finally { fs.lstat = originalLstat; fs.open = originalOpen } + await assert.rejects(opened.stat(), (error) => error.code === 'EBADF') + assert.equal(service.__test.activeSubtitleGrantId(), null) + await assert.rejects(calls.get(service.TOOL_NAMES.analyzeApprovedFile)({}), (error) => error.code === 'UI_APPROVAL_REQUIRED') + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('approved-file MCP failures revoke the grant and never expose a deleted absolute path', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subtitle-mcp-deleted-')) + const file = path.join(directory, 'deleted.srt') + await fs.writeFile(file, '1\n00:00:01,000 --> 00:00:02,000\nDeleted\n') + const calls = new Map() + const service = loadWithHost({ showOpenDialog: async () => [file], registerTool(name, handler) { calls.set(name, handler) } }) + await global.window.subtitleWorkbench.chooseInput() + await fs.rm(file) + await assert.rejects(calls.get(service.TOOL_NAMES.analyzeApprovedFile)({}), (error) => { + assert.equal(error.code, 'APPROVED_SUBTITLE_FAILED') + assert.equal(error.message.includes(directory), false) + assert.equal(error.message.includes(file), false) + return true + }) + assert.equal(service.__test.activeSubtitleGrantId(), null) + await assert.rejects(calls.get(service.TOOL_NAMES.analyzeApprovedFile)({}), (error) => error.code === 'UI_APPROVAL_REQUIRED') + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('renderer bridge is narrow and does not expose raw executable or path operations', async () => { + global.window = { ztools: {} } + delete require.cache[require.resolve('../preload/services.cjs')] + require('../preload/services.cjs') + const api = global.window.subtitleWorkbench + assert.equal(typeof api.consumeEntry, 'function') + assert.equal(typeof api.startAudioExtract, 'function') + assert.equal(api.runWhisper, undefined) + assert.equal(api.extractAudio, undefined) + assert.equal(api.onPluginEnter, undefined) + const result = await api.startTranscription() + assert.equal(result.code, 'TRANSCRIPTION_UNSUPPORTED') + delete global.window +}) +test('showOpenDialog accepts official string array result', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subtitle-dialog-')); const file = path.join(directory, 'one.srt') + await fs.writeFile(file, '1\n00:00:01,000 --> 00:00:02,000\nHi\n') + global.window = { ztools: { showOpenDialog: async () => [file] } } + delete require.cache[require.resolve('../preload/services.cjs')] + require('../preload/services.cjs') + const result = await global.window.subtitleWorkbench.chooseInput() + assert.equal(result.ok, true); assert.equal(result.file.name, 'one.srt') + delete global.window; await fs.rm(directory, { recursive: true, force: true }) +}) +test('VTT transform keeps its input format by default', () => { + global.window = { ztools: {} } + delete require.cache[require.resolve('../preload/services.cjs')] + require('../preload/services.cjs') + const value = global.window.subtitleWorkbench.transform('WEBVTT\n\n00:01.000 --> 00:02.000\nHi\n', 'vtt', { type: 'shift', milliseconds: 500 }) + assert.match(value, /^WEBVTT/); assert.match(value, /00:00:01\.500/) + delete global.window +}) +test('subtitle save replaces regular files but rejects directories and special stat entries', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subtitle-save-target-')) + const regular = path.join(directory, 'regular.srt') + await fs.writeFile(regular, 'old') + let target = regular + const service = loadWithHost({ showSaveDialog: async () => target }) + const saved = await global.window.subtitleWorkbench.saveSubtitle('new', 'srt', 'regular.srt') + assert.deepEqual(saved, { canceled: false, fileName: 'regular.srt' }) + assert.equal(await fs.readFile(regular, 'utf8'), 'new') + target = directory + await assert.rejects(global.window.subtitleWorkbench.saveSubtitle('blocked', 'srt', 'blocked.srt'), /普通文件/) + assert.throws(() => service.__test.assertReplaceableSubtitleOutput({ isSymbolicLink: () => false, isFile: () => false }), /特殊文件/) + assert.throws(() => service.__test.assertReplaceableSubtitleOutput({ isSymbolicLink: () => true, isFile: () => false }), /符号链接/) + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('plugin exit invalidates a pending subtitle save dialog before any file is written', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subtitle-save-session-')) + const destination = path.join(directory, 'after-exit.srt') + let onOut + let resolveDialog + const dialog = new Promise((resolve) => { resolveDialog = resolve }) + loadWithHost({ + showSaveDialog: async () => dialog, + onPluginOut(callback) { onOut = callback } + }) + const pending = global.window.subtitleWorkbench.saveSubtitle('new', 'srt', 'after-exit.srt') + onOut() + resolveDialog(destination) + await assert.rejects(pending, (error) => error?.code === 'SESSION_EXPIRED') + await assert.rejects(fs.lstat(destination), { code: 'ENOENT' }) + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('subtitle save maps filesystem failures to a stable path-free error', async () => { + const secretDestination = path.join(os.tmpdir(), 'subtitle-secret-parent-does-not-exist', 'very-secret-name.srt') + loadWithHost({ showSaveDialog: async () => secretDestination }) + await assert.rejects(global.window.subtitleWorkbench.saveSubtitle('new', 'srt', 'secret.srt'), (error) => { + assert.equal(error?.code, 'SUBTITLE_SAVE_FAILED') + assert.equal(error.message.includes(secretDestination), false) + assert.equal(error.message.includes('very-secret-name.srt'), false) + return true + }) + delete global.window +}) +test('plugin exit during backup cleanup does not reject an already committed subtitle save', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subtitle-save-commit-')) + const destination = path.join(directory, 'target.srt') + await fs.writeFile(destination, 'old') + let onOut + let reachedCleanup + let resumeCleanup + const cleanupReached = new Promise((resolve) => { reachedCleanup = resolve }) + const cleanupResume = new Promise((resolve) => { resumeCleanup = resolve }) + const originalRm = fs.rm + fs.rm = async function delayedBackupRemoval(candidate, options) { + if (String(candidate).endsWith('.backup')) { + reachedCleanup() + await cleanupResume + } + return originalRm.call(this, candidate, options) + } + try { + loadWithHost({ + showSaveDialog: async () => destination, + onPluginOut(callback) { onOut = callback } + }) + const pending = global.window.subtitleWorkbench.saveSubtitle('new', 'srt', 'target.srt') + await cleanupReached + onOut() + resumeCleanup() + assert.deepEqual(await pending, { canceled: false, fileName: 'target.srt' }) + assert.equal(await fs.readFile(destination, 'utf8'), 'new') + } finally { + fs.rm = originalRm + delete global.window + await fs.rm(directory, { recursive: true, force: true }) + } +}) +test('subtitle save rolls back every pre-commit filesystem stage when the plugin exits', async () => { + const scenarios = [ + { name: 'target-check', method: 'lstat', existing: true, match: (args, destination) => args[0] === destination }, + { name: 'temporary-write', method: 'writeFile', existing: true, match: (args) => String(args[0]).endsWith('.tmp') }, + { name: 'backup-move', method: 'rename', existing: true, match: (args, destination) => args[0] === destination && String(args[1]).endsWith('.backup') }, + { name: 'final-move-existing', method: 'rename', existing: true, match: (args, destination) => String(args[0]).endsWith('.tmp') && args[1] === destination }, + { name: 'final-move-new', method: 'rename', existing: false, match: (args, destination) => String(args[0]).endsWith('.tmp') && args[1] === destination } + ] + for (const scenario of scenarios) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), `subtitle-save-${scenario.name}-`)) + const destination = path.join(directory, 'target.srt') + if (scenario.existing) await fs.writeFile(destination, 'old') + let onOut + let stageReached + let resumeStage + const reached = new Promise((resolve) => { stageReached = resolve }) + const resume = new Promise((resolve) => { resumeStage = resolve }) + const original = fs[scenario.method] + let intercepted = false + fs[scenario.method] = async function delayedStage(...args) { + const result = await original.apply(this, args) + if (!intercepted && scenario.match(args, destination)) { + intercepted = true + stageReached() + await resume + } + return result + } + try { + loadWithHost({ + showSaveDialog: async () => destination, + onPluginOut(callback) { onOut = callback } + }) + const pending = global.window.subtitleWorkbench.saveSubtitle('new', 'srt', 'target.srt') + await reached + onOut() + resumeStage() + await assert.rejects(pending, (error) => error?.code === 'SESSION_EXPIRED') + if (scenario.existing) assert.equal(await fs.readFile(destination, 'utf8'), 'old') + else await assert.rejects(fs.lstat(destination), { code: 'ENOENT' }) + assert.deepEqual((await fs.readdir(directory)).filter((entry) => entry.includes('.ztools-')), []) + } finally { + fs[scenario.method] = original + delete global.window + await fs.rm(directory, { recursive: true, force: true }) + } + } +}) +test('subtitle save never replaces a POSIX FIFO', { skip: process.platform === 'win32' }, async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subtitle-save-fifo-')) + const fifo = path.join(directory, 'target.srt') + const created = spawnSync('mkfifo', [fifo], { encoding: 'utf8' }) + assert.equal(created.status, 0, created.stderr) + loadWithHost({ showSaveDialog: async () => fifo }) + await assert.rejects(global.window.subtitleWorkbench.saveSubtitle('blocked', 'srt', 'target.srt'), /普通文件/) + assert.equal((await fs.lstat(fifo)).isFIFO(), true) + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('audio promotion replaces a final only after temp success', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'audio-promote-')); const finalFile = path.join(directory, 'final.wav'); const temporary = path.join(directory, '.final.tmp.wav') + await fs.writeFile(finalFile, 'old'); await fs.writeFile(temporary, 'new') + global.window = { ztools: {} }; delete require.cache[require.resolve('../preload/services.cjs')] + const service = require('../preload/services.cjs') + const result = await service.__test.promoteAudio(temporary, finalFile) + assert.equal(result.warning, null) + assert.equal(await fs.readFile(finalFile, 'utf8'), 'new'); await fs.rm(directory, { recursive: true, force: true }); delete global.window +}) +test('audio promotion restores old final when promote rename fails', async () => { + const files = new Map([['final.wav', 'old'], ['temp.wav', 'new']]); const io = { + async lstat() { return { isSymbolicLink: () => false, isFile: () => true } }, + async rename(from, to) { if (from === 'final.wav') { files.set('backup', files.get('final.wav')); files.delete('final.wav'); return } if (from === 'temp.wav' && to === 'final.wav') throw new Error('promote failed'); if (from.startsWith('final.wav.ztools-backup-') && to === 'final.wav') { files.set('final.wav', files.get('backup')); files.delete('backup') } }, + async rm() {} + } + global.window = { ztools: {} }; delete require.cache[require.resolve('../preload/services.cjs')] + const service = require('../preload/services.cjs') + await assert.rejects(service.__test.promoteAudio('temp.wav', 'final.wav', io), (error) => error.code === 'AUDIO_PROMOTE_FAILED' && error.fileName === 'final.wav' && !error.message.includes('promote failed')) + assert.equal(files.get('final.wav'), 'old'); delete global.window +}) +test('audio promotion rejects directories and special entries before renaming them', async () => { + const service = loadWithHost({}) + for (const kind of ['directory', 'special']) { + let renameCalled = false + const io = { async lstat() { return { isSymbolicLink: () => false, isFile: () => false, isDirectory: () => kind === 'directory' } }, async rename() { renameCalled = true }, async rm() {} } + await assert.rejects(service.__test.promoteAudio('temp.wav', 'final.wav', io), (error) => error.code === 'AUDIO_OUTPUT_NOT_FILE' && error.fileName === 'final.wav') + assert.equal(renameCalled, false) + } + delete global.window +}) +test('audio promotion reports a stable restore failure without exposing paths', async () => { + const service = loadWithHost({}); let renameCalls = 0 + const io = { + async lstat() { return { isSymbolicLink: () => false, isFile: () => true } }, + async rename() { renameCalls += 1; if (renameCalls > 1) throw new Error('restore failed at /private/secret/final.wav') }, + async rm() {} + } + await assert.rejects(service.__test.promoteAudio('temp.wav', '/private/secret/final.wav', io), (error) => { + assert.equal(error.code, 'AUDIO_BACKUP_RESTORE_FAILED') + assert.match(error.fileName, /^final\.wav\.ztools-backup-/) + assert.equal(error.message.includes('/private/secret'), false) + return true + }) + assert.equal(renameCalls, 3) + delete global.window +}) +test('audio cancel uses PromiseLike kill and cleans the temporary WAV after rejection', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'audio-cancel-')); const input = path.join(directory, 'input.wav'); const output = path.join(directory, 'output.wav'); await fs.writeFile(input, 'media') + let rejectJob, killed = false, argv; const job = new Promise((resolve, reject) => { rejectJob = reject }); job.kill = () => { killed = true } + global.window = { ztools: { showOpenDialog: async () => [input], showSaveDialog: async () => output, runFFmpeg: args => { argv = args; return job } } } + delete require.cache[require.resolve('../preload/services.cjs')] + require('../preload/services.cjs') + const selected = await global.window.subtitleWorkbench.chooseInput(); const started = await global.window.subtitleWorkbench.startAudioExtract(selected.file.grantId) + assert.equal(argv[0], '-n'); assert.ok(!argv.includes('-y')); assert.match(argv.at(-1), /\.wav$/) + await global.window.subtitleWorkbench.cancelAudio(started.jobId); assert.equal(killed, true); rejectJob(new Error('killed')); await new Promise(resolve => setImmediate(resolve)) + assert.equal(global.window.subtitleWorkbench.audioJobStatus(started.jobId).state, 'canceled'); assert.deepEqual((await fs.readdir(directory)).filter(name => name.includes('.ztools-')), []) + delete global.window; await fs.rm(directory, { recursive: true, force: true }) +}) +test('audio cancellation stays fail closed when the host cancel method rejects', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'audio-cancel-reject-')); const input = path.join(directory, 'input.wav'); const output = path.join(directory, 'output.wav'); await fs.writeFile(input, 'media'); await fs.writeFile(output, 'old') + let resolveJob; const job = new Promise(resolve => { resolveJob = resolve }); job.kill = async () => { throw new Error('cancel failed') } + global.window = { ztools: { showOpenDialog: async () => [input], showSaveDialog: async () => output, runFFmpeg: () => job } } + delete require.cache[require.resolve('../preload/services.cjs')] + require('../preload/services.cjs') + const selected = await global.window.subtitleWorkbench.chooseInput(); const started = await global.window.subtitleWorkbench.startAudioExtract(selected.file.grantId) + await assert.rejects(global.window.subtitleWorkbench.cancelAudio(started.jobId), /cancel failed/) + assert.equal(global.window.subtitleWorkbench.audioJobStatus(started.jobId).state, 'canceling') + resolveJob(); await new Promise(resolve => setImmediate(resolve)); await new Promise(resolve => setImmediate(resolve)) + assert.equal(global.window.subtitleWorkbench.audioJobStatus(started.jobId).state, 'canceled') + assert.equal(await fs.readFile(output, 'utf8'), 'old') + assert.deepEqual((await fs.readdir(directory)).filter(name => name.includes('.ztools-')), []) + delete global.window; await fs.rm(directory, { recursive: true, force: true }) +}) +test('temporary audio cleanup failure is path-free, visible, and retryable', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'audio-temp-cleanup-')) + const input = path.join(directory, 'input.wav'); const output = path.join(directory, 'output.wav') + await fs.writeFile(input, 'media') + let rejectJob, temporary, cleanupCalls = 0 + const job = new Promise((resolve, reject) => { rejectJob = reject }) + const service = loadWithHost({ showOpenDialog: async () => [input], showSaveDialog: async () => output, runFFmpeg(args) { temporary = args.at(-1); return job } }) + service.__test.setAudioCleanupIo({ async rm(candidate, options) { cleanupCalls += 1; if (cleanupCalls === 1) { const error = new Error('EPERM at '+candidate); error.code = 'EPERM'; throw error } return fs.rm(candidate, options) } }) + const selected = await global.window.subtitleWorkbench.chooseInput() + const started = await global.window.subtitleWorkbench.startAudioExtract(selected.file.grantId) + await fs.writeFile(temporary, 'sensitive partial audio') + rejectJob(new Error('ffmpeg failed')) + await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)) + const status = global.window.subtitleWorkbench.audioJobStatus(started.jobId) + assert.equal(status.state, 'failed') + assert.equal(status.warning.code, 'AUDIO_TEMP_CLEANUP_FAILED') + assert.equal(status.warning.fileName, path.basename(temporary)) + assert.equal(JSON.stringify(status).includes(directory), false) + assert.equal((await fs.lstat(temporary)).isFile(), true) + const pending = global.window.subtitleWorkbench.pendingAudioCleanups() + assert.deepEqual(pending, [{ jobId: started.jobId, code: 'AUDIO_TEMP_CLEANUP_FAILED', fileName: path.basename(temporary) }]) + assert.equal(JSON.stringify(pending).includes(directory), false) + assert.deepEqual(await global.window.subtitleWorkbench.retryAudioCleanup(started.jobId), { ok: true, fileName: path.basename(temporary) }) + await assert.rejects(fs.lstat(temporary), { code: 'ENOENT' }) + assert.deepEqual(global.window.subtitleWorkbench.pendingAudioCleanups(), []) + delete global.window; await fs.rm(directory, { recursive: true, force: true }) +}) +test('plugin exit retains a path-free cleanup record when temporary removal fails', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'audio-exit-cleanup-')) + const input = path.join(directory, 'input.wav'); const output = path.join(directory, 'output.wav') + await fs.writeFile(input, 'media') + let rejectJob, temporary, onOut, cleanupCalls = 0 + const job = new Promise((resolve, reject) => { rejectJob = reject }); job.kill = () => {} + const service = loadWithHost({ showOpenDialog: async () => [input], showSaveDialog: async () => output, runFFmpeg(args) { temporary = args.at(-1); return job }, onPluginOut(callback) { onOut = callback } }) + service.__test.setAudioCleanupIo({ async rm(candidate, options) { cleanupCalls += 1; if (cleanupCalls === 1) throw Object.assign(new Error('locked '+candidate), { code: 'EPERM' }); return fs.rm(candidate, options) } }) + const selected = await global.window.subtitleWorkbench.chooseInput() + const started = await global.window.subtitleWorkbench.startAudioExtract(selected.file.grantId) + await fs.writeFile(temporary, 'sensitive partial audio') + onOut() + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(global.window.subtitleWorkbench.audioJobStatus(started.jobId).state, 'unknown') + assert.deepEqual(global.window.subtitleWorkbench.pendingAudioCleanups(), [{ jobId: started.jobId, code: 'AUDIO_TEMP_CLEANUP_FAILED', fileName: path.basename(temporary) }]) + assert.deepEqual(await global.window.subtitleWorkbench.retryAudioCleanup(started.jobId), { ok: true, fileName: path.basename(temporary) }) + rejectJob(new Error('killed')) + await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(global.window.subtitleWorkbench.pendingAudioCleanups(), []) + delete global.window; await fs.rm(directory, { recursive: true, force: true }) +}) +test('plugin exit cancels a running audio job and a later resolve cannot promote it', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'audio-exit-')); const input = path.join(directory, 'input.wav'); const output = path.join(directory, 'output.wav'); await fs.writeFile(input, 'media'); await fs.writeFile(output, 'old') + let resolveJob, out, outCallback; const job = new Promise(resolve => { resolveJob = resolve }); job.kill = () => {} + global.window = { ztools: { showOpenDialog: async () => [input], showSaveDialog: async () => output, runFFmpeg: args => { out = args.at(-1); return job }, onPluginOut: callback => { outCallback = callback } } } + delete require.cache[require.resolve('../preload/services.cjs')] + require('../preload/services.cjs') + const selected = await global.window.subtitleWorkbench.chooseInput(); const started = await global.window.subtitleWorkbench.startAudioExtract(selected.file.grantId) + const temporary = out + assert.equal(typeof outCallback, 'function'); outCallback(); resolveJob(); await new Promise(resolve => setImmediate(resolve)) + assert.equal(await fs.readFile(output, 'utf8'), 'old'); assert.equal(global.window.subtitleWorkbench.audioJobStatus(started.jobId).state, 'unknown'); if (temporary) assert.deepEqual((await fs.readdir(directory)).filter(name => name.includes('.ztools-')), []) + delete global.window; await fs.rm(directory, { recursive: true, force: true }) +}) +test('plugin exit during the second media grant validation never starts FFmpeg or creates an audio job', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'audio-exit-grant-')) + const input = path.join(directory, 'input.wav'); const output = path.join(directory, 'output.wav') + await fs.writeFile(input, 'media') + let onOut, runCalls = 0, releaseValidation, announceValidation + const validationStarted = new Promise((resolve) => { announceValidation = resolve }) + const validationGate = new Promise((resolve) => { releaseValidation = resolve }) + const service = loadWithHost({ + showOpenDialog: async () => [input], + showSaveDialog: async () => output, + runFFmpeg() { runCalls += 1; return Promise.resolve() }, + onPluginOut(callback) { onOut = callback } + }) + const selected = await global.window.subtitleWorkbench.chooseInput() + const originalLstat = fs.lstat + let inputLstatCalls = 0 + service.__test.setFileValidationIo({ + async lstat(...args) { + const result = await originalLstat(...args) + if (++inputLstatCalls === 3) { + announceValidation() + await validationGate + } + return result + }, + realpath: (...args) => fs.realpath(...args) + }) + try { + const pending = global.window.subtitleWorkbench.startAudioExtract(selected.file.grantId) + await validationStarted + onOut() + releaseValidation() + await assert.rejects(pending, (error) => error.code === 'SESSION_EXPIRED') + assert.equal(runCalls, 0) + assert.equal(service.__test.audioJobCount(), 0) + } finally { + service.__test.setFileValidationIo(null) + delete global.window + await fs.rm(directory, { recursive: true, force: true }) + } +}) +test('backup cleanup failure returns a stable visible warning without reversing the promote', async () => { + const files = new Map([['final.wav', 'old'], ['temp.wav', 'new']]); const io = { + async lstat() { return { isSymbolicLink: () => false, isFile: () => true } }, + async rename(from, to) { if (from === 'final.wav') { files.set('backup', files.get('final.wav')); files.delete('final.wav'); return } if (from === 'temp.wav' && to === 'final.wav') { files.set('final.wav', files.get('temp.wav')); files.delete('temp.wav') } }, + async rm() { throw new Error('cleanup failed') } + } + global.window = { ztools: {} }; delete require.cache[require.resolve('../preload/services.cjs')] + const service = require('../preload/services.cjs'); const result = await service.__test.promoteAudio('temp.wav', 'final.wav', io) + assert.equal(result.warning.code, 'AUDIO_BACKUP_CLEANUP_FAILED'); assert.match(result.warning.fileName, /^final\.wav\.ztools-backup-/); assert.equal(result.warning.fileName.includes('/'), false) + assert.equal(files.get('final.wav'), 'new'); delete global.window +}) +test('audio job status exposes backup cleanup failure without absolute paths', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'audio-cleanup-warning-')); const input = path.join(directory, 'input.wav'); const output = path.join(directory, 'output.wav'); await fs.writeFile(input, 'media'); await fs.writeFile(output, 'old') + let resolveJob; const job = new Promise(resolve => { resolveJob = resolve }); const service = loadWithHost({ showOpenDialog: async () => [input], showSaveDialog: async () => output, runFFmpeg: () => job }) + service.__test.setAudioPromotionIo({ async lstat() { return { isSymbolicLink: () => false, isFile: () => true } }, async rename() {}, async rm() { throw new Error('cleanup failed at /private/secret/backup.wav') } }) + const selected = await global.window.subtitleWorkbench.chooseInput(); const started = await global.window.subtitleWorkbench.startAudioExtract(selected.file.grantId); resolveJob(); await new Promise(resolve => setImmediate(resolve)); await new Promise(resolve => setImmediate(resolve)) + const status = global.window.subtitleWorkbench.audioJobStatus(started.jobId) + assert.equal(status.state, 'completed'); assert.equal(status.warning.code, 'AUDIO_BACKUP_CLEANUP_FAILED'); assert.match(status.warning.fileName, /^output\.wav\.ztools-backup-/); assert.equal(JSON.stringify(status).includes(directory), false); assert.equal(JSON.stringify(status).includes('/private/secret'), false) + service.__test.clearSession(); delete global.window; await fs.rm(directory, { recursive: true, force: true }) +}) +test('audio job status exposes restore failure as a stable path-free error', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'audio-restore-failure-')); const input = path.join(directory, 'input.wav'); const output = path.join(directory, 'output.wav'); await fs.writeFile(input, 'media'); await fs.writeFile(output, 'old') + let resolveJob, renameCalls = 0; const job = new Promise(resolve => { resolveJob = resolve }); const service = loadWithHost({ showOpenDialog: async () => [input], showSaveDialog: async () => output, runFFmpeg: () => job }) + service.__test.setAudioPromotionIo({ async lstat() { return { isSymbolicLink: () => false, isFile: () => true } }, async rename() { renameCalls += 1; if (renameCalls > 1) throw new Error('restore failed at /private/secret/output.wav') }, async rm() {} }) + const selected = await global.window.subtitleWorkbench.chooseInput(); const started = await global.window.subtitleWorkbench.startAudioExtract(selected.file.grantId); resolveJob(); await new Promise(resolve => setImmediate(resolve)); await new Promise(resolve => setImmediate(resolve)) + const status = global.window.subtitleWorkbench.audioJobStatus(started.jobId) + assert.equal(status.state, 'failed'); assert.equal(status.code, 'AUDIO_BACKUP_RESTORE_FAILED'); assert.match(status.fileName, /^output\.wav\.ztools-backup-/); assert.match(status.message, /未能自动恢复/); assert.equal(JSON.stringify(status).includes(directory), false); assert.equal(JSON.stringify(status).includes('/private/secret'), false) + service.__test.clearSession(); delete global.window; await fs.rm(directory, { recursive: true, force: true }) +}) +test('audio extraction rejects a directory output before starting FFmpeg', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'audio-output-directory-')); const input = path.join(directory, 'input.wav'); const output = path.join(directory, 'output'); await fs.writeFile(input, 'media'); await fs.mkdir(output); let started = false + loadWithHost({ showOpenDialog: async () => [input], showSaveDialog: async () => output, runFFmpeg: () => { started = true; return Promise.resolve() } }) + const selected = await global.window.subtitleWorkbench.chooseInput() + await assert.rejects(global.window.subtitleWorkbench.startAudioExtract(selected.file.grantId), (error) => error.code === 'AUDIO_OUTPUT_NOT_FILE' && error.fileName === 'output' && !error.message.includes(directory)) + assert.equal(started, false); delete global.window; await fs.rm(directory, { recursive: true, force: true }) +}) +test('audio extraction converts synchronous FFmpeg launch failures to a stable path-free error', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'audio-launch-failure-')); const input = path.join(directory, 'input.wav'); const output = path.join(directory, 'output.wav'); await fs.writeFile(input, 'media') + loadWithHost({ showOpenDialog: async () => [input], showSaveDialog: async () => output, runFFmpeg: () => { throw new Error('无法启动 '+input) } }) + const selected = await global.window.subtitleWorkbench.chooseInput() + await assert.rejects(global.window.subtitleWorkbench.startAudioExtract(selected.file.grantId), (error) => error.code === 'AUDIO_EXTRACT_START_FAILED' && error.fileName === 'output.wav' && !error.message.includes(directory)) + delete global.window; await fs.rm(directory, { recursive: true, force: true }) +}) diff --git a/plugins/subtitle-workbench/tests/subtitle-core.test.cjs b/plugins/subtitle-workbench/tests/subtitle-core.test.cjs new file mode 100644 index 00000000..a4d36604 --- /dev/null +++ b/plugins/subtitle-workbench/tests/subtitle-core.test.cjs @@ -0,0 +1,13 @@ +'use strict' +const test = require('node:test'); const assert = require('node:assert/strict'); const core = require('../preload/subtitle-core.cjs') +test('SRT handles BOM and CRLF then round trips', () => { const cues = core.parseSrt('\uFEFF1\r\n00:00:01,002 --> 00:00:02,250\r\nHello\r\n\r\n2\r\n00:00:03,000 --> 00:00:04,000\r\nWorld'); assert.equal(cues.length, 2); assert.equal(core.parseSrt(core.toSrt(cues))[0].start, 1002) }) +test('VTT conversion preserves times', () => { const cues = core.parseVtt('WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHi'); assert.match(core.toVtt(cues), /00:00:01\.000/); assert.equal(core.parseVtt(core.toVtt(cues)).length, 1) }) +test('VTT ignores NOTE STYLE and REGION blocks', () => { const cues = core.parseVtt('WEBVTT\n\nNOTE private note\nignore\n\nSTYLE\n::cue { color: lime }\n\nREGION\nid:bottom\n\n00:00:01.000 --> 00:00:02.000\nHi'); assert.equal(cues.length, 1) }) +test('VTT accepts a header metadata line and MM:SS timestamp', () => { const cues = core.parseVtt('WEBVTT - title\nKind: captions\n\n00:01.500 --> 00:02.750\nHi'); assert.equal(cues[0].start, 1500) }) +test('VTT header does not consume a standard second cue', () => { const cues = core.parseVtt('WEBVTT\n\nfirst\n00:01.000 --> 00:02.000\nA\n\nsecond\n00:02.000 --> 00:03.000\nB'); assert.equal(cues.length, 2); assert.equal(cues[1].id, 'second') }) +test('VTT identifier settings and document blocks survive VTT transforms', () => { const cues = core.parseVtt('WEBVTT - title\nKind: captions\n\nSTYLE\n::cue { color: lime }\n\nNOTE keep this\nline\n\ncue-id\n00:01.000 --> 00:02.000 align:start line:90%\nHi'); const output = core.toVtt(core.shift(cues, 500)); assert.match(output, /WEBVTT - title/); assert.match(output, /STYLE/); assert.match(output, /NOTE keep this/); assert.match(output, /cue-id/); assert.match(output, /align:start line:90%/); assert.match(output, /00:00:01\.500/) }) +test('timestamp format and shift clamp', () => { assert.equal(core.formatTime(core.parseTime('01:02:03,004')), '01:02:03,004'); const cue = core.shift([{ start: 200, end: 600, text: 'x' }], -500)[0]; assert.equal(cue.start, 0); assert.equal(cue.end, 400) }) +test('quality check catches editorial issues', () => { const found = core.qualityCheck([{ start: 1000, end: 900, text: '' }, { start: 800, end: 1500, text: 'too fast reading text text text text text text text' }]); assert.deepEqual(found.map(x => x.code).sort(), ['EMPTY_TEXT','FAST_READING','OVERLAP','REVERSED','TOO_SHORT'].sort()) }) +test('quality thresholds allow zero minimum but reject invalid maximum and cps', () => { const found = core.qualityCheck([{ start: 0, end: 0, text: 'x' }], { minDuration: 0, maxDuration: 1, maxCharsPerSecond: 10 }); assert.ok(!found.some(item => item.code === 'TOO_SHORT')); assert.throws(() => core.qualityCheck([], { maxDuration: 0 })); assert.throws(() => core.qualityCheck([], { maxCharsPerSecond: 0 })) }) +test('quality thresholds reject a maximum below minimum', () => assert.throws(() => core.qualityCheck([], { minDuration: 1000, maxDuration: 999 }))) +test('speed conversion maps 2x to half timeline', () => assert.equal(core.scale([{ start: 1000, end: 3000 }], 2)[0].end, 1500)) diff --git a/plugins/subtitle-workbench/tests/ui-security.test.cjs b/plugins/subtitle-workbench/tests/ui-security.test.cjs new file mode 100644 index 00000000..6b54cb23 --- /dev/null +++ b/plugins/subtitle-workbench/tests/ui-security.test.cjs @@ -0,0 +1,47 @@ +'use strict' +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +test('subtitle findings are rendered via DOM textContent, not HTML templates', () => { + const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8') + assert.match(html, //) + assert.match(html, /本地剪辑室/) + assert.doesNotMatch(html, /LOCAL CUT ROOM|QC 检片/) + assert.match(html, /n\.textContent='#'\+\(item\.index\+1\)/) + assert.match(html, /MAX_RENDERED_FINDINGS=500/) + assert.match(html, /items\.slice\(0,MAX_RENDERED_FINDINGS\)/) + assert.match(html, /仅显示前/) + assert.ok(!html.includes('.innerHTML')) +}) +test('renderer maps stable machine codes to Chinese user-facing messages', () => { + const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8') + for (const [code, label] of Object.entries({ EMPTY_TEXT: '字幕文本为空', REVERSED: '结束时间早于开始时间', TOO_SHORT: '显示时长过短', TOO_LONG: '显示时长过长', FAST_READING: '阅读速度过快', OVERLAP: '与上一条字幕重叠' })) { + assert.match(html, new RegExp(`${code}:'${label}'`)) + } + assert.doesNotMatch(html, /\+'('\+item\.code\+')'/) + assert.match(html, /findingLabels\[item\.code\]\|\|'未知质检问题'/) + assert.match(html, /FFMPEG_UNAVAILABLE:'当前宿主未提供 FFmpeg 能力。'/) + assert.doesNotMatch(html, /status\.textContent=r\.code/) +}) +test('audio polling exposes cancellation, leftover backup warnings, and restore failures in Chinese', () => { + const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8') + assert.match(html, /'canceled'/) + assert.match(html, /音轨提取已取消/) + assert.match(html, /AUDIO_BACKUP_CLEANUP_FAILED/) + assert.match(html, /旧备份/) + assert.match(html, /未能清理,请手动检查/) + assert.match(html, /AUDIO_BACKUP_RESTORE_FAILED/) + assert.match(html, /旧文件未能自动恢复/) + assert.match(html, /visibleFileName/) + assert.doesNotMatch(html, /status\.textContent=job\.message/) +}) +test('renderer surfaces retryable temporary cleanup and catches the initial entry promise', () => { + const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8') + assert.match(html, /id="retryCleanup" disabled>重试清理临时音轨/) + assert.match(html, /AUDIO_TEMP_CLEANUP_FAILED:'音轨临时文件未能清理,请重试。'/) + assert.match(html, /api\.pendingAudioCleanups\?\.\(\)/) + assert.match(html, /api\.retryAudioCleanup\(cleanupJobId\)/) + assert.match(html, /void consume\(api\.consumeEntry\(\)\)\.catch\(/) + assert.doesNotMatch(html, /(?:^|\s)consume\(api\.consumeEntry\(\)\);/) +})