本文使用 TypeScript 和 Node.js将带毫秒时间戳的转写片段导出为原文 SRT、原文 WebVTT 与双语 SRT。核心流程是校验源数据、格式化时间、按索引匹配译文再按源顺序生成字幕块。工程背景来自 Vimorph 的转写与字幕模块。示例独立运行使用手写样本并增加输入校验与原文版本匹配它不调用真实识别/翻译服务也不是当前业务代码的完整副本。时间戳与文件结构输出文件头毫秒分隔符文本原文 SRT无专用文件头cue 前有序号逗号如00:00:01,250原文原文 WebVTTWEBVTT后接空行点如00:00:01.250原文双语 SRT与 SRT 相同逗号同一 cue 中先原文再译文SRT 的常见文本块由序号、起止时间、正文和空行组成可参考美国国会图书馆的格式说明。WebVTT 的文件头、时间与 cue 文本按 W3C 格式文档核对查阅版本为候选推荐草案具体环境的读取仍需实测。时间戳表示从媒体开始累计的时长不是日期。示例用整除和余数得到小时、分钟、秒和毫秒超过 24 小时也不会回到零点。它接受非负安全整数毫秒小数、缺失值和非有限值直接报错不在格式化时悄悄截为零。保留源数组顺序同时要求开始时间非递减、结束晚于开始。相邻片段可以重叠示例不会自动排序或重新对齐音频。重叠字幕在不同播放器中的呈现可能不同双语文本也会占据更多显示空间。字幕碰撞、自动断行和阅读速度优化不在这个导出函数的职责内。源片段索引与字幕文件序号当前业务的原文导出支持 TXT/SRT/WebVTT译文和双语导出支持 TXT/SRT。源片段有index、开始和结束毫秒、文本译文只需要关联索引与文本。时间范围继续由源片段提供。下面是示例的完整src/types.ts与配套工程一致exporttypeSegment{index:number;startMillis:number;endMillis:number;text:string;};exporttypeTranscript{sourceId:string;revision:number;segments:Segment[]};exporttypeTranslation{sourceId:string;sourceRevision:number;segments:{index:number;text:string}[];};exporttypeSubtitleFormatsrt|vtt;sourceId标识同一个任务或源文revision和sourceRevision则标识这份源文的版本。三者都是示例约定真实接入时需要由应用维护只有来源和版本同时相同才能按片段索引配对。这两种编号要分开segment.index用于找译文SRT 文件序号表示输出顺序从 1 重新编号。源索引即使是 10、20、99文件中仍然输出 1、2、3。不能按两个数组的相同位置配对也不能把业务索引原样当作文件序号。图中用两个片段说明对应关系配套命令行样本有三个片段。这里只做文本映射和文件序列化不重新计算语音时间也不做音频对齐。完整导出实现下面是完整的src/subtitle-export.ts。它依赖上面的types.ts在配套工程中编译运行。导出前先检查对象、数组和片段形状再检查时间、索引和文本双语路径还会核对来源与版本。importtype{SubtitleFormat,Transcript,Translation}from./types.js;exporttype{Segment,SubtitleFormat,Transcript,Translation}from./types.js;functionrequireObject(value:unknown,name:string):assertsvalueisRecordstring,unknown{if(typeofvalue!object||valuenull||Array.isArray(value)){thrownewError(${name}必须是对象);}}functionrequireSourceId(value:unknown,name:string):assertsvalueisstring{if(typeofvalue!string||!value.trim())thrownewError(${name}必须是非空字符串);}functionnonNegativeInteger(value:unknown,name:string):assertsvalueisnumber{if(typeofvalue!number||!Number.isSafeInteger(value)||value0){thrownewError(${name}必须是非负安全整数);}}functionrequireFormat(format:SubtitleFormat):void{if(format!srtformat!vtt)thrownewError(仅支持 SRT 和 VTT);}exportfunctionformatTimestamp(ms:number,format:SubtitleFormat):string{nonNegativeInteger(ms,时间);requireFormat(format);consthoursMath.floor(ms/3_600_000);constminutesMath.floor(ms/60_000)%60;constsecondsMath.floor(ms/1_000)%60;constfractionms%1_000;constpad(value:number,width:number)String(value).padStart(width,0);constseparatorformatsrt?,:.;return${pad(hours,2)}:${pad(minutes,2)}:${pad(seconds,2)}${separator}${pad(fraction,3)};}functionplainText(text:unknown):string{if(typeoftext!string)thrownewError(字幕文本必须是字符串);if(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(text)){thrownewError(字幕文本不能包含控制字符);}constnormalizedtext.replace(/\r\n?/g,\n).trim();if(!normalized)thrownewError(字幕文本不能为空);if(normalized.split(\n).some(line!line.trim())){thrownewError(字幕文本不能包含内部空白行);}returnnormalized;}functionpayload(text:unknown,format:SubtitleFormat):string{constnormalizedplainText(text);if(formatsrt){// 保留普通 和比较符号拒绝像标签/注释的片段避免读取器样式差异。// 这是示例策略不是通用 SRT 限制也不是 HTML 净化器。if(/\/?[A-Za-z][^]*|!--/.test(normalized)){thrownewError(本示例的 SRT 不接受像 HTML 标签或注释的片段);}returnnormalized;}returnnormalized.replace(//g,amp;).replace(//g,lt;).replace(//g,gt;);}functionvalidateTranscript(transcript:unknown):assertstranscriptisTranscript{requireObject(transcript,原文);requireSourceId(transcript.sourceId,原文 sourceId);nonNegativeInteger(transcript.revision,原文 revision);if(!Array.isArray(transcript.segments))thrownewError(原文 segments 必须是数组);if(!transcript.segments.length)thrownewError(没有可导出的字幕片段);constindexesnewSetnumber();letpreviousStart-1;for(constsegmentoftranscript.segments){requireObject(segment,原文片段);nonNegativeInteger(segment.index,片段 index);if(indexes.has(segment.index))thrownewError(原文 index 重复${segment.index});indexes.add(segment.index);nonNegativeInteger(segment.startMillis,开始时间);nonNegativeInteger(segment.endMillis,结束时间);if(segment.endMillissegment.startMillis)thrownewError(结束时间必须晚于开始时间);if(segment.startMillispreviousStart)thrownewError(片段开始时间必须非递减);previousStartsegment.startMillis;plainText(segment.text);}}functioncue(number:number,start:number,end:number,text:string,format:SubtitleFormat):string{constprefixformatsrt?${number}\n:;return${prefix}${formatTimestamp(start,format)}--${formatTimestamp(end,format)}\n${text};}exportfunctionexportTranscript(transcript:Transcript,format:SubtitleFormat):string{requireFormat(format);validateTranscript(transcript);constcuestranscript.segments.map((segment,position)cue(position1,segment.startMillis,segment.endMillis,payload(segment.text,format),format,));return(formatvtt?WEBVTT\n\n:)cues.join(\n\n)\n\n;}exportfunctionexportBilingualSrt(transcript:Transcript,translation:Translation):string{validateTranscript(transcript);requireObject(translation,译文);requireSourceId(translation.sourceId,译文 sourceId);nonNegativeInteger(translation.sourceRevision,译文 sourceRevision);if(!Array.isArray(translation.segments))thrownewError(译文 segments 必须是数组);if(translation.sourceId!transcript.sourceId)thrownewError(译文 sourceId 与当前原文不一致);if(translation.sourceRevision!transcript.revision)thrownewError(译文快照与当前原文 revision 不一致);constsourceIndexesnewSet(transcript.segments.map(segmentsegment.index));consttranslatednewMapnumber,string();for(constsegmentoftranslation.segments){requireObject(segment,译文片段);nonNegativeInteger(segment.index,译文 index);if(translated.has(segment.index))thrownewError(译文 index 重复${segment.index});if(!sourceIndexes.has(segment.index))thrownewError(译文存在额外 index${segment.index});translated.set(segment.index,payload(segment.text,srt));}constcuestranscript.segments.map((segment,position){consttranslatedTexttranslated.get(segment.index);if(translatedTextundefined)thrownewError(缺少 index${segment.index}的译文);consttext${payload(segment.text,srt)}\n${translatedText};returncue(position1,segment.startMillis,segment.endMillis,text,srt);});returncues.join(\n\n)\n\n;}exportTranscript()按源数组生成 cueSRT 序号用position 1WebVTT 本例省略可选 cue 标识。双语函数先建立Mapindex, text再遍历源片段取译文译文返回顺序不会决定字幕输出顺序。TypeScript 类型只约束编译阶段接口仍可能传来null、缺失数组或非对象片段。入口先检查这些形状返回明确的Error避免读取字段时意外出现TypeError。建立 Map 前检查重复索引是为了避免后一个值静默覆盖前一个值。缺译文、额外索引和空文本同样拒绝让调用方修正输入不通过填空字符串或跳过片段产生一个看似完整的文件。文本会把 CRLF/CR 统一为 LF、去掉外层空白保留内部非空的多行。内部空白行可能改变字幕块边界所以本例拒绝控制字符在裁剪空白前检查避免先被trim()去掉。空行拒绝与空文件拒绝是示例的输入策略不表示所有字幕格式都要求如此。WebVTT 路径把、、转成字符引用原文中的标签也作为普通文本处理不让识别文本直接变成 cue 标记。SRT 保留普通和裸 例如“研发 测试1 23 2”不套用 HTML 实体转义。部分 SRT 读取器会解释样式标签因此本例用有限规则拒绝完整的字母开头标签和!--注释开头。这是本例选择不是 SRT 的通用禁令规则可能拒绝类似example的字面文本也不是 HTML 净化器。需要保留这类内容或样式时应先明确目标读取器的行为。运行示例并查看输出工程要求 Node.js 22.12.0 或更高开发依赖固定为 TypeScript 5.9.3、types/node22.19.19。没有 React 或 FFmpeg 的运行依赖。完整示例工程包含类型、导出函数、手写样本、测试和四个生成文件。解压后运行cd subtitle-export-demo npm install npm test npm run demonpm test先编译 TypeScript再执行 Node 内置测试npm run demo使用编译后的入口。下面是完整src/demo.tsimport{mkdirSync,writeFileSync}fromnode:fs;import{exportBilingualSrt,exportTranscript,typeTranscript,typeTranslation}from./subtitle-export.js;// 本地手写样本不是真实识别或翻译结果。consttranscript:Transcript{sourceId:demo-transcript,revision:3,segments:[{index:10,startMillis:0,endMillis:900,text:先确认时间格式。},{index:20,startMillis:1_250,endMillis:2_450,text:多行字幕\r\n仍属于同一条 cue。},{index:99,startMillis:3_599_999,endMillis:3_601_250,text:这一条跨过一小时。},],};consttranslation:Translation{sourceId:demo-transcript,sourceRevision:3,segments:[{index:99,text:This cue crosses the one-hour mark.},{index:10,text:Check the timestamp format first.},{index:20,text:A multiline subtitle\nis still one cue.},],};mkdirSync(output,{recursive:true});writeFileSync(output/sample.srt,exportTranscript(transcript,srt),utf8);writeFileSync(output/sample.vtt,exportTranscript(transcript,vtt),utf8);writeFileSync(output/bilingual.srt,exportBilingualSrt(transcript,translation),utf8);writeFileSync(output/special.srt,exportTranscript({sourceId:demo-special,revision:1,segments:[{index:5,startMillis:1_250,endMillis:2_301,text:研发 测试1 23 2}],},srt),utf8);console.log(已生成 output/sample.srt、sample.vtt、bilingual.srt、special.srt均为手写测试样本。);原文索引不连续译文故意乱序第二段含多行第三段跨过一小时。程序写出output/sample.srt、output/sample.vtt、output/bilingual.srt另用output/special.srt检查普通特殊字符。四份文件统一采用 UTF-8 与 LF。双语文件开头的输出节选1 00:00:00,000 -- 00:00:00,900 先确认时间格式。 Check the timestamp format first. 2 00:00:01,250 -- 00:00:02,450 多行字幕 仍属于同一条 cue。 A multiline subtitle is still one cue.第三条由原文 index 99 对应译文 index 99但文件序号仍是 3。双语使用原文时间范围不因译文长度增加就自动延长显示时间阅读速度和断行属于后续字幕编辑问题。导出前确认保存与原文版本用户修改了文本导出仍读取旧内存对象就可能下载到旧内容。Vimorph 原文导出会先等待保存再读取最新任务当前译文导出重新读取译文列表但这不能保证源文与译文是一份原子快照。结构变化比文本变化更难处理。后端替换分段时按位置重新编号拆分、合并或删除以后即使旧译文仍有相同的数字索引它指向的内容也可能变了。因此按索引配对只在双方来自同一版源数据时成立。示例同时要求translation.sourceId transcript.sourceId和sourceRevision revision不匹配就拒绝导出避免不同任务的相同版本号互认。每次影响译文对应关系的源修改都必须更新版本生成译文时保存来源与版本若一直沿用旧标识检查就没有保护作用。标识相同也不能证明译文语义正确。当前业务已经记录sourceUpdatedAt但前端没有用它校验旧译文缺失时间、重复索引和缺译文也没有本例的严格校验。示例把导出前的检查集中在函数入口真实集成还需要补齐调用方的保存协调与版本维护。实际验证范围验证层本次结果现有前端相关测试2 个文件共 6 项通过其中导出 2 项、字幕时间格式 1 项另 3 项为显示工具独立 TypeScript 示例编译成功40 项测试通过覆盖对象/数组形状、时间边界、索引集合、来源与版本、文本策略和完整输出命令行输出四个字幕文件生成成功源码、正文完整代码与 ZIP 逐文件一致ffprobe 6.0原有三份样本各 3 条特殊字符样本 1 条开始时间与持续时间匹配Chromium 1484 项实际 WebVTT 解析检查通过生成样本、多行与字符引用、重叠片段、超过 24 小时测试于 2026 年 10 月 14 日执行。现有测试和独立工程使用 Node.js 26.7.0示例编译器为 TypeScript 5.9.3。另用 FFmpeg 6.0 实际读取并重新写出special.srt普通和比较符号保持一致。来源与版本检查的运行结果仅对应本次独立示例。全部输入均为手写文本和时间数据。浏览器检查读取 WebVTT cue 的时间与文本没有播放真实视频或验证所有样式FFmpeg 工具的解析也不等于所有剪辑软件兼容。Safari、移动端、目标视频平台导入、真实识别/翻译准确性以及 Vimorph 保存到导出的业务端到端均未验证。本次复用了已安装依赖没有从空环境重跑npm install。接入时保持导出函数职责明确导出函数接收已经确认的片段数据输出确定的字幕文本。保存协调、源版本维护和翻译生成留在调用流程中格式校验与序列化放在导出边界。这样时间轴、匹配关系或文本不满足约定时可以明确报错而不是在文件导入失败后才寻找原因。