lizhengwei

jira:NYJ-1540 desc: update llm_image.py

  1 +import base64
  2 +import hashlib
  3 +import subprocess
  4 +from pathlib import Path
  5 +
  6 +import cv2
  7 +
  8 +try:
  9 + from .m3u8_to_mp4_sei import (
  10 + download_m3u8_to_mp4,
  11 + extract_h264_es_from_mp4,
  12 + parse_h264_sei,
  13 + extract_utc_from_sei,
  14 + )
  15 +except ImportError:
  16 + from m3u8_to_mp4_sei import (
  17 + download_m3u8_to_mp4,
  18 + extract_h264_es_from_mp4,
  19 + parse_h264_sei,
  20 + extract_utc_from_sei,
  21 + )
  22 +
  23 +# 用于区分传入的 start_utc/end_utc 是绝对 UTC 时间戳还是秒偏移
  24 +_IS_ABSOLUTE_UTC_THRESHOLD = 1_000_000_000
  25 +
  26 +
1 class Video2Frame: 27 class Video2Frame:
2 def __init__(self, cache_dir): 28 def __init__(self, cache_dir):
3 - pass 29 + self.cache_dir = Path(cache_dir)
  30 + self.video_dir = self.cache_dir / "videos"
  31 + self.frames_dir = self.cache_dir / "frames"
  32 + self.video_dir.mkdir(parents=True, exist_ok=True)
  33 + self.frames_dir.mkdir(parents=True, exist_ok=True)
4 34
5 def to_frames(self, url, start_utc, end_utc, fps, roi=None, max_px_area=None) -> list: 35 def to_frames(self, url, start_utc, end_utc, fps, roi=None, max_px_area=None) -> list:
  36 + """
  37 + 下载 m3u8 视频流的指定片段并提取帧,保存到 cache_dir 下。
  38 +
  39 + - video_dir 中保存的是 start_utc ~ end_utc 截取后的视频片段(而非完整视频)。
  40 + - 如果视频中包含 SEI UTC 信息且传入的 start_utc/end_utc 为绝对时间戳,
  41 + 则会基于 SEI UTC 进行帧级定位;否则将 start_utc/end_utc 视为秒偏移量。
  42 +
  43 + Args:
  44 + url: m3u8 视频流地址。
  45 + start_utc: 截取开始时间(秒偏移或绝对 UTC 时间戳)。
  46 + end_utc: 截取结束时间(秒偏移或绝对 UTC 时间戳)。
  47 + fps: 目标抽帧帧率。
  48 + roi: 感兴趣区域 (x, y, w, h),可选。
  49 + max_px_area: 最大像素面积,超过则等比例缩小,可选。
  50 +
  51 + Returns:
  52 + 提取的帧图片路径列表。
  53 + """
  54 + if end_utc <= start_utc:
  55 + raise ValueError("end_utc 必须大于 start_utc")
  56 +
  57 + unique_str = f"{url}_{start_utc}_{end_utc}"
  58 + unique_id = hashlib.md5(unique_str.encode("utf-8")).hexdigest()
  59 +
  60 + clip_path = self.video_dir / f"{unique_id}.mp4"
  61 + frame_output_dir = self.frames_dir / unique_id
  62 + frame_output_dir.mkdir(parents=True, exist_ok=True)
  63 +
  64 + # 判断是否为绝对 UTC 时间戳
  65 + is_absolute_utc = (
  66 + start_utc > _IS_ABSOLUTE_UTC_THRESHOLD
  67 + and end_utc > _IS_ABSOLUTE_UTC_THRESHOLD
  68 + )
  69 +
  70 + # 若片段未缓存,先截取目标片段再保存
  71 + if not clip_path.exists():
  72 + if is_absolute_utc:
  73 + # 绝对 UTC 模式:先下载完整视频到临时文件,解析 SEI 后截取片段
  74 + temp_path = self.video_dir / f"{unique_id}_full.mp4"
  75 + download_m3u8_to_mp4(url, str(temp_path))
  76 +
  77 + # 提取 SEI UTC 信息
  78 + utc_records = []
  79 + try:
  80 + es_data = extract_h264_es_from_mp4(str(temp_path))
  81 + sei_list = parse_h264_sei(es_data)
  82 + utc_records = extract_utc_from_sei(sei_list)
  83 + except Exception:
6 pass 84 pass
7 85
  86 + if utc_records:
  87 + utc_list = [r["utc"] for r in utc_records]
  88 + start_frame_idx = self._find_frame_idx(
  89 + utc_list, start_utc, find_last_not_greater=False
  90 + )
  91 + end_frame_idx = self._find_frame_idx(
  92 + utc_list, end_utc, find_last_not_greater=True
  93 + )
  94 +
  95 + cap_temp = cv2.VideoCapture(str(temp_path))
  96 + video_fps = cap_temp.get(cv2.CAP_PROP_FPS)
  97 + cap_temp.release()
  98 + if video_fps <= 0:
  99 + video_fps = 25.0
  100 +
  101 + start_sec = start_frame_idx / video_fps
  102 + duration = (end_frame_idx - start_frame_idx) / video_fps
  103 + self._ffmpeg_extract_clip(
  104 + str(temp_path), str(clip_path), start_sec, duration
  105 + )
  106 + else:
  107 + # 无 SEI 无法定位,直接将完整视频重命名为片段
  108 + temp_path.rename(clip_path)
  109 +
  110 + # 清理临时完整视频
  111 + if temp_path.exists():
  112 + temp_path.unlink(missing_ok=True)
  113 + else:
  114 + # 秒偏移模式:直接用 ffmpeg 从 URL 截取片段
  115 + duration = end_utc - start_utc
  116 + self._ffmpeg_download_clip(
  117 + url, str(clip_path), start_utc, duration
  118 + )
  119 +
  120 + # 从截取后的片段中提取帧
  121 + cap = cv2.VideoCapture(str(clip_path))
  122 + if not cap.isOpened():
  123 + raise ValueError(f"无法打开视频片段: {clip_path}")
  124 +
  125 + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
  126 + video_fps = cap.get(cv2.CAP_PROP_FPS)
  127 + if video_fps <= 0:
  128 + video_fps = 25.0
  129 +
  130 + # 计算抽帧间隔
  131 + frame_interval = int(video_fps / fps) if fps > 0 else int(video_fps)
  132 + if frame_interval < 1:
  133 + frame_interval = 1
  134 +
  135 + frame_paths = []
  136 + saved_count = 0
  137 + frame_count = 0
  138 +
  139 + while True:
  140 + ret, frame = cap.read()
  141 + if not ret:
  142 + break
  143 +
  144 + if frame_count % frame_interval == 0:
  145 + if roi is not None:
  146 + x, y, w, h = roi
  147 + frame = frame[y:y + h, x:x + w]
  148 +
  149 + if max_px_area is not None:
  150 + h, w = frame.shape[:2]
  151 + area = h * w
  152 + if area > max_px_area:
  153 + scale = (max_px_area / area) ** 0.5
  154 + new_w = int(w * scale)
  155 + new_h = int(h * scale)
  156 + frame = cv2.resize(
  157 + frame, (new_w, new_h), interpolation=cv2.INTER_AREA
  158 + )
  159 +
  160 + frame_path = frame_output_dir / f"frame_{saved_count:06d}.jpg"
  161 + cv2.imwrite(str(frame_path), frame)
  162 + frame_paths.append(str(frame_path))
  163 + saved_count += 1
  164 +
  165 + frame_count += 1
  166 +
  167 + cap.release()
  168 + return frame_paths
  169 +
  170 + @staticmethod
  171 + def _find_frame_idx(utc_list, target_utc, find_last_not_greater=True):
  172 + """
  173 + 在 utc_list 中查找最接近 target_utc 的帧索引。
  174 +
  175 + Args:
  176 + utc_list: 按帧顺序排列的 UTC 列表。
  177 + target_utc: 目标 UTC。
  178 + find_last_not_greater: True 返回最后一个 <= target_utc 的索引;
  179 + False 返回第一个 >= target_utc 的索引。
  180 + """
  181 + if not utc_list:
  182 + return 0
  183 +
  184 + if find_last_not_greater:
  185 + idx = 0
  186 + for i, utc in enumerate(utc_list):
  187 + if utc > target_utc:
  188 + break
  189 + idx = i
  190 + return idx
  191 + else:
  192 + for i, utc in enumerate(utc_list):
  193 + if utc >= target_utc:
  194 + return i
  195 + return len(utc_list) - 1
  196 +
  197 + @staticmethod
  198 + def _ffmpeg_extract_clip(input_path, output_path, start_sec, duration):
  199 + """使用 ffmpeg 从本地视频截取指定片段。"""
  200 + cmd = [
  201 + "ffmpeg", "-y",
  202 + "-i", input_path,
  203 + "-ss", str(start_sec),
  204 + "-t", str(duration),
  205 + "-c", "copy",
  206 + "-bsf:a", "aac_adtstoasc",
  207 + "-movflags", "+faststart",
  208 + output_path,
  209 + ]
  210 + try:
  211 + subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  212 + except subprocess.CalledProcessError as e:
  213 + raise RuntimeError(
  214 + f"ffmpeg 截取片段失败: {e.stderr.decode('utf-8', errors='ignore')}"
  215 + )
  216 +
  217 + @staticmethod
  218 + def _ffmpeg_download_clip(url, output_path, start_sec, duration):
  219 + """使用 ffmpeg 从 URL 下载并截取指定片段。"""
  220 + cmd = [
  221 + "ffmpeg", "-y",
  222 + "-ss", str(start_sec),
  223 + "-fflags", "+discardcorrupt",
  224 + "-i", url,
  225 + "-t", str(duration),
  226 + "-c", "copy",
  227 + "-bsf:a", "aac_adtstoasc",
  228 + "-movflags", "+faststart",
  229 + output_path,
  230 + ]
  231 + try:
  232 + subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  233 + except subprocess.CalledProcessError as e:
  234 + raise RuntimeError(
  235 + f"ffmpeg 下载片段失败: {e.stderr.decode('utf-8', errors='ignore')}"
  236 + )
  237 +
8 238
9 def frames2content(frames): 239 def frames2content(frames):
10 - return  
  240 + """
  241 + 将帧列表(图片路径列表)转为 LLM content 列表。
  242 + 格式与 llm_video_content.py 中的 contents 返回的 content 一致。
  243 + """
  244 + content = []
  245 + video_prompt = (
  246 + f"以下是从视频中按时间顺序提取的 {len(frames)} 帧画面,"
  247 + f"请将它们视为一个连续的视频进行分析。"
  248 + )
  249 + content.append({"type": "text", "text": video_prompt})
  250 +
  251 + for frame_path in frames:
  252 + with open(frame_path, "rb") as f:
  253 + img_bytes = f.read()
  254 + b64_str = base64.b64encode(img_bytes).decode("utf-8")
  255 + content.append({
  256 + "type": "image_url",
  257 + "image_url": {
  258 + "url": f"data:image/jpeg;base64,{b64_str}"
  259 + }
  260 + })
  261 +
  262 + return content
  263 +
  264 +
  265 +if __name__ == "__main__":
  266 + import argparse
  267 + import sys
  268 + import tempfile
  269 +
  270 + parser = argparse.ArgumentParser(description="Video2Frame 测试脚本")
  271 + parser.add_argument(
  272 + "--url",
  273 + default=r"D:\pythonProject\learn\3b3e99cf4ca84c3782503d8817242de2.mp4",
  274 + help="测试用 m3u8 地址(默认使用公开测试流)",
  275 + )
  276 + parser.add_argument(
  277 + "--cache-dir",
  278 + default=None,
  279 + help="缓存目录(默认自动创建临时目录)",
  280 + )
  281 + parser.add_argument(
  282 + "--start",
  283 + type=float,
  284 + default=0,
  285 + help="截取开始时间,单位:秒(默认 0)",
  286 + )
  287 + parser.add_argument(
  288 + "--end",
  289 + type=float,
  290 + default=10,
  291 + help="截取结束时间,单位:秒(默认 10)",
  292 + )
  293 + parser.add_argument(
  294 + "--fps",
  295 + type=float,
  296 + default=1.0,
  297 + help="抽帧帧率(默认 1.0)",
  298 + )
  299 + args = parser.parse_args()
  300 +
  301 + cache_dir = args.cache_dir or tempfile.mkdtemp(prefix="video2frame_test_")
  302 + print(f"[INFO] 缓存目录: {cache_dir}")
  303 +
  304 + v2f = Video2Frame(cache_dir)
  305 +
  306 + # TEST 1: 基本抽帧
  307 + print(
  308 + f"\n[TEST 1] to_frames: url={args.url}, "
  309 + f"start={args.start}s, end={args.end}s, fps={args.fps}"
  310 + )
  311 + try:
  312 + frames = v2f.to_frames(
  313 + url=args.url,
  314 + start_utc=args.start,
  315 + end_utc=args.end,
  316 + fps=args.fps,
  317 + )
  318 + print(f" 成功提取 {len(frames)} 帧")
  319 + if frames:
  320 + print(f" 首帧: {frames[0]}")
  321 + print(f" 末帧: {frames[-1]}")
  322 + except Exception as e:
  323 + print(f" 失败: {e}")
  324 + import traceback
  325 +
  326 + traceback.print_exc()
  327 + sys.exit(1)
  328 +
  329 + # TEST 2: frames2content
  330 + print("\n[TEST 2] frames2content")
  331 + content = frames2content(frames)
  332 + print(f" content 列表长度: {len(content)}")
  333 + for item in content[:3]:
  334 + preview = (
  335 + item["text"][:60] + "..."
  336 + if item["type"] == "text"
  337 + else item["image_url"]["url"][:60] + "..."
  338 + )
  339 + print(f" - type={item['type']}: {preview}")
  340 + if len(content) > 3:
  341 + print(f" ... 还有 {len(content) - 3} 个元素")
  342 +
  343 + # TEST 3: ROI + max_px_area
  344 + print("\n[TEST 3] to_frames with roi + max_px_area")
  345 + try:
  346 + end_crop = min(args.start + 5, args.end)
  347 + frames_cropped = v2f.to_frames(
  348 + url=args.url,
  349 + start_utc=args.start,
  350 + end_utc=end_crop,
  351 + fps=1.0,
  352 + roi=None,
  353 + max_px_area=200_000,
  354 + )
  355 + print(f" 成功提取 {len(frames_cropped)} 帧(带 ROI 裁剪和面积缩放)")
  356 + except Exception as e:
  357 + print(f" 跳过/失败: {e}")
  358 +
  359 + # TEST 4: SEI UTC 模式说明(仅演示,需真实含 SEI 的流才能运行)
  360 + print("\n[TEST 4] SEI UTC 绝对时间戳模式(示例代码,需替换为含 SEI 的流)")
  361 + print(
  362 + " # 示例:假设视频第一帧 UTC 为 1715600000,提取 10 秒片段\n"
  363 + " # frames = v2f.to_frames(url, start_utc=1715600000, end_utc=1715600010, fps=1)"
  364 + )
  365 +
  366 + print("\n[INFO] 所有测试完成")
  1 +#!/usr/bin/env python3
  2 +"""
  3 +m3u8_to_mp4_sei.py
  4 +下载 m3u8 为 mp4,并提取视频中的 SEI UTC 信息。
  5 +
  6 +依赖:
  7 + ffmpeg (需要添加到系统 PATH)
  8 +
  9 +用法示例:
  10 + python m3u8_to_mp4_sei.py "http://example.com/playlist.m3u8"
  11 + python m3u8_to_mp4_sei.py "http://example.com/playlist.m3u8" -o myvideo.mp4 --json
  12 + python m3u8_to_mp4_sei.py "http://example.com/playlist.m3u8" -t 30 --json
  13 +"""
  14 +
  15 +import argparse
  16 +import json
  17 +import os
  18 +import struct
  19 +import subprocess
  20 +import sys
  21 +from pathlib import Path
  22 +from typing import Dict, List, Optional
  23 +
  24 +
  25 +def check_ffmpeg() -> bool:
  26 + """检查系统是否安装了 ffmpeg"""
  27 + try:
  28 + subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True)
  29 + return True
  30 + except (FileNotFoundError, subprocess.CalledProcessError):
  31 + return False
  32 +
  33 +
  34 +def download_m3u8_to_mp4(url: str, output_path: str, duration: Optional[int] = None) -> None:
  35 + """
  36 + 使用 ffmpeg 将 m3u8 下载并封装为 mp4
  37 + :param duration: 若指定,则只下载前 N 秒
  38 + """
  39 + cmd = [
  40 + "ffmpeg",
  41 + "-y",
  42 + "-fflags", "+discardcorrupt", # 容错:丢弃损坏包
  43 + "-i", url,
  44 + ]
  45 + if duration is not None:
  46 + cmd.extend(["-t", str(duration)])
  47 + cmd.extend([
  48 + "-c", "copy",
  49 + "-bsf:a", "aac_adtstoasc",
  50 + "-movflags", "+faststart",
  51 + output_path,
  52 + ])
  53 + print(f"[1/3] 正在下载并封装 mp4: {output_path}")
  54 + result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
  55 + if result.returncode != 0:
  56 + raise RuntimeError(f"ffmpeg 下载失败: {result.stderr}")
  57 +
  58 +
  59 +def extract_h264_es_from_mp4(mp4_path: str) -> bytes:
  60 + """
  61 + 使用 ffmpeg 从 mp4 中提取 H.264 Elementary Stream (AnnexB 格式)
  62 + 返回完整的 ES 字节流
  63 + """
  64 + cmd = [
  65 + "ffmpeg",
  66 + "-y",
  67 + "-i", mp4_path,
  68 + "-c:v", "copy",
  69 + "-an",
  70 + "-bsf:v", "h264_mp4toannexb",
  71 + "-f", "h264",
  72 + "pipe:1",
  73 + ]
  74 + print("[2/3] 正在从 mp4 中提取 H.264 视频流...")
  75 + proc = subprocess.run(cmd, capture_output=True)
  76 + if proc.returncode != 0:
  77 + stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
  78 + raise RuntimeError(f"ffmpeg 提取视频流失败: {stderr}")
  79 + return proc.stdout
  80 +
  81 +
  82 +def _sei_type_name(payload_type: int) -> str:
  83 + names = {
  84 + 0: "buffering_period",
  85 + 1: "pic_timing",
  86 + 2: "pan_scan_rect",
  87 + 3: "filler_payload",
  88 + 4: "user_data_registered_itu_t_t35",
  89 + 5: "user_data_unregistered",
  90 + 6: "recovery_point",
  91 + 7: "dec_ref_pic_marking_repetition",
  92 + 8: "spare_pic",
  93 + 9: "scene_info",
  94 + 10: "sub_seq_info",
  95 + 11: "sub_seq_layer_characteristics",
  96 + 12: "sub_seq_characteristics",
  97 + 13: "full_frame_freeze",
  98 + 14: "full_frame_freeze_release",
  99 + 15: "full_frame_snapshot",
  100 + 16: "progressive_refinement_segment_start",
  101 + 17: "progressive_refinement_segment_end",
  102 + 18: "motion_constrained_slice_group_set",
  103 + 19: "film_grain_characteristics",
  104 + 20: "deblocking_filter_display_preference",
  105 + 21: "stereo_video_info",
  106 + 22: "post_filter_hint",
  107 + 23: "tone_mapping_info",
  108 + 24: "scalability_info",
  109 + 25: "sub_pic_scalable_layer",
  110 + 26: "non_required_layer_rep",
  111 + 27: "priority_layer_info",
  112 + 28: "layers_not_present",
  113 + 29: "layer_dependency_change",
  114 + 30: "scalable_nesting",
  115 + 31: "base_layer_temporal_hrd",
  116 + 32: "quality_layer_integrity_check",
  117 + 33: "redundant_pic_property",
  118 + 34: "tl0_dep_rep_index",
  119 + 35: "tl_switching_point",
  120 + 36: "parallel_decoding_info",
  121 + 37: "mvc_scalable_nesting",
  122 + 38: "view_scalability_info",
  123 + 39: "multiview_scene_info",
  124 + 40: "multiview_acquisition_info",
  125 + 41: "non_equivalent_view_dependency",
  126 + 42: "view_dependency_change",
  127 + 43: "operation_points_not_present",
  128 + 44: "base_view_temporal_hrd",
  129 + 45: "frame_packing_arrangement",
  130 + 46: "multiview_view_position",
  131 + 47: "display_orientation",
  132 + 48: "mvcd_scalable_nesting",
  133 + 49: "mvcd_view_scalability_info",
  134 + 50: "depth_representation_info",
  135 + 51: "three_dimensional_reference_displays_info",
  136 + 52: "depth_timing",
  137 + 53: "depth_sampling_info",
  138 + 54: "constrained_depth_parameter_set_identifier",
  139 + 55: "green_metadata",
  140 + 56: "mastering_display_colour_volume",
  141 + 57: "colour_remapping_info",
  142 + 58: "alternative_transfer_characteristics",
  143 + 59: "alternative_depth_info",
  144 + }
  145 + return names.get(payload_type, f"unknown({payload_type})")
  146 +
  147 +
  148 +def parse_h264_sei(es_data: bytes) -> List[Dict]:
  149 + """
  150 + 从 H.264 ES 数据中解析 SEI NAL 单元
  151 + 返回 SEI 列表,包含 payload_type, uuid, text/data
  152 + """
  153 + sei_list = []
  154 + i = 0
  155 +
  156 + def find_next_start(data: bytes, start: int) -> int:
  157 + j = start
  158 + while j + 3 < len(data):
  159 + if data[j:j + 4] == b"\x00\x00\x00\x01":
  160 + return j
  161 + if data[j:j + 3] == b"\x00\x00\x01":
  162 + return j
  163 + j += 1
  164 + return len(data)
  165 +
  166 + while i < len(es_data):
  167 + if es_data[i:i + 4] == b"\x00\x00\x00\x01":
  168 + start_len = 4
  169 + elif es_data[i:i + 3] == b"\x00\x00\x01":
  170 + start_len = 3
  171 + else:
  172 + i += 1
  173 + continue
  174 +
  175 + if i + start_len >= len(es_data):
  176 + break
  177 +
  178 + nal_unit_type = es_data[i + start_len] & 0x1F
  179 +
  180 + if nal_unit_type == 6: # SEI
  181 + payload_start = i + start_len + 1
  182 + next_start = find_next_start(es_data, payload_start)
  183 + sei_payload = es_data[payload_start:next_start]
  184 +
  185 + k = 0
  186 + payload_type = 0
  187 + while k < len(sei_payload) and sei_payload[k] == 0xFF:
  188 + payload_type += 255
  189 + k += 1
  190 + if k < len(sei_payload):
  191 + payload_type += sei_payload[k]
  192 + k += 1
  193 +
  194 + payload_size = 0
  195 + while k < len(sei_payload) and sei_payload[k] == 0xFF:
  196 + payload_size += 255
  197 + k += 1
  198 + if k < len(sei_payload):
  199 + payload_size += sei_payload[k]
  200 + k += 1
  201 +
  202 + if k + payload_size <= len(sei_payload):
  203 + payload_data = sei_payload[k:k + payload_size]
  204 +
  205 + sei_info = {
  206 + "payload_type": payload_type,
  207 + "payload_type_name": _sei_type_name(payload_type),
  208 + "payload_size": payload_size,
  209 + }
  210 +
  211 + if payload_type == 5: # user_data_unregistered
  212 + if len(payload_data) >= 16:
  213 + uuid_bytes = payload_data[:16]
  214 + user_data = payload_data[16:]
  215 + while user_data and user_data[-1] == 0x00:
  216 + user_data = user_data[:-1]
  217 + if user_data and user_data[-1] == 0x80:
  218 + user_data = user_data[:-1]
  219 +
  220 + sei_info["uuid_hex"] = uuid_bytes.hex()
  221 + sei_info["uuid_ascii"] = uuid_bytes.decode("ascii", errors="replace")
  222 + try:
  223 + text = user_data.decode("utf-8", errors="replace")
  224 + sei_info["text"] = text
  225 + try:
  226 + sei_info["json"] = json.loads(text)
  227 + except json.JSONDecodeError:
  228 + pass
  229 + except Exception:
  230 + sei_info["raw_hex"] = user_data.hex()
  231 +
  232 + sei_list.append(sei_info)
  233 +
  234 + i = next_start
  235 + else:
  236 + i += 1
  237 +
  238 + return sei_list
  239 +
  240 +
  241 +def extract_utc_from_sei(sei_list: List[Dict]) -> List[Dict]:
  242 + """从 SEI 列表中提取 UTC 信息"""
  243 + utc_records = []
  244 + seen = set()
  245 + for sei in sei_list:
  246 + json_data = sei.get("json")
  247 + if isinstance(json_data, dict) and "utc" in json_data:
  248 + # 去重:相同的 utc 只保留一次
  249 + key = json.dumps(json_data, sort_keys=True, ensure_ascii=False)
  250 + if key in seen:
  251 + continue
  252 + seen.add(key)
  253 + utc_records.append({
  254 + "utc": json_data["utc"],
  255 + "origin_utc": json_data.get("origin_utc"),
  256 + "origin_offset": json_data.get("origin_offset"),
  257 + "full_json": json_data,
  258 + })
  259 + return utc_records
  260 +
  261 +
  262 +def main():
  263 + parser = argparse.ArgumentParser(
  264 + description="将 m3u8 下载为 mp4 并提取 SEI UTC 信息",
  265 + formatter_class=argparse.RawDescriptionHelpFormatter,
  266 + epilog="""
  267 +示例:
  268 + %(prog)s "http://example.com/playlist.m3u8"
  269 + %(prog)s "http://example.com/playlist.m3u8" -o myvideo.mp4 --json
  270 + %(prog)s "http://example.com/playlist.m3u8" -t 30 --json -o result.json
  271 + """,
  272 + )
  273 + parser.add_argument("url", help="m3u8 播放列表 URL")
  274 + parser.add_argument("-o", "--output", default=None, help="mp4 输出路径(默认根据 URL 自动命名)")
  275 + parser.add_argument("-t", "--duration", type=int, default=None, help="只下载前 N 秒(默认下载全部)")
  276 + parser.add_argument("--json", action="store_true", help="以 JSON 格式输出结果")
  277 + parser.add_argument("--no-download", action="store_true", help="如果本地已有 mp4,跳过下载")
  278 + parser.add_argument("--save-es", metavar="FILE", help="保存提取的 H.264 ES 流到指定文件(调试用)")
  279 + args = parser.parse_args()
  280 +
  281 + # 强制 stdout UTF-8
  282 + import io
  283 + try:
  284 + if getattr(sys.stdout, "encoding", None) != "utf-8":
  285 + if hasattr(sys.stdout, "reconfigure"):
  286 + sys.stdout.reconfigure(encoding="utf-8")
  287 + elif hasattr(sys.stdout, "buffer"):
  288 + sys.stdout = io.TextIOWrapper(
  289 + sys.stdout.buffer, encoding="utf-8", line_buffering=True
  290 + )
  291 + except Exception:
  292 + pass
  293 +
  294 + if not check_ffmpeg():
  295 + print("错误: 未检测到 ffmpeg,请先安装并添加到 PATH。", file=sys.stderr)
  296 + print("下载地址: https://ffmpeg.org/download.html", file=sys.stderr)
  297 + sys.exit(1)
  298 +
  299 + # 确定 mp4 输出路径
  300 + if args.output:
  301 + mp4_path = Path(args.output)
  302 + else:
  303 + base = args.url.split("?")[0].rsplit("/", 1)[-1]
  304 + if not base or "." not in base:
  305 + base = "output.mp4"
  306 + else:
  307 + base = base.rsplit(".", 1)[0] + ".mp4"
  308 + mp4_path = Path(base)
  309 +
  310 + es_data = b""
  311 + try:
  312 + # 1. 下载/封装 mp4
  313 + if not args.no_download or not mp4_path.exists():
  314 + download_m3u8_to_mp4(args.url, str(mp4_path), duration=args.duration)
  315 + print(f" mp4 已保存: {mp4_path.absolute()}")
  316 + else:
  317 + print(f"[1/3] 使用本地 mp4: {mp4_path.absolute()}")
  318 +
  319 + # 2. 提取 H.264 ES
  320 + es_data = extract_h264_es_from_mp4(str(mp4_path))
  321 + print(f" 提取到 {len(es_data)} 字节的 H.264 ES 数据")
  322 +
  323 + if args.save_es:
  324 + es_path = Path(args.save_es)
  325 + es_path.write_bytes(es_data)
  326 + print(f" H.264 ES 已保存: {es_path.absolute()}")
  327 +
  328 + # 3. 解析 SEI
  329 + sei_list = parse_h264_sei(es_data)
  330 + print(f"[3/3] 解析完成,发现 {len(sei_list)} 条 SEI 消息")
  331 +
  332 + # 4. 提取 UTC
  333 + utc_records = extract_utc_from_sei(sei_list)
  334 +
  335 + result = {
  336 + "m3u8_url": args.url,
  337 + "mp4_path": str(mp4_path.absolute()),
  338 + "sei_count": len(sei_list),
  339 + "utc_records": utc_records,
  340 + "sei_items": sei_list[:20],
  341 + }
  342 +
  343 + # 输出
  344 + json_str = json.dumps(result, ensure_ascii=False, indent=2)
  345 + if args.json and args.output and args.output.endswith(".json"):
  346 + # 如果 --output 以 .json 结尾,且 --json,则保存为 JSON 文件
  347 + with open(args.output, "w", encoding="utf-8-sig") as f:
  348 + f.write(json_str)
  349 + f.write("\n")
  350 + print(f"JSON 结果已保存到: {args.output}")
  351 + elif args.json:
  352 + print(json_str)
  353 + else:
  354 + print(f"\n{'='*50}")
  355 + print("SEI UTC 信息提取结果")
  356 + print(f"{'='*50}")
  357 + print(f"mp4 文件 : {result['mp4_path']}")
  358 + print(f"SEI 消息总数 : {result['sei_count']}")
  359 + print(f"UTC 记录数 : {len(utc_records)}")
  360 + if utc_records:
  361 + print(f"\nUTC 详细信息:")
  362 + for idx, rec in enumerate(utc_records, 1):
  363 + print(f" [{idx}] utc={rec['utc']}, origin_utc={rec.get('origin_utc')}, origin_offset={rec.get('origin_offset')}")
  364 + else:
  365 + print("\n未从 SEI 中提取到 UTC 信息。")
  366 + print(f"{'='*50}")
  367 +
  368 + except Exception as e:
  369 + print(f"错误: {e}", file=sys.stderr)
  370 + sys.exit(1)
  371 +
  372 +
  373 +if __name__ == "__main__":
  374 + main()
  375 +
  376 +# python mp4_sei_extractor.py "3b3e99cf4ca84c3782503d8817242de2.mp4" --json -o sei_result.json