在综艺 Reaction 视频的制作流程里,最耗时的工作往往不是录制本身,而是回看完整素材并反复拖动进度条。以《换乘恋爱 4》EP-17 这样的关键集数为例,观众往往会把某枚戒指的出现视为“剧情炸弹”,因为戒指一出现,关系走向就发生明显反转;制作团队要快速在几十秒内定位这个镜头,还要在对应位置插入自己的表情、评价或字幕,才能形成完整的 Reaction 内容。如果只靠大脑记时间码,或者用记事本记一句“大概第 8 分钟”,效率很低,也无法复用。下面就从解决这个实际痛点出发,编写一个基于原生 HTML5 Video API 的高能节点标记播放器,把 EP-17 里的“戒指炸弹”“自尊心冲突”这类节点变成可点击、可跳转、可导出的时间轴标记。
这个项目不依赖 Vue、React 或构建工具,只需要一个浏览器和一个本地静态服务器就能跑通。核心思路很简单:视频播放器负责播放,JavaScript 维护一个带时间戳的节点数组,再通过 DOM 渲染成可点击的标记列表。这样无论是个人剪片前整理素材,还是小团队一起拉片对齐剧情爆点,都能比纯手工打点节省大量时间。
1. 为什么 Reaction 剪辑需要高能节点标记器
1.1 高能片段是协作对齐的核心单位
Reaction 视频的观赏性和制作效率,很大程度上取决于“高能片段”是否被准确捕捉。所谓高能片段,不是指视频中每一秒都有戏剧冲突,而是指那些真正会引发观众情绪波动的节点。在 EP-17 这样的单集内容里,高能节点往往有明确的视觉道具或台词信号,比如“戒指炸弹”对应一个反转镜头,“放下一点自尊心”对应一段关系对话。
如果没有工具,剪辑人员通常这么做:先完整看一遍素材,凭记忆记录大概时间点;然后写脚本时再次跳转核对;最后在剪辑软件里逐段剪切。一个 90 分钟的单集,反复找点可能耗时 1 到 2 小时。更麻烦的是,如果两个人同时看,一个人说“在第 8 分钟左右”,另一个人一拖就拖到第 10 分钟,对齐成本非常高。
高能节点标记器的价值,就是把这些模糊的口头描述变成结构化的数据:这个片段发生在哪一秒,是什么类型,用户看到这里应该注意什么。所有信息聚合在一个时间轴上,谁都可以快速跳转到指定节点。
1.2 三种素材整理方式对比
先看普通 Excel 表格、纯文本笔记和播放器内嵌标记三种方案的区别。这里用表格做一个直观对比:
| 方案 | 记录方式 | 跳转效率 | 协作复用 | 适合场景 |
|---|---|---|---|---|
| Excel 表格 | 手写时间列、备注列 | 低,要手动在播放器输入时间 | 可用网盘共享,但不同步 | 临时记录少量节点 |
| 纯文本笔记 | 写下“戒指炸弹 482s” | 低,需要手动拖进度条 | 可复制,但容易漏看 | 个人简单打点 |
| 播放器内标记 | 在播放器中点击添加,自动收集时间 | 高,点击节点直接跳转 | 可导出 JSON 再共享 | 剪辑素材整理 |
| 在线协作标记 | 后端存储标记,多人同时编辑 | 高,实时同步 | 高,权限可控 | 团队项目制作 |
从表格能看出,播放器内嵌标记比纯文本方案多了一层交互能力:点击节点时可以直接修改video.currentTime,这是 Excel 和笔记做不到的。
1.3 这个 Demo 能做什么
下面的示例项目会实现一个最小可用闭环:
- 输入视频地址并加载。
- 在任意播放位置添加标记,记录当前秒数。
- 标记包含类型、标签名、备注,例如“戒指炸弹”“自尊心冲突”。
- 自动按时间排序,并在列表中跳转。
- 支持删除标记。
- 支持导出 JSON 和导入 JSON。
- 使用
localStorage本地持久化,刷新页面后不丢失。
在这个能力基础上,后续可以扩展后端保存、多人协作、视频帧缩略图等生产功能。
2. 先理解 HTML5 Video 的时间控制能力
2.1 video 元素的核心属性与方法
HTML5 原生<video>元素已经把播放器能力封装得比较完整。这个项目只用到其中最核心的几个:
src:视频地址,可以是本地相对路径、服务器绝对地址或可直接播放的媒体 URL。currentTime:当前播放位置,单位是秒,可以读取也可以赋值。duration:视频总时长,单位是秒。play():开始播放,返回 Promise。pause():暂停播放。timeupdate:播放位置变化时触发的事件。
在实现标记跳转时,最重要的代码只有一句:
video.currentTime = marker.time;给currentTime赋一个合法的秒数后,浏览器会立刻把播放进度跳到对应位置,并触发后续的timeupdate、seeked等事件。
2.2 timeupdate 事件的工作方式
timeupdate是项目实现“自动播报高能节点”的依赖事件。它不需要手动定时器,浏览器会在媒体播放位置变化时触发。实际触发频率取决于浏览器实现,通常在 250 毫秒到 4 秒之间。对于这个 Demo 来说,每秒钟触发几次足够用来判断是否接近某个标记。
这里要注意,timeupdate的触发并不是绝对均匀的。系统繁忙、视频卡顿、浏览器节能策略都可能让它变得稀疏。所以不要依赖它做精确到帧的业务,只能作为普通 UI 更新与提示的触发信号。
video.addEventListener('timeupdate', () => { const current = video.currentTime; // 在这里判断是否逼近某个高能节点 const next = markers.find((marker) => { return marker.time > current && marker.time - current <= 5; }); if (next) { // 弹出提示 } });上面代码中的marker.time - current <= 5表示判断“当前时间距离标记时间 5 秒以内”,用于提前提示。这个阈值可以根据素材类型调整,综艺类可以设大一点,纯影视解说可以设小一点。
2.3 currentTime 跳转的边界情况
currentTime虽然可以随意赋值,但有几个边界情况需要提前处理:
- 赋值小于 0,浏览器会按 0 处理,但更稳妥的做法是
Math.max(0, time)。 - 赋值大于
duration,浏览器会跳到视频结尾并触发ended。 - 视频没有加载完成时,
duration可能是NaN,此时跳转可能不生效。 - 用户在标记列表里点击跳转时,最好先判断
video.src是否已设置。
这些边界情况会在后文的核心代码中通过校验和兜底处理来解决。
3. 项目结构与页面骨架
3.1 项目目录与运行方式
这是一个零依赖的前端小项目,目录结构可以保持简单:
reaction-highlight-player/ ├── index.html ├── style.css ├── app.js └── highlights.example.json直接用浏览器打开index.html也可以运行。不过如果使用远程视频地址,有些浏览器会限制自动播放或全屏,建议放到本地静态服务器里访问。这里提供一个最简单的启动方式:
python3 -m http.server 8080然后在浏览器中访问:
http://localhost:8080如果电脑上安装的是 Node.js,也可以用:
npx serve .两种方式任选一种即可。关键点是保证页面和视频文件在同一来源下,避免后续做视频截图或复用媒体资源时出现跨域问题。
3.2 index.html 页面骨架
index.html负责页面结构和引入脚本。不需要复杂框架,只需要两个区域:视频播放区域和标记管理区域。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Reaction 高能节点标记播放器 Demo</title> <link rel="stylesheet" href="style.css"> </head> <body> <main class="player-shell"> <section class="video-area"> <video id="videoPlayer" controls preload="metadata"></video> <div class="toolbar"> <label> 视频地址: <input id="videoUrl" type="url" placeholder="输入本地或远程视频地址"> </label> <button id="loadVideoBtn" type="button">加载</button> </div> </section> <section class="marker-area"> <h2>高能节点标记</h2> <form id="markerForm"> <input id="markerTime" type="number" min="0" step="1" placeholder="时间点(秒)"> <select id="markerType"> <option value="bomb">反转/炸弹</option> <option value="emotion">情感冲突</option> <option value="dialogue">关键台词</option> <option value="custom">自定义</option> </select> <input id="markerLabel" type="text" placeholder="标签名,例如:戒指炸弹"> <textarea id="markerNote" placeholder="备注说明"></textarea> <button type="submit">添加标记</button> <button id="exportBtn" type="button">导出 JSON</button> <button id="importBtn" type="button">导入 JSON</button> <input id="importFile" type="file" accept=".json" hidden> </form> <ul id="markerList"></ul> </section> </main> <script src="app.js"></script> </body> </html>关键点有两个:
<video controls preload="metadata">中controls让浏览器显示原生控制条,preload="metadata"表示只预加载元数据,避免进入页面就缓冲整段视频。- 导入按钮是一个隐藏的文件输入框,由“导入 JSON”按钮触发点击,这样 UI 上不会出现浏览器默认的文件选择样式。
3.3 标记的数据结构
标记的数据结构直接决定后续扩展是否方便。一个合理的 JSON 节点可以这样定义:
{ "id": 1710000000001, "time": 482, "type": "bomb", "label": "戒指炸弹出现", "note": "镜头特写,标记为反转节点", "createdAt": "2025-01-01T10:00:00.000Z" }字段含义如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | number | 标记唯一 ID,用时间戳生成 |
| time | number | 事件发生的秒数 |
| type | string | 标记类型,如 bomb、emotion 等 |
| label | string | 短标签名 |
| note | string | 备注,可以写更详细的剪辑思路 |
| createdAt | string | ISO 时间,记录创建时间 |
之所以保留id和createdAt,是为了将来接入后端后可以做增量同步和冲突处理。导出文件时可以统一包含一个版本号:
{ "version": 1, "markers": [] }这个设计在后续扩展中会比只存一个数组更稳妥。
4. 核心 JS 实现:标记的增删改查与跳转
4.1 全局状态与 localStorage
app.js里先定义全局状态。标记数组是唯一核心数据源,所有 UI 都从它渲染。
const video = document.getElementById('videoPlayer'); const videoUrlInput = document.getElementById('videoUrl'); const loadVideoBtn = document.getElementById('loadVideoBtn'); const markerForm = document.getElementById('markerForm'); const markerTime = document.getElementById('markerTime'); const markerType = document.getElementById('markerType'); const markerLabel = document.getElementById('markerLabel'); const markerNote = document.getElementById('markerNote'); const markerList = document.getElementById('markerList'); const exportBtn = document.getElementById('exportBtn'); const importBtn = document.getElementById('importBtn'); const importFile = document.getElementById('importFile'); const STORAGE_KEY = 'reaction-highlight-markers'; let markers = []; let promptedMarkerId = null;STORAGE_KEY是localStorage的键名。markers是页面内存中的标记数组。页面刷新后通过loadMarkers()从本地恢复。
function loadMarkers() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; const data = JSON.parse(raw); return Array.isArray(data) ? data : []; } catch (error) { console.warn('本地存储解析失败,已重置', error); localStorage.removeItem(STORAGE_KEY); return []; } } function saveMarkers() { localStorage.setItem(STORAGE_KEY, JSON.stringify(markers)); }很多人在写本地持久化时忘记处理JSON.parse异常。如果用户曾经在控制台手动修改过数据,或者存储内容被截断,JSON.parse会直接抛错,导致整个脚本停摆。这里用try/catch包住,并且在异常时清空本地存储,是更稳的做法。
4.2 添加标记
添加标记时,不直接把表单值塞进数组,而是先做类型校验和时间排序。
function addMarkerFromForm(event) { event.preventDefault(); const time = Number.parseFloat(markerTime.value); if (!Number.isFinite(time) || time < 0) { alert('请输入合法的秒数'); return; } markers.push({ id: Date.now(), time, type: markerType.value, label: markerLabel.value.trim(), note: markerNote.value.trim(), createdAt: new Date().toISOString() }); markers.sort((a, b) => a.time - b.time); saveMarkers(); renderMarkers(); markerForm.reset(); }这里有两个容易踩的坑:
markerTime.value从input[type=number]拿到的是字符串,必须用Number.parseFloat转数字,再用Number.isFinite判断。- 标记添加后要排序,否则列表展示顺序会乱。后面做“下一个高能节点提示”时也依赖有序数组。
4.3 渲染标记列表
渲染列表时,如果直接用innerHTML拼接用户输入内容,会存在 XSS 风险。比如某个标记的label写成<img src=x onerror=alert(1)>,插入 DOM 时就会执行脚本。最简单的方式是写一个escapeHtml方法。
function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }然后渲染列表:
function formatTime(seconds) { const s = Math.max(0, Math.floor(seconds)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; const pad = (n) => n.toString().padStart(2, '0'); return h > 0 ? `${pad(h)}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`; } function getTypeName(type) { const map = { bomb: '反转/炸弹', emotion: '情感冲突', dialogue: '关键台词', custom: '自定义' }; return map[type] || type; } function renderMarkers() { markerList.innerHTML = ''; markers.forEach((marker, index) => { const li = document.createElement('li'); li.dataset.index = index; li.innerHTML = ` <div class="marker-main"> <strong>${formatTime(marker.time)}</strong> <span class="type-tag">${getTypeName(marker.type)}</span> <span>${escapeHtml(marker.label || '未命名')}</span> </div> <p class="marker-note">${escapeHtml(marker.note || '')}</p> <div class="marker-actions"> <button class="jump-btn" type="button">跳转</button> <button class="delete-btn" type="button">删除</button> </div> `; li.querySelector('.jump-btn').addEventListener('click', () => jumpToMarker(marker)); li.querySelector('.delete-btn').addEventListener('click', () => removeMarker(index)); markerList.appendChild(li); }); }这里没有使用insertAdjacentHTML,而是先createElement,再在里面使用innerHTML,最后绑定事件。这种写法在原生 JS 里比较直观,且可以精准给每个按钮绑定当前标记。
4.4 跳转与删除
跳转的关键逻辑是设置video.currentTime。注意播放器没有加载视频时,src为空,跳转不会生效,需要给出提示。
function jumpToMarker(marker) { if (!video.src) { alert('请先加载视频'); return; } const targetTime = Math.max(0, marker.time); if (Number.isFinite(video.duration) && targetTime > video.duration) { alert('标记时间超出视频总时长'); return; } video.currentTime = targetTime; video.play().catch(() => { console.warn('自动播放被浏览器拦截,请手动点击播放'); }); }video.play()返回 Promise。在某些浏览器中,即使用户点击了按钮,自动播放策略也可能阻止播放。这里用catch捕获,避免产生未处理的 Promise 异常。
删除标记只需要从数组中移除一项,然后重新渲染:
function removeMarker(index) { markers.splice(index, 1); saveMarkers(); renderMarkers(); }删除后必须重新保存并渲染,否则列表和本地存储之间会出现不一致。
4.5 导出与导入 JSON
导出功能让标记可以在不同设备之间复用。核心是使用Blob创建一个 JSON 文件,然后模拟点击下载链接。
function exportJSON() { const data = { version: 1, markers }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `reaction-highlights-${Date.now()}.json`; a.click(); URL.revokeObjectURL(url); }URL.revokeObjectURL用于释放内存。下载完成后如果不释放,重复导出会积累大量临时 URL。
导入时读取文件内容并解析 JSON:
importBtn.addEventListener('click', () => { importFile.click(); }); importFile.addEventListener('change', async (event) => { const file = event.target.files[0]; if (!file) return; try { const text = await file.text(); const data = JSON.parse(text); if (!data || !Array.isArray(data.markers)) { throw new Error('文件格式不正确,缺少 markers 数组'); } markers = data.markers; saveMarkers(); renderMarkers(); } catch (error) { console.error('导入失败', error); alert('导入失败:' + error.message); } importFile.value = ''; });导入成功后,页面中已有的标记会被导入数据整体替换。这个行为在真实项目中需要二次确认,避免误覆盖。
5. 加入高能节点自动提示
5.1 触发逻辑
高能节点自动提示是把这个工具从“手动标记器”升级成“剪辑辅助工具”的关键功能。当视频播放到某个标记前 5 秒时,页面顶部弹出一条提示,告诉用户即将到达哪个节点。
这里需要一个showToast方法。不引入第三方库,直接用 DOM 创建提示元素:
function showToast(message) { let toast = document.querySelector('.toast'); if (!toast) { toast = document.createElement('div'); toast.className = 'toast'; document.body.appendChild(toast); } toast.textContent = message; toast.style.opacity = '1'; clearTimeout(toast._timer); toast._timer = setTimeout(() => { toast.style.opacity = '0'; }, 3000); }然后在timeupdate事件中判断下一个节点:
function findNextMarker(currentTime) { return markers.find((marker) => { return marker.time > currentTime && marker.time - currentTime <= 5; }); } video.addEventListener('timeupdate', () => { const nextMarker = findNextMarker(video.currentTime); if (nextMarker && nextMarker.id !== promptedMarkerId) { showToast(`即将到达:${nextMarker.label || '高能节点'} ${formatTime(nextMarker.time)}`); promptedMarkerId = nextMarker.id; } });这里把“接近 5 秒”的标记找出来,并且只在第一次进入范围时提示。用户如果停留在同一个标记附近反复拖动进度条,不会重复打扰。
5.2 用 promptedMarkerId 去重
promptedMarkerId是一个状态标记,用来记录“上次提示过的节点 ID”。当视频继续播放并越过当前节点后,findNextMarker会返回空或返回下一个节点,promptedMarkerId会被更新,因此同一个节点不会连续触发。
如果没有这个去重逻辑,会出现什么情况?由于timeupdate每秒触发多次,条件满足的每一帧都会调用showToast,页面上会连续弹出多个提示,体验非常差。所以去重不是可选项,而是必须的逻辑。
更严格的做法是在标记列表里维护一个hasPrompted字段,但会影响导出数据。用内存变量promptedMarkerId更轻量。
5.3 自动播放策略的影响
自动提示本身不是播放操作,不会触发自动播放限制。但用户在点击标记跳转后,如果调用video.play(),会被部分浏览器的自动播放策略拦截。最明显的表现是:视频已经跳到对应位置,但处于暂停状态。
处理方式是在jumpToMarker中明确调用video.play()并捕获 Promise:
video.play().catch(() => { // 保持暂停状态,等待用户手动播放 });如果项目要求点击后必须播放,可以考虑在提示弹层中增加“手动点击后播放”的交互,而不是绕过浏览器策略。由于这是前端技术实现,不能通过强制设置来规避浏览器的安全策略,只能从产品逻辑上做降级。
6. 运行验证与常见问题排查
6.1 最小验证路径
写完代码后,按下面的路径验证才是完整的闭环:
- 启动本地静态服务器。
- 在文本框输入一个可播放的视频地址,点击“加载”。
- 视频播放到某个关键位置,暂停,输入时间点或直接使用当前时间,填写标签与备注。
- 点击“添加标记”,确认列表中出现对应节点。
- 点击“跳转”,确认播放进度跳到指定时间。
- 点击“导出 JSON”,确认得到一个合法 JSON 文件。
- 刷新页面,确认标记仍然存在。
- 点击“导入 JSON”,选择刚才导出的文件,确认列表恢复。
其中第 6 到 8 步最容易被人跳过。很多人在本地调试时只验证“能添加”,没有验证持久化和导出,结果换一台电脑后标记全部丢失,才意识到问题。
6.2 常见问题排查表
实际运行中,比较常见的问题和排查路径可以整理成表格:
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 添加标记后列表不刷新 | JS 报错,或localStorage不可用 | 打开控制台看报错 | 修复语法错误;隐私模式下改用内存方案 |
| 点击跳转没有效果 | 视频未加载,或currentTime超出时长 | 检查video.src和duration | 先加载视频;跳转前做数值校验 |
| 导入文件后标记为空 | JSON 结构不对,缺少markers数组 | 手动打开 JSON 检查 | 导入时校验Array.isArray(data.markers) |
| 自动提示重复弹出 | 缺少promptedMarkerId去重 | 看控制台日志 | 增加去重状态 |
| 视频地址加载失败 | 地址非媒体直链,或服务器禁止访问 | 用浏览器直接打开该地址 | 换成可直链的媒体地址 |
| 刷新后标记丢失 | localStorage被清空,或在不同浏览器打开 | 检查 Application 面板 | 使用导出 JSON 做备份 |
6.3 至少三个容易踩的坑
第一个坑是直接读markerTime.value当作数字用。表单输入框的值永远是字符串,直接参与计算可能会出现“0 + '1'”这样的字符串拼接问题。必须用Number.parseFloat转换并校验。
第二个坑是渲染列表时没有做 HTML 转义。标记的标签和备注来自用户输入,如果包含 HTML 片段,轻则样式被破坏,重则形成 XSS 漏洞。即使这是一个本地工具,也建议写escapeHtml,形成习惯。
第三个坑是使用timeupdate做精确判断。这个事件不是稳定帧级触发,不要在事件里做昂贵操作。提示、更新currentTime这类轻量 UI 操作可以,不要做重计算或复杂 DOM 操作,否则会造成视频卡顿。
第四个容易忽略的问题是跨源视频。<video>标签播放远程视频时,如果远程服务器不允许跨域,播放通常可以正常,但使用 Canvas 截图或读取视频帧数据时会报错。如果后续要自动生成标记缩略图,需要确保视频服务器返回Access-Control-Allow-Origin响应头。
7. 生产化扩展:从单机标注到团队协作
7.1 标记数据搬到后端
当前 Demo 把标记保存在localStorage,这只适合单机调试。真实团队协作时,需要把标记字段提交到后端,常见接口设计如下:
POST /api/episodes/17/highlights { "time": 482, "type": "bomb", "label": "戒指炸弹出现", "note": "镜头特写,标记为反转节点" }后端需要做的几件事:
- 按剧集 ID 隔离数据,避免不同内容串数据。
- 记录创建人、更新时间,方便追溯。
- 支持列表查询和增量更新,避免前端一次加载全部数据。
- 对
time字段建立索引,便于按时间区间查询。
前端可以从“本地数组 + 渲染”升级为“服务端状态 + 缓存”。使用 React 或 Vue 后,标记列表可以抽成独立组件,配合 WebSocket 实现多人同时拉片时的实时同步。
7.2 自动识别高能片段的方向
手动标记虽然准确,但效率有上限。如果想进一步降低人工成本,可以考虑自动识别高能片段,常见方案有:
- 音频分析:检测音量异常升高、人声情绪激动,自动生成候选节点。
- 字幕关键词:当字幕中出现“戒指”“炸弹”“分手”“误会”等关键词时,自动插入候选标记。
- 画面变化:检测镜头切换频率、场景亮度突变等,辅助定位反转镜头。
这些方向都适合作为后期扩展,不建议在一开始就做。先把手动标记流程跑通,再根据真实素材积累节点样本,训练或调优规则才有意义。
7.3 对接剪辑软件与导出规范
当标记列表积累到一定规模后,需要把数据导出到剪辑软件。不同软件的工程格式差异很大,但很多剪辑软件支持 CSV 时间码导入。可以从 JSON 生成一个简单 CSV:
time,label,note,type 00:08:02,戒指炸弹出现,镜头特写反转节点,bomb 00:12:00,自尊心冲突,双方对话张力较大,emotion另外也可以将标记点转换成 XML 或项目文件里的标记轨道。导出前需要和剪辑团队确认时间基准,是使用视频媒体时间,还是使用时间轴轨道时间。两者如果差一个前导偏移,整批标记都会错位。
7.4 发布前检查清单
如果要把这个功能做成正式工具,发布前建议按以下清单逐项检查:
- 视频地址是否支持上传,还是只用远程地址。
- 标记数据是否包含用户身份,是否允许删除和编辑。
- 是否有批量导入和导出能力,导出格式是否满足下游工具要求。
- 是否有异常处理,例如视频加载失败、标记时间非法、JSON 格式错误。
- 是否对用户输入做转义,防止 XSS。
- 是否考虑移动端触摸操作。
- 是否限制单集标记数量,避免渲染性能问题。
- 是否记录操作日志,方便回溯误删或误改。
对刚接触这个场景的开发者来说,先跑通原生 HTML5 Video 版本,再把播放器逻辑迁移到组件化框架里,是性价比最高的路线。不要一开始就引入复杂工程,因为高能节点标记的核心难点从来不是框架,而是“时间轴数据如何组织”和“播放器事件如何驱动交互”这两个基础问题。把这两个问题想清楚,用原生 JS 写一遍,再换任何框架都会顺手很多。