直播回放的自动化生产流水线:从直播结束到"可学习的课程资产"

简介: 企业培训直播的价值只有 30% 在直播本身,70% 在回放——大量学员是事后看回放完成学习的。但"直播结束"到"回放可学"之间隔着一条完整的工程流水线:直播流不是视频文件,需要录制、转码、切片、分发;回放也不只是"能播放",还要打点定位、防拖拽刷课、与 AI 知识萃取打通。本文拆解这套流水线的完整设计与实现,覆盖服务端录制、转码任务编排、直播事件时间轴对齐、回放观看控制、以及回放资产与 AI 能力的联动,给出可直接落地的架构与核心代码。

一、问题拆解:直播流到课程资产的五个断点

一场 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 资产化"的顺序落地:前两步解决"能看",中间两步解决"好用",最后一步把直播变成可复用的资产。

目录
相关文章
|
5天前
|
人工智能 自然语言处理 安全
阿里云AI数智鉴密:AI 生成内容如何拿到一张"防篡改的身份证"
隐形水印 + C2PA签名:让AI生成内容“持证上岗”。
1125 0
|
13天前
|
人工智能 自然语言处理 安全
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
本文聚焦阿里云2026年推出的三款自研AI办公产品,清晰拆解千问办公、Qoder Teams、Qoder CN的差异化定位与能力边界:千问办公主打职场全场景提效,支持自然语言指令一键完成PPT生成、数据分析等高频办公任务;Qoder Teams面向程序员团队,深度整合AI代码生成、团队协同与企业知识库能力;Qoder CN则专为金融、政务等强合规场景打造,实现数据不出境与VPC私有化部署。文章同步给出分场景选型指南与最新活动定价,帮助不同类型的企业按需组合产品,实现业务岗、研发岗与强合规场景的AI能力全覆盖。
3755 4
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
|
5天前
|
人工智能 运维 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8,六大核心能力重构企业全自动化工作流与计费选型指南
传统AI办公工具大多停留在对话问答、文档摘要、简单文案生成层面,只能完成单点碎片化任务,无法自主拆解复杂业务流程,很难串联多工具、多文档、外部业务系统完成端到端完整工作交付。很多企业在落地AI办公的时候,需要组合多款不同工具,来回切换界面,手动复制粘贴中间结果,智能化改造落地门槛居高不下。千问办公QwenWork是整合多款智能体产品能力打造的一体化企业办公智能体平台,底层基座依托Qwen3.8大模型,打通桌面端Agent、云端Agent、企业协同Agent三种运行形态,不再局限简单问答,接收业务目标之后自主拆解任务步骤,调用各类工具,处理文档、表格、浏览器自动化、数据查询,直接输出可交付的办公
1444 0
|
5天前
|
人工智能 安全 前端开发
刚刚 GPT-6 Astra 发布,全球最强,AGI 时代到来!
OpenAI 正式推出 GPT-6 Astra 模型,带大家看看这次 GPT 有哪些提升,跟 Claude Fable 5.1 有什么差距?AI 编程能力如何?AGI 真的来了么?
622 0
|
11天前
|
人工智能 并行计算 数据可视化
秋叶ComfyUI-AKI最新整合包|完整部署教程+核心指令手册
秋叶ComfyUI-AKI一键整合包,国内适配最优、稳定性最强的商用/学习级版本:全封装虚拟环境、预装90%常用节点、内置绘世启动器与成熟工作流,免配置、零依赖、解压即用,完美兼顾新手入门与专业批量生产需求。(239字)
|
14天前
|
人工智能 Linux iOS开发
Ollama使用教程:Ollama官网下载、Ollama本地部署大模型(2026最新)
Ollama 是一款免费开源的本地大模型运行工具,支持在 Windows/macOS/Linux 上离线运行 Qwen、DeepSeek、Llama 等主流开源模型,数据不出本机、隐私安全。提供 OpenAI 兼容 API,命令行一键拉取/运行/管理模型,无需联网,无调用限制,是开发者与 AI 爱好者部署本地 AI 助手的理想选择。(239 字)