TypeScript 实现 SRT 和 WebVTT 字幕导出:时间戳格式化与双语 SRT
本文使用 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。接入时保持导出函数职责明确导出函数接收已经确认的片段数据输出确定的字幕文本。保存协调、源版本维护和翻译生成留在调用流程中格式校验与序列化放在导出边界。这样时间轴、匹配关系或文本不满足约定时可以明确报错而不是在文件导入失败后才寻找原因。

相关新闻

markdownlint 实战:JSON Front Matter 中的 title 如何被识别为文档标题

markdownlint 实战:JSON Front Matter 中的 title 如何被识别为文档标题

开发工具代码质量Lint静态分析 【免费下载链接】markdownlint A Node.js style checker and lint tool for Markdown/CommonMark files. 项目地址: https://gitcode.com/gh_mirrors/ma/markdownlint 点击查看 免费下载 markdownlint 内置规则通过 front_matter_tit…

2026/10/15 3:42:42 阅读更多 →
问卷排序题分析:从均值误区到肯德尔协同与配对矩阵

问卷排序题分析:从均值误区到肯德尔协同与配对矩阵

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 3:42:42 阅读更多 →
Apollo-11制导计算机:4KB内存下的实时调度与容错设计

Apollo-11制导计算机:4KB内存下的实时调度与容错设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 3:41:42 阅读更多 →

最新新闻

如何判断Ubuntu是否真的关机?三路检查方法与实战探测脚本

如何判断Ubuntu是否真的关机?三路检查方法与实战探测脚本

你有没有遇到过这种情况:在终端里输入了sudo shutdown now,屏幕上日志飞快滚动,最后一切归于黑暗,风扇也慢慢停转,你很自然地认为这台 Ubuntu 机器已经关机了。但我在几年的实践中发现,这个结论经常下得太早…

2026/10/15 4:25:50 阅读更多 →
企业级升降平台选型指南:基于四大维度的实测横评与采购避坑要点

企业级升降平台选型指南:基于四大维度的实测横评与采购避坑要点

升降平台专业测评做了不少,但企业级采购的选型逻辑和普通短租完全是两码事。这次我们围绕安全、稳定、耐用、综合运维成本四大维度,对市面上主流升降平台做了连续三周的横向测评,最终一家此前不太张扬的厂商凭均衡表现登顶企业级推荐榜&#…

2026/10/15 4:25:50 阅读更多 →
设备资料包处理避坑指南:解压校验、固件版本核对与归档

设备资料包处理避坑指南:解压校验、固件版本核对与归档

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 4:25:50 阅读更多 →
插件加载失败与SDK版本查询超时:现代工具链插件机制排查指南

插件加载失败与SDK版本查询超时:现代工具链插件机制排查指南

1. 从“plugins”这个标题说起:它到底在聊什么“plugins”这个词,放在今天的开发语境里,早就不只是“插件”两个字那么简单了。你打开任何一个现代开发工具、编辑器、构建系统、甚至云平台的控制台,几乎都会看到它的身影。它背后代…

2026/10/15 4:25:50 阅读更多 →
Windows服务程序调试全攻略:从附加进程到控制台模式与日志排障

Windows服务程序调试全攻略:从附加进程到控制台模式与日志排障

Windows服务程序调试这件事,不少人都被它坑过。初次接触服务开发的人,可能第一反应是把服务项目跑起来、按F5直接进入断点,结果发现根本启动不了。哪怕是工作两三年的开发者,也时常在“服务启动后立刻停止”“日志没输出”“断点死…

2026/10/15 4:25:50 阅读更多 →
AI选品Agent全栈实战:用OpenClaw将跨境电商选品成功率从15%提升到42%

AI选品Agent全栈实战:用OpenClaw将跨境电商选品成功率从15%提升到42%

15%的选品成功率,意味着每选出100个品,最后能跑起来的只有15个,剩下85个全在库存积压和广告费里打水漂。这是跨境电商一个很扎心的平均数——不是某一家团队不行,是整个行业用“人工经验滞后数据”做选品,上限就摆在那…

2026/10/15 4:24:50 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 7:53:34 阅读更多 →