直播回放的自动化生产流水线:从直播结束到"可学习的课程资产"
一、问题拆解:直播流到课程资产的五个断点
一场 90 分钟的企业培训直播结束,学员想看回放。看似简单的需求,背后有五个必须打通的断点。
断点一:直播流不是视频文件。 直播使用的是 RTC(实时通信)或 RTMP(推流分发)协议,流是"边产生边消费"的,没有天然的"完整文件"概念。要得到回放,必须在直播过程中或结束时把流"录"下来——录制方案的选型直接决定回放的质量和成本。
断点二:录制产物不能直接点播。 服务端录制出来的是高码率的原始格式(如 FLV 或高码率 TS),直接给学员播放会带来两个问题:文件太大(90 分钟直播可能 2-3GB),带宽成本高;部分浏览器不兼容该格式,播放失败率高。必须转码成点播友好的多清晰度 MP4/HLS。
断点三:90 分钟的回放没有结构。 学员最常见的需求是"讲师讲分布式事务那一段在第几分钟"——如果没有章节和知识点定位,学员只能拖进度条碰运气。直播过程中沉淀的事件(翻页、签到、连麦问答)如果直接丢弃,回放就成了一段"不可检索的视频"。
断点四:回放可以被"刷"。 培训完成率考核场景下,学员打开回放直接拖到最后一秒标记完成,是运营团队最头疼的问题之一。回放的观看控制(防拖拽、时长校验、行为上报)是必修课。
断点五:回放的价值没有二次开发。 回放入库后,如果只是"能播放",那它只是一段视频。如果能自动触发 ASR 转录、知识点萃取、AI 出题,它就变成了可检索、可测验、可关联的"课程资产"。这是直播投入产出比的关键放大器。
这条流水线可以概括为:录制 → 转码 → 打点 → 看控 → 资产化,五个环节串联,每个环节独立成模块,通过消息队列衔接。
二、整体架构:事件驱动的五段流水线
┌──────────────────────────────────────────────────────────────┐
│ 直播进行中(直播服务) │
│ RTC/RTMP 直播流 直播事件流(翻页/签到/连麦/提问) │
└───────┬──────────────────────┬───────────────────────────────┘
│ │
┌───────▼──────────┐ ┌───────▼────────────────────────────┐
│ ① 服务端录制 │ │ 直播事件时间轴记录器 │
│ (Recorder) │ │ (Event Timeline Recorder) │
│ 合流录制/分轨录制 │ │ 事件 + 相对直播开始的偏移量 │
└───────┬──────────┘ └───────┬────────────────────────────┘
│ 直播结束事件 │
│ (MQ: LIVE_ENDED) │
┌───────▼──────────────────────▼──────────────────────────────┐
│ 流水线编排器 (Pipeline Orchestrator) │
└───┬──────────┬──────────┬──────────┬──────────┬─────────────┘
│ │ │ │ │
┌───▼───┐ ┌───▼───┐ ┌────▼────┐ ┌───▼────┐ ┌───▼─────────┐
│ ② 转码 │ │ 快照 │ │ ③ 时间轴 │ │ 封面 │ │ ⑤ AI 资产化 │
│ 任务 │ │ 缩略图│ │ 对齐器 │ │ 生成 │ │ (ASR→萃取→ │
│ 队列 │ │ │ │ │ │ │ │ 出题) │
└───┬───┘ └───────┘ └────┬────┘ └────────┘ └───┬─────────┘
│ │ │
┌───▼────────────────────▼──────────────────────▼─────────────┐
│ ④ 回放入库:媒体库 + 时间轴元数据 + 观看控制策略 │
│ CDN 分发 · 打点播放器 · 防拖拽 · 行为上报 │
└─────────────────────────────────────────────────────────────┘
四个关键设计决策:
录制与转码分离。 录制发生在直播服务的媒体服务器上,转码发生在独立的转码集群。两者通过"直播结束"事件解耦——录制完立即上传原始文件到对象存储,转码集群按需消费。这样直播服务的扩容不依赖转码能力,转码集群也可以在闲时处理积压任务。
事件时间轴与直播流并行记录。 直播过程中的业务事件(翻页、签到、连麦)由独立的"事件时间轴记录器"按相对直播开始的偏移量记录。时间轴和视频是两份独立的数据,回放入库时做对齐——这是打点回放的基础。
流水线事件驱动。 直播结束 → 转码完成 → 缩略图完成 → 时间轴对齐完成 → 回放发布,每个环节完成后发出下一个事件,编排器跟踪整体状态。任何环节失败可以单独重试,不用从头跑。
AI 资产化异步触发。 回放发布后自动触发 ASR 转录 → 知识点萃取 → AI 出题的级联任务。这条 AI 链路耗时较长(90 分钟视频约需 10-15 分钟),不阻塞回放发布——学员可以立即看回放,知识点和测验题在后台陆续就绪。
三、服务端录制:合流还是分轨
录制的核心选型是"合流录制"还是"分轨录制",两者决定了后续流水线的复杂度。
合流录制:把直播的所有流(讲师摄像头、屏幕共享、白板)在服务端混合成一个画面(常见布局:大屏显示共享内容 + 角标显示讲师头像),录制成单个文件。
分轨录制:每路流单独录制,回放时由前端播放器按时间轴动态合成画面。
| 维度 | 合流录制 | 分轨录制 |
|---|---|---|
| 文件复杂度 | 单文件,处理简单 | 多文件,需对齐管理 |
| 回放清晰度 | 混流时已损失(共享内容被压缩) | 每路流保留原始清晰度 |
| 后期剪辑灵活性 | 无法拆分画面 | 可以事后调整布局、放大共享内容 |
| 转码成本 | 单文件转码,成本低 | 多文件转码,成本高 2-3 倍 |
| 播放器要求 | 普通 HTML5 播放器 | 定制播放器(多流同步) |
企业培训场景的推荐选择是合流录制为主、共享内容高分辨率分轨为辅:合流文件作为"通用回放"(任何设备直接播),屏幕共享流单独录制一份高清版本,在回放播放器中提供"查看高清 PPT"入口。这样兼顾了通用性和关键内容的清晰度。
"""
服务端录制管理器(基于 FFmpeg)
合流录制方案:
- 讲师摄像头流(RTMP/RTC)
- 屏幕共享流(RTMP/RTC)
- 在服务端用 FFmpeg 混流:共享内容全屏 + 摄像头画中画
"""
import subprocess
import threading
class LiveRecorder:
"""直播服务端录制器"""
def __init__(self, stream_configs: dict):
"""
Args:
stream_configs: 各路流的拉流地址
{
"camera": "rtmp://media-server/live/teacher_camera",
"screen": "rtmp://media-server/live/teacher_screen",
}
"""
self.streams = stream_configs
self.process = None
self.start_time = None
def start_composite_recording(self, output_path: str):
"""
开始合流录制
布局:屏幕共享全屏(1920x1080),摄像头右上角画中画(320x180)
"""
camera_url = self.streams["camera"]
screen_url = self.streams["screen"]
cmd = [
"ffmpeg",
# 输入 0:屏幕共享流
"-i", screen_url,
# 输入 1:摄像头流
"-i", camera_url,
# 滤镜:摄像头缩放到 320x180,叠加到 (1560, 40) 位置
"-filter_complex",
"[0:v]scale=1920:1080[screen];"
"[1:v]scale=320:180[camera];"
"[screen][camera]overlay=1560:40:shortest=0[out]",
"-map", "[out]",
# 音频:取摄像头的音频(讲师说话)
"-map", "1:a",
# 输出编码:H.264 + AAC,录制友好参数
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-c:a", "aac", "-b:a", "128k",
# 时间戳:使用流的时间戳,保证与直播事件对齐
"-use_wallclock_as_timestamps", "1",
# 输出格式:MP4(录制结束自动补全 moov box)
"-f", "mp4", "-movflags", "+faststart",
output_path,
]
self.start_time = time.time()
self.process = subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE
)
# 监控进程异常退出
threading.Thread(
target=self._watch_process, daemon=True
).start()
def start_highres_track_recording(self, screen_url: str, output_path: str):
"""
高清分轨录制:单独录制屏幕共享流(保留原始分辨率)
用于回放中的"查看高清 PPT"功能
"""
cmd = [
"ffmpeg",
"-i", screen_url,
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
"-an", # 屏幕共享通常不含音频
"-f", "mp4", "-movflags", "+faststart",
output_path,
]
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def stop(self) -> float:
"""停止录制,返回录制时长(秒)"""
if self.process:
# 发送 'q' 让 FFmpeg 优雅退出(保证 MP4 moov box 完整)
self.process.communicate(input=b"q")
duration = time.time() - self.start_time
return duration
return 0
def _watch_process(self):
"""监控录制进程:异常退出时告警"""
ret = self.process.wait()
if ret != 0:
# FFmpeg 异常退出(推流中断等),触发告警
alert_service.notify(
f"Recording process exited abnormally: code={ret}"
)
录制稳定性的三个细节:一是推流中断处理——讲师网络抖动导致推流断开时,FFmpeg 会读到 EOF 退出,录制管理器需要监听进程退出事件并尝试重连续录(重启 FFmpeg 用 -ss 对齐时间戳,或接受分段文件由后续流水线拼接);二是 MP4 的 moov box 问题——录制中途进程被杀会导致 MP4 缺少索引无法播放,用 -movflags +faststart 缓解,极端损坏时用 untrunc 类工具修复;三是录制时长上限——单文件超过 4 小时建议自动分段(比如每 2 小时切一个文件),避免单文件过大。
四、转码流水线:任务编排与多清晰度生成
录制文件上传到对象存储后,"直播结束"事件触发转码流水线。
4.1 转码任务编排器
/**
* 回放转码流水线编排器
*
* 状态机:
* UPLOADED → TRANSCODING → THUMBNAILING → TIMELINE_ALIGNING → READY
* 任何状态失败 → FAILED(可重试,从失败环节继续)
*/
@Service
public class ReplayPipelineOrchestrator {
@Autowired
private TranscodeTaskRepository transcodeRepo;
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 直播结束事件入口
*/
@RocketMQMessageListener(topic = "LIVE_ENDED", consumerGroup = "replay-pipeline")
public void onLiveEnded(LiveEndedEvent event) {
Long liveId = event.getLiveId();
String rawFileUrl = event.getRecordingUrl(); // 录制文件的对象存储地址
// 1. 创建流水线任务
ReplayPipelineTask task = new ReplayPipelineTask();
task.setLiveId(liveId);
task.setRawFileUrl(rawFileUrl);
task.setStatus(PipelineStatus.UPLOADED);
transcodeRepo.save(task);
// 2. 触发转码(第一步)
transcodeRepo.updateStatus(task.getId(), PipelineStatus.TRANSCODING);
rocketMQTemplate.syncSend(
"REPLAY_TRANSCODE",
TranscodeTask.builder()
.taskId(task.getId())
.inputUrl(rawFileUrl)
.outputProfiles(Arrays.asList(
new VideoProfile("1080p", 1920, 1080, 2500), // 码率 kbps
new VideoProfile("720p", 1280, 720, 1200),
new VideoProfile("480p", 854, 480, 600)
))
.build()
);
}
/**
* 转码完成事件:触发缩略图生成 + 时间轴对齐
*/
@RocketMQMessageListener(topic = "REPLAY_TRANSCODE_DONE", consumerGroup = "replay-pipeline")
public void onTranscodeDone(TranscodeDoneEvent event) {
ReplayPipelineTask task = transcodeRepo.getById(event.getTaskId());
// 并行触发三个后续任务
// 1. 缩略图 + 雪碧图(进度条预览用)
rocketMQTemplate.syncSend("REPLAY_THUMBNAIL",
ThumbnailTask.of(event.getTaskId(), event.getOutputUrls().get("720p")));
// 2. 时间轴对齐(用直播事件流打点)
rocketMQTemplate.syncSend("REPLAY_TIMELINE_ALIGN",
TimelineAlignTask.of(event.getTaskId(), task.getLiveId()));
// 3. 封面截取(取视频第 5 分钟的帧作为默认封面)
rocketMQTemplate.syncSend("REPLAY_COVER",
CoverTask.of(event.getTaskId(), event.getOutputUrls().get("720p"), 300));
transcodeRepo.updateStatus(task.getId(), PipelineStatus.THUMBNAILING);
}
/**
* 全部子任务完成:发布回放 + 触发 AI 资产化
*/
public void onAllSubtasksDone(Long taskId) {
ReplayPipelineTask task = transcodeRepo.getById(taskId);
// 1. 回放入库、状态置为可学习
replayService.publish(task.getLiveId(), task);
// 2. 异步触发 AI 资产化(不阻塞回放发布)
rocketMQTemplate.syncSend("REPLAY_AI_ASSETIZE",
AiAssetizeTask.of(task.getLiveId(), task.getOutputUrls().get("480p")));
// 3. 通知直播发起人
notificationService.notifyReplayReady(task.getLiveId());
}
}
4.2 转码 Worker:多清晰度与 HLS 切片
"""
转码 Worker(消费 REPLAY_TRANSCODE 任务)
产出:
1. 三档清晰度的 MP4(点播播放)
2. HLS 切片(m3u8 + ts,移动端和弱网场景)
3. 雪碧图(进度条悬停预览)
"""
import subprocess
import os
class TranscodeWorker:
def process(self, task):
input_url = task["inputUrl"]
output_base = f"/data/transcode/{task['taskId']}"
# Step 1: 并行生成三档清晰度(FFmpeg 单命令多输出)
profiles = task["outputProfiles"]
cmd = ["ffmpeg", "-i", input_url]
for p in profiles:
cmd += [
"-vf", f"scale={p['width']}:{p['height']}",
"-c:v", "libx264", "-preset", "medium",
"-b:v", f"{p['bitrateKbps']}k",
"-c:a", "aac", "-b:a", "128k",
"-f", "mp4", "-movflags", "+faststart",
f"{output_base}_{p['name']}.mp4",
]
subprocess.run(cmd, check=True, timeout=7200) # 最长 2 小时
# Step 2: 720p 版本转 HLS 切片(6 秒一片)
subprocess.run([
"ffmpeg", "-i", f"{output_base}_720p.mp4",
"-c:v", "libx264", "-preset", "medium", "-b:v", "1200k",
"-c:a", "aac", "-b:a", "128k",
"-hls_time", "6",
"-hls_playlist_type", "vod",
"-hls_segment_filename", f"{output_base}_720p/seg_%03d.ts",
f"{output_base}_720p/index.m3u8",
], check=True)
# Step 3: 雪碧图(每 10 秒一帧,10x20 网格拼图)
subprocess.run([
"ffmpeg", "-i", f"{output_base}_720p.mp4",
"-vf", "fps=1/10,scale=160:90,tile=10x20",
"-frames:v", "1",
f"{output_base}_sprite.jpg",
], check=True)
# Step 4: 上传产物到对象存储 + 发完成事件
outputs = self.upload_to_oss(output_base, task["taskId"])
self.emit_done_event(task["taskId"], outputs)
转码成本优化:90 分钟的直播回放,三档清晰度转码在 8 核机器上约需 20-30 分钟。实践中的优化手段:一是用 -preset 分档——480p 用 fast(用户对低清档画质容忍度高)、1080p 用 medium;二是闲时调度——非紧急的回放(比如夜间结束的直播)延迟到凌晨转码集群空闲时段处理,白天优先处理"直播刚结束就有学员等着看"的任务;三是按需转码——先只出 480p 让回放尽快可用,720p/1080p 后台补齐。
五、事件时间轴:让回放"可跳转"
这是整条流水线中技术含量最高的环节。直播过程中,业务系统会持续产生事件——讲师翻了一页 PPT、发起了一次签到、有学员连麦提了个问题。这些事件带着"相对直播开始的偏移量"被记录下来,回放生成后,时间轴成为播放器的章节锚点。
5.1 直播事件时间轴记录器
/**
* 直播事件时间轴记录器(直播进行时运行)
*
* 原则:所有事件记录"相对直播开始的毫秒偏移量",不记录绝对时间戳。
* 原因:回放视频的时间轴从 0 开始,相对偏移量可以直接映射为播放位置。
*/
@Service
public class LiveEventTimelineRecorder {
@Autowired
private LiveEventRepository eventRepository;
/**
* 直播开始时间锚点(直播开播时记录)
*/
public void onLiveStarted(Long liveId) {
redisTemplate.opsForValue().set(
"live:anchor:" + liveId, String.valueOf(System.currentTimeMillis())
);
}
/**
* 记录直播事件
*
* @param eventType 事件类型:
* PPT_PAGE_CHANGED - 讲师翻页(附带页码)
* SIGN_IN_STARTED - 发起签到
* HAND_RAISED - 学员举手
* MIC_CONNECTED - 学员连麦(附带学员名)
* QUESTION_ASKED - 学员提问
* QUIZ_STARTED - 发起随堂测验(附带题目 ID)
* SCREEN_SHARE_STARTED / STOPPED - 共享屏幕开始/结束
* KEYPOINT_MARKED - 讲师手动标记重点(产品可做的亮点功能)
*/
public void recordEvent(Long liveId, String eventType, String payloadJson) {
// 1. 计算相对偏移量
String anchorStr = redisTemplate.opsForValue().get("live:anchor:" + liveId);
long anchor = Long.parseLong(anchorStr);
long offsetMs = System.currentTimeMillis() - anchor;
// 2. 落库(直播事件流表)
LiveEvent event = new LiveEvent();
event.setLiveId(liveId);
event.setEventType(eventType);
event.setOffsetMs(offsetMs);
event.setPayload(payloadJson);
event.setRecordedAt(new Date());
eventRepository.save(event);
// 3. 同时投递到实时通道(供直播中观看的学员看实时字幕/提示)
rocketMQTemplate.convertAndSend(
"LIVE_EVENT_REALTIME:" + liveId, event
);
}
}
5.2 时间轴对齐器:事件流 → 回放章节
回放入库时,把原始事件流加工成"回放章节结构"。对齐的核心问题是:录制视频的 0 点和直播的 0 点不一定重合——直播服务先开播、录制几秒后才开始,或者推流中途断开重连导致录制分段。对齐器需要校准偏移。
/**
* 时间轴对齐器
*
* 职责:把直播事件流(相对直播开始的偏移量)转换为回放章节(相对视频开始的偏移量)
*
* 校准原理:
* 1. 基准点校准:取直播开始后的第一个"翻页事件",
* 在录制视频中通过 PPT 画面变化检测(帧差分)找到对应的画面变化点,
* 两者的差值就是时间轴偏移校准量 Δ。
* 2. 所有事件的应用偏移 = 事件偏移量 - Δ。
*/
@Service
public class TimelineAligner {
@Autowired
private LiveEventRepository eventRepository;
@Autowired
private FrameAnalyzer frameAnalyzer; // 视频帧分析服务
/**
* 对齐:直播事件流 → 回放章节
*/
public ReplayTimeline align(Long liveId, String videoUrl) {
// 1. 取直播事件流(按偏移量排序)
List<LiveEvent> events = eventRepository
.getByLiveIdOrderByOffset(liveId);
if (events.isEmpty()) {
return ReplayTimeline.empty(liveId);
}
// 2. 计算校准偏移量 Δ
long deltaMs = calibrateOffset(events, videoUrl);
// 3. 生成章节:翻页事件序列 → 章节结构
// 策略:连续的翻页事件归并为"一个 PPT 讲解段",
// 讲师手动标记的重点(KEYPOINT_MARKED)作为一级章节锚点
List<ReplayChapter> chapters = buildChapters(events, deltaMs);
// 4. 生成"精彩片段":连麦问答段、随堂测验段单独标记
List<Highlight> highlights = buildHighlights(events, deltaMs);
ReplayTimeline timeline = new ReplayTimeline();
timeline.setLiveId(liveId);
timeline.setVideoUrl(videoUrl);
timeline.setChapters(chapters);
timeline.setHighlights(highlights);
timeline.setCalibrationDeltaMs(deltaMs);
return timeline;
}
/**
* 校准偏移量
*
* 方法:找到直播开始后 60 秒内的第一个翻页事件,
* 在视频的 [0, 120s] 范围内做画面变化检测,
* 找到 PPT 翻页的画面突变帧,计算两者差值。
*/
private long calibrateOffset(List<LiveEvent> events, String videoUrl) {
// 找第一个翻页事件
LiveEvent firstPageChange = events.stream()
.filter(e -> "PPT_PAGE_CHANGED".equals(e.getEventType()))
.findFirst().orElse(null);
if (firstPageChange == null) {
return 0; // 没有翻页事件,无法校准,假设无偏移
}
// 在视频前 120 秒内检测画面突变点
// frameAnalyzer 用 FFmpeg 抽帧 + 帧差分(相邻帧像素差异 > 阈值判定为突变)
List<Double> sceneChangePoints = frameAnalyzer
.detectSceneChanges(videoUrl, 0, 120);
if (sceneChangePoints.isEmpty()) {
return 0;
}
// 找与翻页事件偏移最接近的画面突变点
double eventOffsetSec = firstPageChange.getOffsetMs() / 1000.0;
double nearestSceneChange = sceneChangePoints.stream()
.min(Comparator.comparingDouble(p -> Math.abs(p - eventOffsetSec)))
.orElse(eventOffsetSec);
// Δ = 视频中的位置 - 事件流中的位置
return (long) ((nearestSceneChange - eventOffsetSec) * 1000);
}
/**
* 构建章节
*/
private List<ReplayChapter> buildChapters(List<LiveEvent> events, long deltaMs) {
List<ReplayChapter> chapters = new ArrayList<>();
ReplayChapter current = null;
for (LiveEvent event : events) {
long videoOffsetMs = event.getOffsetMs() - deltaMs;
if (videoOffsetMs < 0) continue; // 校准后为负的事件(发生在录制开始前)丢弃
switch (event.getEventType()) {
case "KEYPOINT_MARKED":
// 讲师手动标记的重点:一级章节
JSONObject payload = JSON.parseObject(event.getPayload());
current = new ReplayChapter();
current.setTitle(payload.getString("title")); // 讲师标记时输入的标题
current.setStartMs(videoOffsetMs);
current.setSource(ChapterSource.MANUAL_MARK);
chapters.add(current);
break;
case "PPT_PAGE_CHANGED":
// 翻页事件:如果当前没有章节(讲师从未手动标记),用页码做章节
if (current == null || current.getSource() != ChapterSource.MANUAL_MARK) {
JSONObject p = JSON.parseObject(event.getPayload());
current = new ReplayChapter();
current.setTitle("PPT 第 " + p.getInteger("page") + " 页");
current.setStartMs(videoOffsetMs);
current.setSource(ChapterSource.PPT_PAGE);
chapters.add(current);
}
break;
default:
break;
}
// 关闭上一个章节
if (chapters.size() >= 2) {
ReplayChapter prev = chapters.get(chapters.size() - 2);
prev.setEndMs(videoOffsetMs);
}
}
return chapters;
}
/**
* 构建精彩片段:连麦问答、随堂测验
*/
private List<Highlight> buildHighlights(List<LiveEvent> events, long deltaMs) {
List<Highlight> highlights = new ArrayList<>();
LiveEvent micConnect = null;
for (LiveEvent event : events) {
long videoOffsetMs = event.getOffsetMs() - deltaMs;
if (videoOffsetMs < 0) continue;
if ("MIC_CONNECTED".equals(event.getEventType())) {
micConnect = event;
} else if ("QUESTION_ASKED".equals(event.getEventType()) && micConnect != null) {
// 连麦问答片段:从连麦开始到提问后 3 分钟
JSONObject q = JSON.parseObject(event.getPayload());
Highlight h = new Highlight();
h.setTitle("问答:" + StringUtils.abbreviate(q.getString("question"), 20));
h.setStartMs(micConnect.getOffsetMs() - deltaMs);
h.setEndMs(videoOffsetMs + 180 * 1000);
h.setType(HighlightType.QA);
highlights.add(h);
micConnect = null;
}
}
return highlights;
}
}
对齐后的时间轴 JSON(回放播放器直接消费):
{
"liveId": 88001,
"videoUrl": "https://cdn.example.com/replay/88001_720p.m3u8",
"calibrationDeltaMs": 4200,
"chapters": [
{ "title": "课程开场与目标", "startMs": 0, "endMs": 320000, "source": "MANUAL_MARK" },
{ "title": "PPT 第 5 页", "startMs": 320000, "endMs": 560000, "source": "PPT_PAGE" },
{ "title": "分布式事务核心原理", "startMs": 560000, "endMs": 1980000, "source": "MANUAL_MARK" }
],
"highlights": [
{ "title": "问答:两阶段提交的缺点?", "startMs": 1450000, "endMs": 1630000, "type": "QA" },
{ "title": "随堂测验:CAP 三选二", "startMs": 2400000, "endMs": 2520000, "type": "QUIZ" }
]
}
5.3 打点回放播放器
/**
* 打点回放播放器(前端)
*
* 功能:
* 1. 章节侧边栏:点击章节跳转到对应播放位置
* 2. 进度条打点标记:章节锚点和精彩片段在进度条上显示
* 3. 拖拽控制:观看控制策略的执行端(见第六节)
*/
export function ReplayPlayer({ replay }: { replay: ReplayData }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [currentChapter, setCurrentChapter] = useState(0);
// 章节跳转
const seekToChapter = (chapter: ReplayChapter) => {
videoRef.current!.currentTime = chapter.startMs / 1000;
};
// 根据播放位置高亮当前章节
const onTimeUpdate = () => {
const pos = videoRef.current!.currentTime * 1000;
const idx = replay.timeline.chapters.findIndex(
(c, i) =>
pos >= c.startMs &&
(i === replay.timeline.chapters.length - 1 || pos < c.endMs)
);
if (idx >= 0 && idx !== currentChapter) setCurrentChapter(idx);
};
return (
<div className="replay-player-layout">
<div className="video-container">
<video
ref={videoRef}
src={replay.videoUrl}
controls
onTimeUpdate={onTimeUpdate}
/>
{/* 进度条打点渲染 */}
<ProgressMarkers
chapters={replay.timeline.chapters}
highlights={replay.timeline.highlights}
duration={replay.durationMs}
/>
</div>
{/* 章节侧边栏 */}
<aside className="chapter-sidebar">
<h4>本节内容</h4>
{replay.timeline.chapters.map((ch, i) => (
<div
key={i}
className={`chapter-item ${i === currentChapter ? 'active' : ''}`}
onClick={() => seekToChapter(ch)}
>
<span className="chapter-title">{ch.title}</span>
<span className="chapter-time">{formatMs(ch.startMs)}</span>
</div>
))}
{/* 精彩片段(问答/测验) */}
<h4>精彩片段</h4>
{replay.timeline.highlights.map((h, i) => (
<div key={i} className="highlight-item" onClick={() => seekToChapter(h as any)}>
<span className="highlight-tag">{h.type === 'QA' ? '问答' : '测验'}</span>
<span className="highlight-title">{h.title}</span>
</div>
))}
</aside>
</div>
);
}
六、观看控制:防拖拽与完成校验
回放的完成率是培训运营的核心指标,防刷课是观看控制模块的唯一目标。
/**
* 回放观看控制服务
*
* 三层防线:
* 1. 客户端:拦截拖拽事件,只允许在小范围内快进(比如 ±15 秒)
* 2. 服务端:有效观看时长校验——客户端每 30 秒心跳上报播放位置,
* 服务端累计"有效时长"(位置连续递增的部分才计入)
* 3. 判定:有效时长 ≥ 视频时长 × 阈值(如 90%)才标记完成
*/
@Service
public class ReplayWatchControlService {
@Autowired
private WatchSessionRepository sessionRepo;
/**
* 观看心跳上报
*
* 客户端每 30 秒上报一次:
* { sessionId, currentPositionMs, playbackRate, timestamp }
*/
public void onHeartbeat(WatchHeartbeat heartbeat) {
WatchSession session = sessionRepo.getById(heartbeat.getSessionId());
// 有效性校验 1:播放倍速不能超过 2x(倍速刷课)
if (heartbeat.getPlaybackRate() > 2.0) {
session.setViolationCount(session.getViolationCount() + 1);
sessionRepo.save(session);
}
// 有效性校验 2:位置跳跃检测
long lastPos = session.getLastPositionMs();
long currentPos = heartbeat.getCurrentPositionMs();
long expectedMax = lastPos + 35000; // 30 秒心跳 + 5 秒容差
if (currentPos <= lastPos + 5000) {
// 回退或原地:不计入有效时长(允许 5 秒内的微调)
} else if (currentPos > expectedMax) {
// 跳跃超过心跳周期:只把"从头到上次位置"这段已验证的部分计入
// 本次跳跃部分标记为可疑,计入可疑时长
session.setSuspiciousMs(session.getSuspiciousMs() + (currentPos - expectedMax));
} else {
// 正常推进:计入有效观看时长
session.setEffectiveMs(
session.getEffectiveMs() + (currentPos - lastPos)
);
}
session.setLastPositionMs(currentPos);
sessionRepo.save(session);
}
/**
* 完成判定
*/
public CompletionResult checkCompletion(WatchSession session, long videoDurationMs) {
double effectiveRatio = (double) session.getEffectiveMs() / videoDurationMs;
double threshold = 0.9; // 有效观看 ≥ 90% 视频时长才算完成
boolean completed = effectiveRatio >= threshold;
// 可疑时长超过 10% 视频时长:进入人工核查名单
boolean needReview = session.getSuspiciousMs() > videoDurationMs * 0.1;
return new CompletionResult(completed, effectiveRatio, needReview);
}
}
前端拖拽拦截:完全禁止拖拽会伤害正常用户(“我就想回去再看一眼刚才那段”),实践中采用"允许回退、限制快进"策略——快进最多允许 15 秒(跳过片头广告的体验),回退不限。配合服务端的位置连续性校验,刷课成本被拉到"老老实实看"的程度。
七、AI 资产化:回放的二次加工
回放发布后触发 AI 级联任务,把"一段视频"变成"一套可检索、可测验的课程资产"。这条链路复用平台已有的 AI 能力(ASR 转录、知识点萃取、AI 出题)。
/**
* 回放 AI 资产化编排
*
* 级联任务链:
* 1. ASR 转录(48 分钟 720p 版本,成本最优)
* 2. 知识点萃取(基于转录文本 + 章节时间轴,产出知识点卡片)
* 3. AI 出题(基于知识点 + 转录文本,产出随堂测验题)
*
* 每步产物独立入库,失败不影响已完成的步骤。
*/
@Service
public class ReplayAiAssetizeService {
@RocketMQMessageListener(topic = "REPLAY_AI_ASSETIZE", consumerGroup = "replay-ai")
public void onReplayPublished(AiAssetizeTask task) {
Long liveId = task.getLiveId();
// Step 1: ASR 转录(生成带时间戳的全文转录)
String transcriptUrl = aiGateway.call(
"asr/transcribe",
Map.of("audioUrl", task.getVideoUrl(), "language", "zh"),
AsrResult.class
).getTranscriptUrl();
// Step 2: 知识点萃取(传入转录文本 + 已对齐的章节时间轴)
// 关键:章节时间轴作为先验信息传入,萃取的知识点直接带章节归属
ReplayTimeline timeline = timelineService.getByLiveId(liveId);
KnowledgeExtractResult extractResult = aiGateway.call(
"llm/extract",
Map.of(
"transcriptUrl", transcriptUrl,
"chapterHints", timeline.getChapters() // 章节作为先验
),
KnowledgeExtractResult.class
);
// 知识点入知识库(供企业知识检索复用)
knowledgeBaseService.ingestLiveReplay(liveId, extractResult);
// Step 3: AI 出题(基于知识点,挂在对应章节下)
for (ChapterKnowledgePoint kp : extractResult.getKnowledgePoints()) {
List<Question> questions = aiGateway.call(
"question/generate",
Map.of(
"sourceText", kp.getRelatedTranscript(),
"knowledgePointId", kp.getId(),
"count", 2
),
QuestionListResponse.class
).getQuestions();
// 题目挂到对应章节:学员学到该章节时弹出随堂测验
questionService.attachToChapter(liveId, kp.getChapterIndex(), questions);
}
// 完成:更新回放的资产化状态
replayService.markAiAssetsReady(liveId);
}
}
资产化完成后的回放,对学员是"带测验的打点视频",对企业是"知识库新增了一批带出处的知识点"——直播内容完成了从"一次性活动"到"长期资产"的转变。
八、效果与经验总结
这套流水线在多场企业培训直播中稳定运行,关键数据:
| 指标 | 数值 |
|---|---|
| 直播结束 → 回放可看(480p 先行) | 平均 8 分钟 |
| 直播结束 → 全清晰度 + 打点就绪 | 平均 25 分钟 |
| 时间轴校准误差 | P95 < 1.5 秒 |
| 回放章节点击跳转占比 | 63%(学员高度依赖章节导航) |
| 回放完成率(有效观看 ≥90%) | 71% |
| 刷课拦截率(可疑行为被标记) | 约 12% 的观看会话触发可疑标记 |
| AI 资产化总耗时(90 分钟直播) | 12-18 分钟,回放发布后异步完成 |
企学宝技术团队总结的四个核心经验:
录制方案决定流水线上限。 合流录制 + 屏幕共享高清分轨的组合,让回放既有"任何设备直接播"的通用性,又保留了关键内容的清晰度。全分轨方案在技术上更优雅,但转码成本和播放器复杂度对企业培训场景不划算。
时间轴是直播和点播之间的桥。 直播过程中顺手记录的事件流(翻页、签到、连麦、提问),在回放场景下价值翻倍——它们是章节锚点、精彩片段、AI 萃取的先验信息。建议从产品层面就设计"讲师手动标记重点"功能,这是质量最高的一级章节来源。帧差分校准解决了录制起点偏移的工程问题,P95 误差控制在 1.5 秒内。
防刷课要"客户端拦截 + 服务端校验"双保险。 只做客户端拦截会被懂技术的学员绕过(直接调 API 上报心跳),只做服务端校验会让正常用户在前端随便拖。位置连续性 + 倍速上限 + 可疑时长阈值的三层服务端校验,把刷课成本拉到不值得的程度。
AI 资产化让直播投入产出比翻倍。 一场直播的直接覆盖是"到场学员",回放的覆盖是"全体学员",AI 资产化后的覆盖是"全体学员 + 企业知识库 + 测验题库"。直播内容的三次价值放大,全部由流水线自动完成,无需人工介入。
如果你的培训平台也在做直播回放,建议按"录制 → 转码 → 时间轴 → 看控 → AI 资产化"的顺序落地:前两步解决"能看",中间两步解决"好用",最后一步把直播变成可复用的资产。
- 点赞
- 收藏
- 关注作者
评论(0)