本文同步自 MAW 原始仓库的 JSON_SCHEMA.md。
字幕工程文件规范(.mosp / .json)
本文档定义 MAWE(Moy’s ASR Workflow Editor)、edit.py 生成的 .edit.html 以及 blank-editor.html 共同接受的工程文件格式。工程文件内容是 UTF-8 JSON;.mosp 是当前默认和推荐的扩展名,.json 作为旧工程与兼容输入/输出扩展名继续支持。
用途:让任意来源(ASR、第三方模型生成、人工手写)的 JSON 都能直接被编辑器加载、编辑、再导出。
适用版本:对应 edit.py / generate_subtitle_qwen_api.py 当前实现。
一、顶层结构
{
"media": "...",
"language": "...",
"model": "...",
"sticker_root": "...",
"waveform": { ... },
"gap_remove": { ... },
"script_alignment": { ... },
"workspace": { ... },
"preview": { ... },
"segments": [ ... ]
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
segments |
array<object> |
必填 | 字幕段数组。缺失或不是数组时,页面直接弹「文件格式不对,缺少 segments 字段」并拒绝加载 |
media |
string |
否 | 媒体文件路径(绝对/相对均可)。便携 HTML 会在“打开工程”时用它的文件名匹配同一次选择的媒体;只选工程文件时会提示用户继续选择媒体。浏览器安全限制下不能自行读取该路径或跳转其目录。服务器编辑器可按该路径自动加载 |
language |
string |
否 | 语言代码,如 Chinese、English。仅用于显示 |
model |
string |
否 | ASR 模型名,如 qwen3-asr。仅用于显示 |
sticker_root |
string |
否 | 表情包根目录绝对路径。打开工程时会覆盖编辑器内的 STICKER_ROOT |
waveform |
object |
否 | 可丢弃的紧凑波形缓存。由 edit.py 或浏览器自动生成;不影响字幕语义 |
gap_remove |
object |
否 | 可逆的空隙移除决定。保留原始媒体/字幕时间,仅描述导出与跳过播放时使用的派生时间轴 |
script_alignment |
object |
否 | 录制对齐工具写入的选择记录;不改变 MAWE 的字幕与时间码语义 |
workspace |
object |
否 | 编辑器工作区:四个功能区的窗口布局与显示状态;不影响字幕和波形缓存。服务器版也可使用独立的本机命名工作区库跨工程复用 |
preview |
object |
否 | 预览呈现设置。含 preview.subtitle(主字幕预览框与样式)、可选的 preview.extension_subtitle(拓展字幕样式)和 preview.sticker(表情包预览层)。不影响字幕时间与文本 |
1.0 工程文件扩展名
- 转写器和 Launcher 默认生成
.mosp;命令行的--json参数名称为历史兼容名称,含义是“同时生成工程文件”。 .mosp文件不是新的二进制容器,而是普通 UTF-8 JSON,方便脚本、版本控制和其他工具读取。- 编辑器、服务器和桌面入口都继续接受
.json。打开旧.json工程时可以原扩展名保存,也可以通过“另存为”改成.mosp。 - 服务器覆盖保存会保留当前扩展名,并先创建同目录备份:
project.mosp.bak或project.json.bak。 .workspace.json是可选的独立工作区迁移文件,不是字幕工程文件;Resolve JSON、保留区域 JSON 等导出文件也不应重新作为工程打开。
1.1 waveform 波形缓存
waveform 不是工程真源,而是从媒体派生的性能缓存。第三方生成 JSON 时可以完全省略;编辑器加载媒体后会补算。
{
"schema": "moy.asr.waveform.v1",
"encoding": "i8-minmax-base64",
"peaks_per_second": 100,
"peak_count": 123456,
"duration_ms": 1234560,
"data": "base64 编码的 [min,max] int8 峰值对",
"source": {
"name": "audio.wav",
"size": 987654321,
"modified_ms": 1784000000000
}
}
data每个峰占 2 字节:有符号 int8 的最小值、最大值,整体再做 base64。source用于缓存失效;媒体文件名、字节大小或最后修改时间变化时会重新计算。- 默认密度 100 峰/秒。三小时音频约产生 108 万峰、2.88 MB base64 字符串。
- 未识别的
schema/encoding会被忽略,不阻止工程加载。 - Qwen/Soniox/必剪/本地命令行生成器默认不内嵌波形;加
--with-waveform时可在转写生成工程文件时把同一 payload 写入顶层waveform,并在媒体旁生成只含 wave 层的.ReaPeaks缓存。GUI 转写默认开启该模式。 - 编辑器首次打开缺少有效
waveform的工程时,仍可能在媒体旁写入<媒体名>.waveform.jsonsidecar;它使用同一source签名,可被后续工程复用。sidecar 不属于字幕真源,删除后可重新提取。
1.1a spectral 频谱缓存(可选)
spectral 同样是媒体派生的性能缓存,只用于编辑器把波形按主频染色。它不是真源,第三方生成 JSON 时可以完全省略;服务器加载媒体时若在媒体旁找到 REAPER 生成的 <媒体名>.ReaPeaks,会解析出光谱层并内联下发。
{
"schema": "moy.asr.spectral.v1",
"encoding": "u16-freq-density-base64",
"sample_rate": 48000,
"division": 2400,
"peak_count": 72000,
"data": "base64 编码的 [freq,density] uint16 对",
"source": {
"name": "audio.wav",
"size": 987654321,
"modified_ms": 1784000000000
}
}
data每个频谱采样占 4 字节:主频 uint16(低 15 位有效,0–32767)、密度 uint16(低 14 位有效,0–16383),整体再做 base64。division是时间对齐用的每采样样本数:sample_rate / division即每秒频谱采样数。sample_rate、source与主波形一致。- 生成时机:转写生成工程时,
--with-waveform在媒体旁自动生成<媒体名>.ReaPeaks的 wave 层(GUI 默认开启);只有同时勾选 Launcher 的“生成 ReaPeaks 频谱数据”或传入--with-spectral,才额外执行频谱 FFT 并写入 spectral 层。--with-spectral必须与--with-waveform一起使用。服务器只读取已有的.ReaPeaks,不负责生成。生成由 Rust 内核(reapeaks)承担,经 ffmpeg 解码媒体;缺少 ffmpeg 或解码失败时打日志跳过。numpy 不参与.ReaPeaks生成(仅 OCR 后处理路径 lazy import)。 - 解析器读取 REAPER 的
RPKN/RPKL文件,取匹配peaks_per_second分辨率的 spectral 层(-(int)'s'标记);无 spectral 层、文件缺失或损坏时静默降级,不影响编辑器。 - 未识别的
schema/encoding会被忽略。浏览器端在decodeSpectralPayload校验这两字段与data长度(peak_count * 4)。
1.1b waveform_reapeaks 波形层(可选)
waveform_reapeaks 是 .ReaPeaks 最细 wave 层转成的 moy.asr.waveform.v1 payload(字段与 §1.1 完全一致)。它是默认的波形形状来源:编辑器默认使用本字段绘制包络,没有可用 .ReaPeaks 时自动回退自研 waveform(1000 Hz 重采样);用户可在波形设置中手动切换两种来源。
{
"schema": "moy.asr.waveform.v1",
"encoding": "i8-minmax-base64",
"peaks_per_second": 300,
"peak_count": 1500,
"duration_ms": 5000,
"data": "base64 的 [min,max] int8 对",
"source": { "name": "audio.wav", "size": 441044, "modified_ms": 1786328355571 }
}
- 由服务器加载媒体时从
find_reapeaks找到的.ReaPeaks解析最细 wave 层得到;peaks_per_second = sample_rate / division(约 300 峰/秒)。 - 缺失
.ReaPeaks或没有 wave 层时该字段不出现,编辑器回退自研波形。 - 与
spectral同源,均为.ReaPeaks派生的可丢弃缓存,非真源。 - 没有
spectral数据时,编辑器会自动取消并禁用“频谱颜色”开关;后台读到合法频谱后重新启用该开关。
1.2 workspace 工作区
workspace 使用独立 schema moy.asr.editor.workspace.v1。一个工作区 = 窗口布局(“视频、当前字幕编辑区、字幕列表、波形”四个功能区的停靠方式与尺寸)+ 显示状态(波形显示模式与偏好、字幕列表/编辑区的显示开关)。保存或恢复工作区时两部分一起生效。
{
"schema": "moy.asr.editor.workspace.v1",
"preset": "custom",
"selectedPreset": "cinema",
"waveformMode": "basic",
"waveformSettings": { "visibleSeconds": 20, "secondsPerRow": 10, "rowHeight": 120, "waveformScale": 1, "side": "left", "disabledDisplay": "dim", "showGroupBadges": true, "dragPlayhead": true },
"editorDisplay": { "cueListShowIndex": true, "cueListShowTime": true, "cueListShowSticker": false, "cueListShowCharcount": true, "cueEditorShowNavigation": false, "cueEditorShowTimeActions": true, "cueEditorShowSticker": false },
"splitPercent": 60,
"columnPercent": 58,
"rows": [42, 27, 31],
"tree": {
"type": "split",
"direction": "row",
"ratio": 58,
"children": [
{
"type": "split",
"direction": "column",
"ratio": 42,
"children": [
{ "type": "module", "id": "player" },
{
"type": "split",
"direction": "column",
"ratio": 46.55,
"children": [
{ "type": "module", "id": "panel" },
{ "type": "module", "id": "cues" }
]
}
]
},
{ "type": "module", "id": "wave" }
]
}
}
preset是渲染器,决定这份窗口布局如何绘制:classic(标准堆叠网格)、wave-right(右侧整列波形网格)或custom(由tree渲染;「字幕列表编辑」「大荧幕布局」与用户自定义工作区都走这条路)。未知值回退到wave-right。selectedPreset记录用户最后在工作区下拉框选择的项:内置工作区为classic/wave-right/three-fold/cinema(大荧幕布局),本机命名工作区为saved:<名称>。它与tree一起保存,使内部以custom渲染的工作区在重开工程后仍显示用户所见的名称。waveformMode可为multi(多行)或basic(单行)。工作区中存在该字段时随恢复一并切换;缺失时保持当前浏览器设置。waveformSettings保存波形区数值与显示偏好:基础模式窗口长度、多行每行长度及高度、振幅、左右侧、禁用字幕显示、分组徽章与拖动播放头。字段缺失时保持浏览器本机设置。editorDisplay保存“字幕列表显示”和“字幕编辑显示”两组开关。它只包含工作区可见性,不包含导出、自动保存或快捷键等全局偏好。splitPercent是 classic 网格中多行波形与字幕列表比例,范围会被限制在 35–75;它与工作区一起导出,因此拖动后可撤销、复用。columnPercent是custom渲染器最外层左右分栏的比例,范围会被限制在 30–75。rows是左侧“视频 / 当前字幕 / 字幕列表”的相对高度,编辑器会自动归一化并保证每区可用的最小高度。tree是custom渲染器的二叉 split tree。type: "module"是功能区叶子;type: "split"的direction为row(左右)或column(上下),ratio是第一个子区的比例。- 布局编辑模式拖动标题条时,中央区域会显示“对换”预览;靠近上/下/左/右边沿会显示对应半区的“插入”预览。松开后目标叶子会被拆成新的横向或纵向 split,可继续嵌套。
- 工程文件导出会包含
workspace。单文件 HTML 在「工作区配置 ▾」提供“导出工作区配置 / 导入工作区配置”,以.workspace.json文件显式迁移该结构;服务器版的「保存工作区」则把同一结构保存到用户本机:内置工作区保存为该预设的本机覆盖版(可重置回默认),自定义工作区保存到命名工作区库(可另存、删除),均可供其他工程复用;该操作不会写回字幕工程文件。 - 拖动模块、拖动任一布局分隔条、导入和重置工作区都会进入统一的
Ctrl(Cmd)+Z撤销栈;「编辑布局」中可用「重置工作区」恢复当前内置工作区的默认状态。
1.3 gap_remove 空隙移除
gap_remove 是编辑决策,不会重写 segments[*].start/end 或原媒体。编辑器把其中 removed: true 的区间从派生时间轴压缩掉,用于自动跳过播放、去空隙 SRT 和去空隙 OTIO;removed: false 表示用户已恢复该空隙。
{
"schema": "moy.asr.gap_remove.v1",
"detector": "audio_gate",
"minimum_ms": 500,
"threshold_db": -24,
"hysteresis_db": 2,
"lead_in_ms": 40,
"lead_out_ms": 80,
"skip_playback": true,
"manual_corrections": false,
"operation_mode": "middle_drag",
"disable_coverage_percent": 80,
"disable_remaining_ms": 300,
"gaps": [
{
"start": 1280,
"end": 2440,
"removed": true,
"source": "audio_gate",
"origins": ["audio_gate"]
},
{
"start": 6120,
"end": 7050,
"removed": false,
"source": "audio_gate",
"origins": ["audio_gate", "manual"]
}
],
"provenance": {
"schema": "moy.asr.gap_provenance.v1",
"sources": {
"script_alignment": [],
"audio_gate": [
{ "id": "silence-001", "start": 1280, "end": 2440 }
]
},
"manual_overrides": [
{ "id": "manual-001", "start": 6120, "end": 7050, "removed": false }
],
"legacy": []
}
}
detector固定为audio_gate:扫描波形峰值包络,声音高于threshold_db时打开 gate,低于threshold_db - hysteresis_db后才关闭;不会用字幕之间的时间差推断空隙。gaps[*].source和gaps[*].origins是根据provenance派生的可读字段:source表示唯一的初始自动来源;origins列出当前区间的全部贡献来源。多个自动来源重叠时source为null;只有人工覆盖时才为manual。它们不是来源真源,旧客户端可以忽略。provenance是可选的来源真源,当前来源层为script_alignment、audio_gate与manual_overrides;legacy是兼容读取字段,启用的旧范围会迁入audio_gate,旧的removed: false范围会迁入manual_overrides,规范化输出中的legacy为空数组。支持它的新客户端据此分层重扫和重建最终gaps。minimum_ms的允许范围是 100–60000,单位为毫秒;默认 500。判定基于应用前/后端预留后的最终移除区间,预留吃完整段时不纳入移除。threshold_db的范围是 -96–0,默认 -24;hysteresis_db的范围是 0–30,默认 2。比如阈值 -24、滞回 2 时,声音达到 -24 才算有声,低于 -26 才重新算静音。建议使用 1–3dB;过高会延迟回到静音。滞回位于「空隙检测与调整」折叠区内。lead_in_ms/lead_out_ms是每段空隙两侧保留的静音毫秒数,范围 0–2000,默认前端 40、后端 80。扫描得到的原始静音区间会在起点加lead_in_ms、终点减lead_out_ms后再写入gaps,避免剪掉空隙后两句贴得太急;预留后的区间短于minimum_ms时整段保留。这两个值在扫描生成空隙时继续生效;对已有结果点击「收缩空隙」时,会再次按当前值向内调整现有区间,是额外的可撤销微调。manual_corrections表示当前结果是否包含人工修正。新客户端根据provenance.manual_overrides是否为空维护它;旧客户端仍可把它当作全局摘要。Alt+左键切换整段、Ctrl/Cmd+复制拖动、中键范围操作和“全部恢复”都会留下普通人工覆盖;整体拖动会留下operation: "move"的内部记录,保存base_start/base_end和target_start/target_end,重复拖动时更新原记录;旧移动目标被后续操作从中间覆盖时,记录可使用target_ranges保存剩余目标片段。边界拖动会留下operation: "boundary_resize"的内部记录,保存edge、base、boundary与可选cleared_ranges。两类记录都直接调整同一条 Gap 的范围,不会在原位置追加removed: false恢复块;重新扫描不会删除这些人工调整。operation: "move"移动的是用户看到的整条 Gap:先清除原可见范围,再把相同状态放到固定长度的目标范围。通常使用target_start/target_end;旧移动目标被后续操作从中间覆盖时,使用可选target_ranges保存剩余片段(可以为空以继续清除 base)。同状态的被覆盖 Gap 会被吸收,removed状态不同的 Gap 只缩小其重叠部分,因此相邻的 active/inactive Gap 仍是独立对象。普通removed: false仍然表示用户明确保留、但不参与跳过的恢复区。removed: false的恢复区段仍保留在时间轴上,但不参与播放跳过、去空隙导出或“禁用空隙内字幕”;“清理区段”则从来源层删除选中范围内的记录,不留下恢复覆盖,因此之后重新扫描可能再次生成同一段静音 Gap。operation_mode控制人工修正交互:none仅保留 Alt+点击整段切换,boundary_drag在 hover 空隙时显示左右边界手柄,middle_drag默认用中键增加静音、按住 Alt 才恢复声音,boundary_and_middle(界面显示「边界与中键」)同时启用边界手柄和中键范围操作;当前界面默认boundary_drag。边界只移动被点中的 Gap,不会联动相邻 Gap;向内缩小启用或未激活 Gap 时,被让出的边缘会从最终投影清除,未激活 Gap 不会凭此产生启用 Gap。向外覆盖另一段时,完整覆盖会清理整段,部分覆盖只裁掉相交范围,并在cleared_ranges中保留已覆盖范围以防回拖时旧 Gap 复活。重复拖动同一边界会更新已有的boundary_resize记录。disable_coverage_percent与disable_remaining_ms是“禁用空隙内字幕”设置,均为可选字段,缺失时默认分别为 80% 和 300ms。执行“禁用字幕”时,编辑器先把所有removed: true空隙合并,再筛选空隙覆盖字幕时长达到该比例、且未被覆盖的剩余字幕时长不超过该阈值的主字幕;完全落在空隙内的字幕会命中。该操作只设置字幕的disabled标记,不改写起止时间,并可通过撤销恢复。- 「空隙检测与调整」中的「收缩空隙」是对现有
audio_gate空隙的额外处理:每段起点增加当前lead_in_ms,终点减少当前lead_out_ms;被预留量完全吃掉的区间会丢弃,其他区间保留原有removed状态。它直接重写provenance.sources.audio_gate的区间并据此重建gaps,不新增manual_overrides,也不因此标记manual_corrections;已有人工覆盖仍然保留。不改写字幕起止时间;重复点击会继续收缩,且每次都可撤销。 - 扫描不会移除开头或结尾的素材。
- 波形将
removed: true画为橙色斜纹、removed: false画为灰蓝斜纹;边界把手和整体/边界拖动预览使用蓝色表示正在进行人工修改;左键仅跳转播放头,Alt+左键才在两种状态间切换。 - 旧工程没有 provenance、或使用
legacy_subtitle_gapdetector 时,现有removed: true范围会按audio_gate迁入并继续启用,removed: false范围迁为人工恢复;重新扫描和「收缩空隙」都会处理迁入的自动静音范围。
1.3a script_alignment 录制对齐记录
script_alignment 是录制对齐 Server 写入的可选诊断与选择记录,不替代 segments 或 gap_remove。候选、选择和 Extra 范围可以包含 sourceSlices,用于记录一个源字幕段内的 item 子范围:
{
"sourceCueIndex": 19,
"sourceCueId": "main-020",
"start": 46390,
"end": 48230,
"itemStart": 0,
"itemEnd": 12,
"sourceText": "目前支持画面上的这些模型"
}
选中的 incomplete 候选默认不会进入保留区间;用户明确手动启用后,选择记录会保留原始 incomplete 分类。完整的 match 候选默认进入保留区间;用户也可以手动禁用当前已采用的候选,让它从保留区间中移除。两类覆盖都会保留在选择记录中:
{
"candidateActions": {
"candidate-001-01": "keep",
"candidate-002-01": "discard"
},
"manuallyEnabledCandidateIds": ["candidate-001-01"],
"manuallyEnabledLineIds": ["line-001"],
"manuallyDisabledCandidateIds": ["candidate-002-01"],
"manuallyDisabledLineIds": ["line-002"],
"blockedIncompleteLineIds": []
}
candidateActions 只记录用户对已选候选的显式覆盖:incomplete 使用 keep 手动启用,完整 match 使用 discard 手动禁用;manuallyEnabledCandidateIds 表示实际解除自动禁用的候选,manuallyDisabledCandidateIds 表示从默认保留中排除的完整候选,blockedIncompleteLineIds 表示仍会被禁用的不完整文稿行。这样可以区分识别结果、自动建议和用户确认。
当源段只有部分 item 被采用时,导出的工程会在相应 item 边界拆分字幕段;未采用部分设置 disabled: true,其间的时间同时写入 gap_remove.gaps。没有有效 items 时,录制对齐工具退回到源字幕段边界。
候选和已选记录还可以包含 internalSkips,表示一个 take 内部自动识别出的重复源段:
{
"kind": "skip-source",
"reasonCode": "repetition",
"sourceText": "双语字幕",
"sourceSlices": [{
"sourceCueIndex": 5,
"sourceCueId": "main-006",
"start": 16309,
"end": 17030,
"itemStart": 0,
"itemEnd": 4,
"sourceText": "双语字幕"
}]
}
当前 MVP 只在相邻的完整源字幕段之间启用这一规则:文本归一化后完全相同,或具有足够长的共同开头并且后一个片段前有明显停顿。规则既适用于候选内部,也适用于候选外的连续未认领片段;默认舍弃前一个、保留后一个,因此不会把近似改口错误地列为新的 Alternative。导出时会从候选的保留范围扣除 internalSkips,相应字幕段设为 disabled: true,时间写入 gap_remove.gaps。
候选的 alternativeGroupId 表示同一文稿行的局部录制组;相邻候选之间默认最多相隔 10000ms,且最多跨过 8 个源字幕段,限制值记录在对齐结果的 settings.alternativeMaxGapMs 与 settings.alternativeMaxCues 中。不同组的完整命中不会自动作为 Alternative 禁用,而会以 kind: "extra"、reasonCode: "distant-match" 进入可确认范围,默认保留。
1.4 preview 预览呈现
preview 记录预览呈现层的设置,与字幕时间/文本完全解耦。目前定义两个子几何:preview.subtitle(字幕预览框,编辑器里 #overlay)与 preview.sticker(表情包预览层,编辑器里 #sticker-overlay-layer),都是在播放器区域内的几何,以 player-wrap 矩形的归一化分数存储,因此在播放器缩放和跨机传输后仍然一致。
{
"subtitle": { "x": 0.1, "y": 0.76, "width": 0.8, "height": 0.16, "font_size": 32, "font_family": "yahei", "color": "#ffffff" },
"extension_subtitle": { "font_size": 30, "font_family": "yahei", "color": "#ffd34d" },
"sticker": { "x": 0.73, "y": 0.04, "width": 0.24, "height": 0.3 }
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
x |
number |
是 | 左上角横坐标,占播放器宽度的分数,范围 [0, 1] |
y |
number |
是 | 左上角纵坐标,占播放器高度的分数,范围 [0, 1] |
width |
number |
是 | 预览框宽度,占播放器宽度的分数,范围 [0, 1] |
height |
number |
是 | 预览框高度,占播放器高度的分数,范围 [0, 1] |
font_size |
number |
否 | 字幕预览字号,单位 px,范围 [12, 96];缺失时使用原来的响应式默认字号 |
font_family |
string |
否 | 字幕预览字体族:内置键 default、yahei、hei、song、sans,或本机字体族名称(最长 128 个字符) |
background_color |
string |
否 | 字幕预览背景色,6 位十六进制颜色 #RRGGBB;缺失时使用黑色 |
background_alpha |
number |
否 | 字幕预览背景不透明度,范围 [0, 1];缺失时使用 0.65,设为 0 时隐藏背景 |
color |
string |
否 | 六位十六进制颜色,如 #ffffff;主字幕默认白色,拓展字幕默认黄色 #ffd34d |
color_underline |
boolean |
否 | 播放预览按字幕颜色快照给文字加下划线以区分不同颜色的字幕;缺失时视为 true(默认开启),设为 false 时关闭下划线。编辑器仅在关闭时写入该字段 |
preview.extension_subtitle |
object |
否 | 拓展字幕样式;同样支持 font_size、font_family、color,没有字号时默认比主字幕小 2px |
约束
x、y、width、height四个字段都必须是数字(不接受字符串、布尔),且落在[0, 1]。- 若存在
font_size,必须是[12, 96]内的数字;若存在font_family,必须是内置字体键或非空本机字体族名称,最长 128 个字符,不能包含控制字符;若存在background_color,必须是#RRGGBB格式;若存在background_alpha,必须是[0, 1]内的数字。 - 若存在
color,必须是#RRGGBB六位十六进制颜色;拓展字幕样式不包含独立几何,沿用preview.subtitle的预览框。 - 若存在
color_underline,必须是布尔值;其他取值视为缺失并按默认true处理。 - 盒子必须留在播放器内:
x + width <= 1且y + height <= 1。 - 编辑器额外强制最小可读尺寸
width >= 0.20、height >= 0.08(这是编辑器 UX 钳制,非数据契约的硬校验;导入时会被编辑器再钳制)。 preview缺失或preview.subtitle缺失时按旧工程处理,编辑器使用默认几何{ x: 0.1, y: 0.76, width: 0.8, height: 0.16 }——字幕带占 76%→92%(底部留 8%),宽度 80% 居中。preview.sticker缺失时同样按旧工程处理,使用默认几何{ x: 0.73, y: 0.04, width: 0.24, height: 0.3 }(右上角)。两个几何共用同一套归一化与钳制规则。- 该几何只移动/缩放预览框容器;内部文字
<span>仍保持居中与药丸样式,segments[*].start/end/items[*].start/end永不被此几何改动。
1.5 multi_subtitle 多重字幕
multi_subtitle 是可选的双语字幕扩展结构。旧工程缺失该字段时,编辑器按关闭状态加载;保存时会补写关闭的空结构。顶层 segments 始终是主轨真源,扩展字幕只放在 tracks[*].segments 中。
{
"multi_subtitle": {
"schema": "moy.asr.multi_subtitle.v1",
"enabled": true,
"display_mode": "both",
"main_split_mode": "word",
"tracks": [{
"id": "translation",
"role": "extension",
"name": "English",
"language": "English",
"split_mode": "word",
"source_name": "translation.srt",
"segments": [{
"id": "translation-segment-001",
"start": 1100,
"end": 2900,
"text": "Extended subtitle",
"items": [{"text": "Extended subtitle", "start": 1100, "end": 2900}]
}]
}],
"bindings": [{
"id": "binding-001",
"track_id": "translation",
"main_segment_ids": ["main-001"],
"extension_segment_ids": ["translation-segment-001"],
"start_offset_ms": 100,
"end_offset_ms": -100
}]
}
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
multi_subtitle.schema |
string | 否 | 固定为 moy.asr.multi_subtitle.v1 |
multi_subtitle.enabled |
boolean | 否 | 默认 false;关闭只隐藏扩展数据,不删除数据 |
multi_subtitle.display_mode |
string | 否 | main / extension / both,默认 both |
multi_subtitle.main_split_mode |
string | 否 | 主字幕语言类型:continuous(字符型)或 word(单词型);旧工程缺失时按主字幕文本自动判断 |
multi_subtitle.tracks |
array | 否 | 扩展轨数组;当前 UI 只管理第一条轨道 |
tracks[i].id |
string | 是 | 轨道稳定 ID |
tracks[i].role |
string | 否 | 当前固定为 extension |
tracks[i].name |
string | 否 | 用户可见轨道名 |
tracks[i].language |
string | 否 | 语言或语言代码 |
tracks[i].split_mode |
string | 否 | 副字幕语言类型:continuous(字符型)或 word(单词型);用于近似拆分和字数统计 |
tracks[i].source_name |
string | 否 | 来源文件名,不保存绝对路径 |
tracks[i].segments |
array | 是 | 扩展字幕段;每段至少有段级时间码和文本,items 可选 |
tracks[i].segments[j].id |
string | 是 | 扩展字幕稳定 ID |
tracks[i].segments[j].start/end |
int | 是 | 非负整数毫秒,start < end |
tracks[i].segments[j].text |
string | 是 | 扩展字幕文本 |
tracks[i].segments[j].items |
array | 否 | 可选字词时间码;结构和主轨 segments[i].items 相同 |
tracks[i].segments[j].disabled |
bool | 否 | 禁用该扩展字幕;预览、隐藏禁用项和扩展 SRT 导出会跳过它 |
bindings |
array | 否 | 主轨与扩展轨的绑定关系 |
bindings[i].track_id |
string | 是 | 指向扩展轨 ID |
bindings[i].main_segment_ids |
array | 是 | MVP 必须恰好一个主轨 ID |
bindings[i].extension_segment_ids |
array | 是 | MVP 必须恰好一个扩展轨 ID |
bindings[i].start_offset_ms |
int | 是 | extension.start - main.start |
bindings[i].end_offset_ms |
int | 是 | extension.end - main.end |
约束:
- 主轨和扩展轨段均使用不重复的稳定字符串 ID;当前规范化会为缺失 ID 的输入补齐,并在导出/保存时写入。主轨按
main-001、扩展轨按<track-id>-segment-001的顺序生成;如果生成值与后续显式 ID 冲突,会使用确定性的-generated后缀。浏览器与 Python 服务端使用同一规则。 - 当前 MVP 强制每个绑定一对一;数组形式保留给未来一对多关系,但当前校验要求数组长度均为 1,且一个端点不能重复绑定。
- 自动导入按段级时间码匹配:时间区间有交集,且开始/结束时间差均不超过
300ms;冲突选择总差值最小的候选。未匹配段保留,可手动绑定。 - SRT 导入没有字词时间码,因此扩展段通常不带
items;mosp/json 导入和主副交换可以带上可选items,保存、加载和再次交换时保留它们。 continuous(字符型)允许字符边界,word(单词型)只允许空格或安全标点附近的边界,禁止拆碎单词。切分时会清理断点两侧相邻的中英文逗号、句号及空白;两种模式也分别决定字数统计规则。enabled: false时工程仍保留轨道、绑定、语言类型和 ID;主轨 SRT 导出语义不变,扩展轨使用独立 SRT 导出。
二、segment 对象
segments[i] 的字段定义:
{
"id": "main-001",
"start": 1234,
"end": 5678,
"text": "字幕文本",
"items": [ ... ],
"speaker": "1",
"sticker": null,
"sticker_ref": null,
"color": null,
"color_ref": null,
"_dirty": false
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id |
string |
必填 | 主字幕稳定 ID;输入缺失时规范化为 main-001、main-002 等确定性 ID |
start |
int |
必填 | 段起始时间,单位毫秒 |
end |
int |
必填 | 段结束时间,单位毫秒,要求 end > start |
text |
string |
必填 | 字幕显示文本。可含 \n 表示换行(在编辑器里渲染为 <br>) |
items |
array<object> |
推荐填 | 字级时间戳数组。用于「双击拆分时按字分配时间」。可填 [],此时拆分会按字符比例估算时间点 |
disabled |
bool |
否 | 禁用该字幕;预览、隐藏禁用项和默认导出会跳过它 |
speaker |
string |
否 | 说话人标签(非空字符串)。保存供应商返回的 opaque ID(如 Soniox 的 "1"/"2"),不转换为整数或姓名。仅当该段所有带语音 items 都是同一 speaker 时才写入;缺少该字段的旧工程继续有效 |
sticker |
object|null |
否 | 表情包 head 信息。见第四节 |
sticker_ref |
object|null |
否 | 引用上方 head 的表情包(跨多句用) |
color |
object|null |
否 | 颜色标记 head。见第四节 |
color_ref |
object|null |
否 | 引用上方 head 的颜色 |
_dirty |
bool |
否 | 是否被人工改过。生成时不要写 true,仅由编辑器内部维护 |
关键约束
start/end/items[*].start/items[*].end全部是整数毫秒(不是秒、不是字符串、不是浮点)segments建议按时间升序排列,且segments[i].end <= segments[i+1].start- 代码不强校验时间重叠,但重叠会导致播放器跳转/高亮行为异常
items首元素start建议等于 segmentstart,末元素end建议等于 segmentend- 带
speaker的工程遇到说话人变化时必须切分字幕,不能把两个 speaker 合入同一 segment
三、items(字级时间戳)
segments[i].items[k] 的字段:
{
"text": "字",
"start": 1234,
"end": 1300,
"speaker": "1"
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
text |
string |
是 | 单字或单词。所有 item 的 text 拼接后应等于所属 segment 的 text(标点也应包含在内,编辑器拆分时会按需剥掉) |
start |
int |
是 | 该字/词起始时间(毫秒) |
end |
int |
是 | 该字/词结束时间(毫秒) |
speaker |
string |
否 | 该字/词的说话人标签(非空字符串),保存供应商返回的 opaque ID |
生成建议
- 中文逐字给时间戳,英文按词给
- 标点符号可作为零宽 item(
start == end),或并入前一个字的 item,代码都能容忍 - 若生成模型拿不到字级时间,填
[]也可接受,编辑器会按字符比例自动插值(拆分时间精度会下降) - 如果
items字段整个缺失,编辑器视同[]
四、表情包 / 颜色(head + ref 系统)
这套机制服务于「跨多句字幕覆盖同一个表情包或颜色」的需求。
生成 JSON 时直接全部填 null 即可,让用户在编辑器里手动分配。本节仅供深度二次开发参考。
4.1 sticker head(首条持完整信息)
{
"name": "表情包名(去扩展名)",
"filename": "表情包名.png",
"rel": "相对 sticker_root 的路径,通常等于 filename",
"width": 1920,
"height": 1080,
"start": 1234,
"end": 9999
}
| 字段 | 说明 |
|---|---|
name |
显示名,通常等于文件名去扩展名 |
filename |
完整文件名(含扩展名) |
rel |
相对 sticker_root 的路径。平铺目录下等于 filename |
width / height |
可选正整数,原始图片像素宽高。旧工程缺失时,导出器使用兼容默认值。 |
start / end |
表情包时间范围(毫秒)。导出 EDL 时使用;跨多句时通常等于 head 段的 start 与最后一句的 end |
4.2 sticker_ref(后续条引用 head)
{
"name": "表情包名",
"headIdx": 5
}
headIdx 是 segments 数组里的整数下标(0-based),指向同属一个表情包的 head 段。拆分/合并/删除时编辑器会自动维护这个索引。
4.3 color head
{ "name": "red", "value": "#f07f6f", "start": 1234, "end": 9999 }
name 只能是以下 5 种之一(调色板唯一权威定义在 maw/colors.py 的 COLOR_PALETTE:speaker 自动取色、1~5 手动标记与编辑器/波形显示共用,构建时注入编辑器;下表为当前值,旧工程可能保留调整前存储的 value):
| name | value |
|---|---|
yellow |
#c4a019 |
green |
#66bb6a |
red |
#f07f6f |
purple |
#bf89e6 |
blue |
#61a7fa |
4.4 color_ref
{ "name": "red", "headIdx": 5 }
五、最小可用示例
下面这份 JSON 可被编辑器直接接受:
{
"media": "D:/path/to/video.mp4",
"language": "Chinese",
"model": "your-model-name",
"segments": [
{
"start": 0,
"end": 2150,
"text": "大家好",
"items": [
{ "text": "大", "start": 0, "end": 620 },
{ "text": "家", "start": 620, "end": 1280 },
{ "text": "好", "start": 1280, "end": 2150 }
],
"sticker": null,
"sticker_ref": null,
"color": null,
"color_ref": null
},
{
"start": 2200,
"end": 5400,
"text": "今天给大家介绍一下字幕编辑器的 JSON 规范。",
"items": [
{ "text": "今", "start": 2200, "end": 2350 },
{ "text": "天", "start": 2350, "end": 2510 },
{ "text": "给", "start": 2510, "end": 2680 },
{ "text": "大", "start": 2680, "end": 2850 },
{ "text": "家", "start": 2850, "end": 3020 },
{ "text": "介", "start": 3020, "end": 3200 },
{ "text": "绍", "start": 3200, "end": 3400 },
{ "text": "一", "start": 3400, "end": 3580 },
{ "text": "下", "start": 3580, "end": 3780 },
{ "text": "字", "start": 3780, "end": 3950 },
{ "text": "幕", "start": 3950, "end": 4120 },
{ "text": "编", "start": 4120, "end": 4300 },
{ "text": "辑", "start": 4300, "end": 4480 },
{ "text": "器", "start": 4480, "end": 4660 },
{ "text": "的", "start": 4660, "end": 4820 },
{ "text": "JSON", "start": 4820, "end": 5170 },
{ "text": "规", "start": 5170, "end": 5290 },
{ "text": "范", "start": 5290, "end": 5400 },
{ "text": "。", "start": 5400, "end": 5400 }
],
"sticker": null,
"sticker_ref": null,
"color": null,
"color_ref": null
}
]
}
六、给 LLM 生成 JSON 的 Prompt 模板
把下面这段直接粘给任意模型当生成约束:
请基于我提供的字幕文本与时间信息,生成符合如下规范的 JSON:
1. 输出必须是合法 UTF-8 JSON,顶层为 object,含 segments 数组(必需)
2. 每个 segment 必须有 start、end、text 三个字段
3. 时间单位统一为毫秒整数(不是秒、不是字符串、不是浮点)
4. start < end,且 segments 按时间升序排列
5. items 数组每项 {text, start, end};所有 item 的 text 拼接后应等于 segment.text
6. items 首项 start = segment.start,末项 end = segment.end
7. 标点作为零宽 item(start=end)或并入前一个字
8. sticker / sticker_ref / color / color_ref 全部填 null
9. 不要输出 _dirty 字段
10. 不要输出任何 JSON 之外的解释文字、Markdown 代码块标记
11. 中文逐字给时间戳,英文按词给
12. media / language / model 字段按需填写,允许省略
13. 不要生成 waveform;它是编辑器从媒体自动计算的缓存
七、校验方式
生成后任选其一验证:
方式 1:用 edit.py 直接生成 HTML
cd <MAW 仓库目录>
uv run python edit.py your_generated.mosp
成功会生成 your_generated.edit.html。
方式 2:用空壳编辑器加载
file://双击打开本仓库根目录的blank-editor.html- 点「打开工程」选
.mosp或.json工程文件 - 若弹出「文件格式不对,缺少 segments 字段」红色提示,说明顶层结构错误
- 若正常显示字幕列表,则格式合格
方式 3:JSON Schema 自检(可选)
用任意 JSON 校验工具确认以下条件:
- 顶层是 object
segments是数组,且每个元素都是 object- 每个 segment 含
start/end/text start/end为非负整数且start < endsegments[*].items若存在,每个元素含text/start/end
八、字段速查表
| 字段路径 | 类型 | 必填 | 单位/取值 |
|---|---|---|---|
segments |
array | ✅ | 字幕段数组 |
segments[i].start |
int | ✅ | 毫秒 |
segments[i].end |
int | ✅ | 毫秒 |
segments[i].text |
string | ✅ | 显示文本 |
segments[i].items |
array | 推荐 | 字级时间戳,可 [] |
segments[i].disabled |
bool | ❌ | 禁用该字幕 |
segments[i].items[k].text |
string | ✅ | 单字/词 |
segments[i].items[k].start |
int | ✅ | 毫秒 |
segments[i].items[k].end |
int | ✅ | 毫秒 |
segments[i].items[k].speaker |
string | ❌ | 说话人 opaque ID |
segments[i].speaker |
string | ❌ | 段内统一说话人才写入 |
segments[i].sticker |
object|null | ❌ | 表情包 head |
segments[i].sticker_ref |
object|null | ❌ | {name, headIdx} |
segments[i].color |
object|null | ❌ | {name, value, start, end} |
segments[i].color_ref |
object|null | ❌ | {name, headIdx} |
segments[i]._dirty |
bool | ❌ | 生成时不要写 |
media |
string | ❌ | 媒体文件路径 |
language |
string | ❌ | 语言代码 |
model |
string | ❌ | 模型名 |
sticker_root |
string | ❌ | 表情包根目录 |
waveform |
object | ❌ | 可丢弃的 moy.asr.waveform.v1 峰值缓存 |
gap_remove |
object | ❌ | 可逆的 moy.asr.gap_remove.v1 空隙移除决定 |
multi_subtitle |
object | ❌ | 可选的 moy.asr.multi_subtitle.v1 主轨/扩展轨与绑定 |
preview |
object | ❌ | 预览呈现设置容器 |
preview.subtitle.x |
number | ❌ | 归一化 [0,1],x + width <= 1 |
preview.subtitle.y |
number | ❌ | 归一化 [0,1],y + height <= 1 |
preview.subtitle.width |
number | ❌ | 归一化 [0,1],编辑器最小 0.20 |
preview.subtitle.height |
number | ❌ | 归一化 [0,1],编辑器最小 0.08 |
preview.subtitle.font_size |
number | ❌ | px,范围 [12,96];缺失时使用响应式默认字号 |
preview.subtitle.font_family |
string | ❌ | 内置字体键,或本机字体族名称;缺少该字体时预览回退到默认无衬线字体 |
preview.subtitle.background_color |
string | ❌ | 6 位十六进制颜色 #RRGGBB;缺失时使用黑色 |
preview.subtitle.background_alpha |
number | ❌ | 不透明度 [0,1];缺失时使用 0.65,设为 0 时隐藏字幕背景 |
preview.subtitle.color |
string | ❌ | #RRGGBB 六位十六进制颜色,默认 #ffffff |
preview.extension_subtitle |
object | ❌ | 拓展字幕样式;沿用主字幕预览框 |
preview.extension_subtitle.font_size |
number | ❌ | px,范围 [12,96];缺失时默认比主字幕小 2px |
preview.extension_subtitle.font_family |
string | ❌ | default / yahei / hei / song / sans |
preview.extension_subtitle.color |
string | ❌ | #RRGGBB 六位十六进制颜色,默认 #ffd34d |
preview.sticker.x |
number | ❌ | 归一化 [0,1],x + width <= 1 |
preview.sticker.y |
number | ❌ | 归一化 [0,1],y + height <= 1 |
preview.sticker.width |
number | ❌ | 归一化 [0,1],编辑器最小 0.20 |
preview.sticker.height |
number | ❌ | 归一化 [0,1],编辑器最小 0.08 |
九、版本与兼容
- 本规范与
edit.py/generate_subtitle_qwen_api.py当前实现同步 - 设计决策(字级时间戳为何重要、长音频切片策略等)见
CHANGELOG.md - 字段命名保持向后兼容:新增字段不会破坏旧 JSON 加载
- 旧编辑器会忽略新增的
waveform字段;新编辑器可加载完全不含该字段的旧工程 - 删除字段会触发兼容性记录到
CHANGELOG.md