SEI 详解一、什么是 SEISEISupplemental Enhancement Information补充增强信息是 H.264/H.265 码流中的一种 NAL 单元类型用于携带额外的元数据不影响画面解码。H.264/H.265 码流结构 ├── SPS (Sequence Parameter Set) 编码参数 ├── PPS (Picture Parameter Set) 图像参数 ├── SEI (Supplemental Enhancement Info) ← 自定义数据通道 ├── I 帧 (IDR) 关键帧 ├── P 帧 预测帧 └── ...核心特点解码器看到 SEI 会直接跳过不影响画面应用层可以从码流中提取 SEI 内容嵌入在码流内部跟随视频帧一起传输二、为什么需要 SEI当前项目用 SEI 做一件事在视频帧中嵌入时间戳。远程驾驶场景 车端采集帧 客户端播放 │ │ │ 帧采集时刻: │ 帧到达时刻: │ timestamp 1691234567890 │ 1691234570120 │ │ │ 网络传输 │ │ ───────────────────────────▶ │ │ │ │ 问题客户端怎么知道这帧 │ │ 是什么时候采集的 │ │ │ │ 方案把 timestamp 塞进 SEI │ │ │ │ SEI: {timestamp: │ │ 1691234567890} │ │ │ │ 客户端提取 SEI 中的时间戳 │ │ 计算延迟: 701120-567890 │ │ 1333ms 端到端延迟 │不嵌入 SEI 的话客户端只能用 RTP 头的时间戳但那是相对时间不知道真实采集时刻无法计算端到端延迟无法做音视频同步三、SEI 在码流中的位置一个 IDR 帧的完整码流H.265 为例 偏移 数据 含义 ────────────────────────────────────────────── 0x00 00 00 00 01 NAL 起始码 0x04 [40 01] VPS NAL 头 (type32) 0x06 [VPS 数据...] 00 00 00 01 NAL 起始码 [42 01] SPS NAL 头 (type33) [SPS 数据...] 00 00 00 01 NAL 起始码 [44 01] PPS NAL 头 (type34) [PPS 数据...] 00 00 00 01 NAL 起始码 [4E 01] SEI NAL 头 (type39) ← 注入的 SEI [05] payload_type5 (user_data_unregistered) [payload_size] [UUID 16字节] [JSON 时间戳数据] [80] RBSP 结束标记 00 00 00 01 NAL 起始码 [28 01] IDR NAL 头 (type19) ← 原始关键帧 [IDR 帧数据...]SEI 放在 IDR 帧前面解码器先读到 SEI跳过再读到 IDR 帧正常解码。四、SEI 注入的两种实现方式当前项目对 H.264 和 H.265 用了完全不同的方法方式 1H.264 — 使用 FFmpeg 内置 bitstream filter位置[video_push.cpp#L242-L281](file:///d:/city_code/video_push-master/src/video_push/src/video_push.cpp#L242-L281)if(packet_size50000)// 判断为关键帧{// 1. 准备 SEI 数据std::string extera_data66666666666666666666666666666666{\timestamp\:1691234567890};// 2. 获取 FFmpeg 内置的 h264_metadata filterconstAVBitStreamFilter*filterav_bsf_get_by_name(h264_metadata);// 3. 创建 filter 上下文AVBSFContext*bsfnullptr;av_bsf_alloc(filter,bsf);// 4. 设置 SEI 内容av_opt_set(bsf-priv_data,sei_user_data,extera_data.data(),...);// 5. 初始化 filteravcodec_parameters_copy(bsf-par_in,out_stream_-codecpar);av_bsf_init(bsf);// 6. 输入原始包 → 输出含 SEI 的包av_bsf_send_packet(bsf,pkt);// 输入av_bsf_receive_packet(bsf,pkt);// 输出自动注入了 SEI// 7. 释放av_bsf_free(bsf);}原理输入: [原始 H.264 帧] │ h264_metadata filterFFmpeg 内置 │ 自动解析 H.264 码流结构 │ 在关键帧前插入 SEI NAL ▼ 输出: [SEI NAL] [原始 H.264 帧]方式 2H.265 — 手动构造 SEI NAL 单元位置[video_push.cpp#L283-L362](file:///d:/city_code/video_push-master/src/video_push/src/video_push.cpp#L283-L362)因为 H.265 没有现成的 bitstream filter需要手动构造。步骤 1判断帧类型// 查找 NAL 起始码if(data[0]0data[1]0data[2]0data[3]1)offset4;// 4字节起始码elseif(data[0]0data[1]0data[2]1)offset3;// 3字节起始码// 解析 NAL 类型H.265 的 NAL 头是 2 字节uint8_tnal_type(data[offset]1)0x3F;H.265 NAL 头解析data[offset] 的二进制: [b7 b6 b5 b4 b3 b2 b1] [b0] │ 右移1位 → [0 b7 b6 b5 b4 b3 b2] 0x3F → [0 0 b5 b4 b3 b2 b1] │ 这 6 位 nal_type步骤 2决定是否注入if(nal_type0||nal_type1||// P/B 帧nal_type19||nal_type20||// IDR 帧nal_type16||nal_type17||// BLA 帧nal_type18||nal_type32)// CRA/VPS{// 在这些帧前面注入 SEI步骤 3手动构造 SEI NAL调用createSEIWithUserData位置[video_push.cpp#L450-L496](file:///d:/city_code/video_push-master/src/video_push/src/video_push.cpp#L450-L496)std::vectoruint8_tVideoPush::createSEIWithUserData(conststd::stringuser_data){// ① 固定 UUID16字节标识这是用户未注册数据constuint8_tuuid[]{0x2c,0xa2,0xde,0x09,0xb5,0x17,0x47,0xdb,0xbb,0x55,0xa4,0xfe,0x7f,0xc2,0xfc,0x4e};// ② payload UUID 用户数据std::vectoruint8_tpayload;payload.insert(payload.end(),uuid,uuid16);payload.insert(payload.end(),user_data.begin(),user_data.end());// ③ 构造 SEI NAL 单元std::vectoruint8_tsei_nal;// 起始码sei_nal{0x00,0x00,0x00,0x01};// NAL 头type39, PREFIX_SEI_NUTsei_nal.push_back(391);// 0x4Esei_nal.push_back(0x01);// temporal_id1// payload_type5user_data_unregisteredsei_nal.push_back(0x05);// payload_size可变长编码size_t remainingpayload.size();while(remaining0xFF){sei_nal.push_back(0xFF);remaining-0xFF;}sei_nal.push_back(remaining);// payload 内容sei_nal.insert(sei_nal.end(),payload.begin(),payload.end());// RBSP 结束标记sei_nal.push_back(0x80);returnsei_nal;}构造出的 SEI NAL 二进制结构偏移 字节 含义 ────────────────────────────────────────────── 00 00 00 00 01 起始码 04 4E NAL 头 byte1 (type39) 05 01 NAL 头 byte2 (temporal_id1) 06 05 payload_type5 (user_data_unregistered) 07 2A payload_size42 (16字节UUID 26字节数据) 08 2C A2 DE 09 12 B5 17 47 DB UUID (16字节固定标识) 16 BB 55 A4 FE 20 7F C2 FC 4E 24 36 36 36 36 ... 用户数据 (666666...{timestamp:...}) 2E 80 RBSP 结束标记步骤 4拼接 SEI 原始帧// 分配新缓冲区intnew_sizesei_nal.size()pkt-size;new_pkt-dataav_malloc(new_sizeAV_INPUT_BUFFER_PADDING_SIZE);// 先放 SEImemcpy(new_pkt-data,sei_nal.data(),sei_nal.size());// 再放原始帧memcpy(new_pkt-datasei_nal.size(),pkt-data,pkt-size);// 用新包替换旧包av_packet_unref(pkt);av_packet_move_ref(pkt,new_pkt);拼接结果[SEI NAL: 起始码NAL头UUID时间戳JSON0x80] [IDR NAL: 起始码NAL头原始帧数据]五、H.264 和 H.265 注入方式对比维度H.264H.265实现方式FFmpeg 内置h264_metadatafilter手动构造 NAL 单元代码量约 20 行约 80 行关键帧判断packet_size 50000启发式解析 NAL type精确SEI 位置filter 自动决定插入位置手动拼接在帧数据前面性能每次创建 filter 上下文开销较大直接内存拷贝开销小可靠性依赖 FFmpeg 实现依赖手动构造的正确性六、面试话术面试官SEI 注入是怎么做的回答SEI 是 H.264/H.265 码流中的补充增强信息我在其中嵌入了 JSON 格式的时间戳用于远程驾驶的音视频同步和延迟计算。H.264 和 H.265 的实现方式不同H.264 用了 FFmpeg 内置的h264_metadatabitstream filter传入sei_user_data参数即可H.265 没有现成 filter我查了标准文档手动构造了 SEI NAL 单元包括起始码、NAL 头type39、payload_type5用户未注册数据、UUID、payload、RBSP 结束标记然后拼接在原始帧前面注入策略是只在关键帧前注入因为关键帧是解码的起点保证客户端收到 SEI 后能立即使用。用户问的是三个问题为什么通过判断帧大小就能判断是否为关键帧为什么关键帧才需要注入 SEI什么是关键帧这三个问题是层层递进的需要先解释什么是关键帧再解释为什么关键帧更大最后解释为什么只在关键帧注入 SEI。让我用通俗易懂的方式解释。三个问题逐层解答一、什么是关键帧I 帧视频编码中帧分为两种视频帧序列I P P P P I P P P P I ... ↑ ↑ ↑ 关键帧 关键帧 关键帧 (I-frame) (I-frame) (I-frame)帧类型编码方式数据量解码方式I 帧关键帧只看当前帧独立编码大可以独立解码P 帧预测帧参考前一帧的差异编码小必须配合前几帧才能解码类比I 帧 一张完整的照片P 帧 照片相比上一张的改动部分比如人物移动了 2 像素二、为什么通过大小能判断关键帧H.264 编码后的帧大小对比典型值 I 帧关键帧: 40KB ~ 100KB → 包含完整画面信息 P 帧预测帧: 5KB ~ 20KB → 只包含前后帧的差异 代码中设置阈值 50000 字节约 50KB packet_size 50000 → 很可能是 I 帧 packet_size ≤ 50000 → 很可能是 P 帧为什么这样判断I 帧编码的是完整画面信息量大所以压缩后体积也明显更大。P 帧只存差异体积小。用 50KB 做阈值是一个粗略但实用的启发式判断在大多数场景下足够准确。局限性这不是 100% 准确的判断方式极端情况下可能误判。H.265 分支用解析 NAL 类型的方式判断更精确。三、为什么只在关键帧注入 SEI场景远程驾驶视频流 客户端拉流时的解码过程 1. 客户端接入流 → 必须等到一个 I 帧才能开始解码 P 帧必须基于前一帧解码没有 I 帧就无从开始 2. 如果 SEI 注入在 P 帧上 客户端刚接入时 → 先等 I 帧 → 开始解码 → 后面的 P 帧才带 SEI 时间戳信息晚于画面起点 3. 如果 SEI 注入在 I 帧上 客户端刚接入时 → 等到 I 帧 → 同时获得画面 时间戳 时间戳信息与画面起点同步核心原因原因说明解码起点客户端从 I 帧开始解码SEI 放在 I 帧上确保解码起点就有时间戳避免丢失P 帧可能因为网络抖动丢弃I 帧是解码器重点保障的帧降低开销只在部分帧注入减少 CPU 开销SEI 注入涉及 filter 上下文创建实际场景假设每 30 帧有 1 个 I 帧约 1 秒一个那么时间戳的精度就是秒级。对于远程驾驶的音视频同步和延迟计算秒级精度已经足够。面试追问准备Q为什么 H.264 和 H.265 的判断方式不同回答H.264 没有现成的 bitstream filter 可以用来精确判断帧类型所以用大小做粗略判断。而 H.265 可以通过解析 NAL 头中的 nal_type 精确判断所以用了更精确的方式。实际上 H.264 也可以解析 NAL 类型但代码作者选择了更简单的方案。Q如果 50000 字节的阈值不准会怎么样回答最坏情况是偶尔把 P 帧误判为 I 帧注入了 SEI但 P 帧不是解码起点或者把 I 帧误判为 P 帧漏掉了 SEI。前者浪费了一些 CPU后者导致偶尔丢失时间戳。在实际场景中H.264 的 I 帧和 P 帧大小差异通常在 5 倍以上50KB 阈值足够稳定。