from config.config_utils import config, get_ffprobe import argparse import hashlib import logging import subprocess import shutil import sys import os import json import time from tool.log.log_manager import UnifiedLogger from tool.VideoProcessing.multithread_algo import split_frame def derive_video_name(cos_path: str) -> str: """把 COS 完整路径变成本地工作目录/文件名用的唯一标识: <原文件名(不含扩展名)>_<完整路径MD5前8位>。 不能只用文件名 —— 源数据里大量不同父目录复用同样的数字文件名(如 cos://bkt/0021/17688/00046.mp4 和 cos://bkt/0021/17690/00046.mp4), 纯文件名当本地 标识会导致两个不同视频被并发处理到时互相踩踏同一份本地文件(下载/抽帧/拼接的中间文件 全挤在同一个 ./split/00046 目录), 引发 "Directory not empty" 崩溃和一连串连锁失败 (实测占某台机器全部 error 记录的 100%)。 保留原文件名前缀是为了日志/目录名还能看出是哪个视频, 不至于变成纯哈希、丧失可读性; main.py 里 process_video_task() 必须调用同一个函数(而不是各自按文件名重算), 两处 对同一个 cos_path 才会算出同一个 video_name。""" filename = os.path.basename(cos_path.strip()) stem = os.path.splitext(filename)[0] digest = hashlib.md5(cos_path.strip().encode("utf-8")).hexdigest()[:8] return f"{stem}_{digest}" def _probe_duration(video_path): """用 ffprobe 取视频时长(秒);失败返回 None。 先取流时长, 再取容器(format)时长——有些封装(尤其AV1/部分mp4)流里没duration、只在format里。""" # ① 流时长 try: r = subprocess.run([ get_ffprobe(), '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=duration', '-of', 'json', video_path ], capture_output=True, text=True) d = float(json.loads(r.stdout)['streams'][0].get('duration', 0) or 0) if d > 0: return d except Exception: pass # ② 容器时长(兜底) try: r = subprocess.run([ get_ffprobe(), '-v', 'error', '-show_entries', 'format=duration', '-of', 'json', video_path ], capture_output=True, text=True) d = float(json.loads(r.stdout).get('format', {}).get('duration', 0) or 0) if d > 0: return d except Exception: pass return None def _append_long_video(cos_path, list_path): """把被"视频过长"过滤掉的视频 COS 路径追加到一份 txt,供以后(GPU机器/放大上限)补跑。 进程池下多个 worker 进程会并发写同一文件:用 O_APPEND 单行追加(POSIX 下小写原子), 失败也只记日志、绝不影响主流程。""" if not cos_path or not list_path: return try: with open(list_path, 'a', encoding='utf-8') as f: f.write(cos_path.rstrip('\n') + '\n') except Exception: pass from tool.merge import process_images from tool.getsrt import processsrt, OcrIncompleteError # 配置日志 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler('video_processing.log'), logging.StreamHandler(sys.stdout) ] ) def delete_local_folder(folder_path): """ 删除本地文件夹,但保留子文件夹composite及其内容 :param folder_path: 文件夹路径 :return: """ try: if not os.path.exists(folder_path): logging.warning(f"文件夹不存在,无需删除: {folder_path}") return True # 定义要保留的子文件夹路径 composite_path = os.path.join(folder_path, 'composite') # 如果composite子文件夹存在,先临时保存它 temp_dir = None if os.path.exists(composite_path): # 创建一个临时目录来保存composite文件夹 temp_dir = os.path.join(os.path.dirname(folder_path), f"temp_composite_{os.getpid()}") shutil.move(composite_path, temp_dir) # 删除整个文件夹 shutil.rmtree(folder_path) # 如果之前有composite文件夹,将其移回原位 if temp_dir and os.path.exists(temp_dir): # rmtree 删除了父目录本身,需要先重建 os.makedirs(folder_path, exist_ok=True) shutil.move(temp_dir, composite_path) logging.info(f"已删除本地文件夹内容,但保留了composite子文件夹: {folder_path}") else: logging.info(f"已删除本地文件夹: {folder_path}") return True except Exception as e: logging.error(f"删除文件夹失败: {e}") # 尝试恢复临时文件夹以防出错 if temp_dir and os.path.exists(temp_dir): try: os.makedirs(folder_path, exist_ok=True) shutil.move(temp_dir, os.path.join(folder_path, 'composite')) except Exception as restore_error: logging.error(f"恢复composite文件夹失败: {restore_error}") return False def download_from_cos(cos_target, cos_source, coscli_path, cos_config_path, local_dest_path): """ 从COS下载文件到 local_dest_path。 :param cos_target: 目录 :param cos_source: 完整 COS 路径 :param coscli_path: cos exe文件地址 :param cos_config_path: cos 配置文件 :param local_dest_path: 下载到的本地目标路径 —— 必须是调用方(process_single_video)按 derive_video_name() 算出的 collision-safe 路径, 不能在这里自己按 os.path.basename(cos_source) 重新拼一个 './video/<文件名>' —— 之前就是这么写的, 跟调用方检查是否存在用的 local_video_path (带路径MD5后缀)对不上, 下载明明成功了、下一步却判"文件不存在", 100%必现。 :return: """ try: # cos_source 是完整 COS 路径时直接使用,否则拼接目录 full_cos_path = cos_source if cos_source.startswith("cos://") else f"{cos_target}{cos_source}" cmd = [f"{coscli_path}", "-c", f"{cos_config_path}", "cp", full_cos_path, local_dest_path] # 执行命令(带超时:网络卡住时不会永久阻塞 worker,超时即判下载失败) result = subprocess.run(cmd, capture_output=True, text=True, timeout=1800) if result.returncode == 0: logging.info(f"成功下载到: {local_dest_path}") return cos_source else: logging.error(f"下载失败: {result.stderr}") return False except subprocess.TimeoutExpired: logging.error(f"下载超时(>1800s),跳过: {cos_source}") return False except Exception as e: logging.error(f"下载过程中出错: {e}") return False def process_single_video(cos_source, cos_target): """ 单个视频处理流程 :param cos_source: 单个视频名称 :param cos_target: 上传位置 :return: True 处理成功, False 处理失败 """ # 1. 读取参数 split_frame_config = config.get_splitframe_config() download_flag = split_frame_config.get('download') coscli_path = split_frame_config.get('coscli_path') cos_config_path = split_frame_config.get('cosconfig_path') filename = os.path.basename(cos_source) root_path = "./video" video_name = derive_video_name(cos_source) local_video_path = os.path.join(root_path, video_name + os.path.splitext(filename)[1]) # 使用线程安全的日志器,避免多线程下重置单例导致状态混乱 logger = UnifiedLogger() logger.reset() logger.init_logging(f"log/{video_name}.json") logger.start_program("main") try: return _process_video_pipeline( cos_source, cos_target, video_name, local_video_path, split_frame_config, download_flag, coscli_path, cos_config_path, logger ) except OcrIncompleteError as e: # 不丢帧: OCR没全成功 → 不产出带洞SRT, 把视频记到"OCR失败待重跑"列表, 判失败(以后用另一jsonl补跑) try: fail_list = config.get_subtitle_config().get('ocr_fail_list', './OCR失败待重跑.txt') _append_long_video(cos_source, fail_list) except Exception: pass logger.log(f"视频{video_name} OCR未完成(不丢帧→不产出带洞SRT, 已记入待重跑): {e}", level="error", program="main") _save_error_metadata( cos_source, video_name, error_reason=f"OcrIncomplete: {e}", download_flag=download_flag, interval_seconds=split_frame_config.get('interval_seconds', 0.33) ) logger.end_program("main") logger.save_logs() return False except Exception as e: logger.log(f"视频{video_name}处理流程异常: {type(e).__name__}: {e}", level="error", program="main") _save_error_metadata( cos_source, video_name, error_reason=f"{type(e).__name__}: {e}", download_flag=download_flag, interval_seconds=split_frame_config.get('interval_seconds', 0.33) ) logger.end_program("main") logger.save_logs() return False def _process_video_pipeline(cos_source, cos_target, video_name, local_video_path, split_frame_config, download_flag, coscli_path, cos_config_path, logger): """ 视频处理内部流水线,将各阶段错误统一记录并抛出 """ # 2. 进行数据下载 logger.step(video_name, "开始") if download_flag: logger.log("从COS下载视频...", program="main") if not os.path.exists(local_video_path): if not download_from_cos(cos_target, cos_source, coscli_path, cos_config_path, local_video_path): raise RuntimeError(f"视频{video_name}下载失败") logger.step(video_name, "下载完成") # 检查文件是否存在 if not os.path.exists(local_video_path): raise FileNotFoundError(f"视频{video_name}不存在: {local_video_path}") # 判断字幕文件是否存在 name = os.path.basename(local_video_path).split(".")[0] if os.path.exists(f"./split/{name}/composite/{name}.srt"): return True # 视频时长过滤(抽帧前 ffprobe 取时长,省掉抽帧/拼接/OCR;也避免超长视频撑爆内存/卡死worker) _sub_cfg = config.get_subtitle_config() min_dur = _sub_cfg.get('min_duration_seconds', 300) max_dur = _sub_cfg.get('max_duration_seconds', 5400) # 默认1.5小时上限 vdur = _probe_duration(local_video_path) # 在【删视频前】把时长存进日志stat: ① ffprobe能处理AV1(cv2不行) ② delete_video删源视频后main.py就读不到了 if vdur is not None and vdur > 0: logger.set_stat("video_duration", round(vdur, 2)) if vdur is not None and 0 < vdur < min_dur: logger.step(video_name, "已过滤", f"视频过短 {vdur:.0f}s < {min_dur}s") raise ValueError(f"视频{video_name}被过滤: 视频过短({vdur:.0f}秒, 阈值{min_dur}秒)") if vdur is not None and max_dur and vdur > max_dur: logger.step(video_name, "已过滤", f"视频过长 {vdur:.0f}s > {max_dur}s") # 把超长视频的 COS 路径单独留一份,供以后在 GPU 机器/放大上限后补跑 # (当前 run 仍记 数据.jsonl 跳过它,不占 worker) long_list = _sub_cfg.get('long_video_list', './超长待重跑.txt') _append_long_video(cos_source, long_list) raise ValueError(f"视频{video_name}被过滤: 视频过长({vdur:.0f}秒, 阈值{max_dur}秒)") # 3. 进行视频抽帧 interval_seconds = split_frame_config.get('interval_seconds', 0.33) try: _t = time.time() duration, planned_frames, actual_frames, backend = split_frame(local_video_path, interval_seconds) logger.set_stat("frame_count", actual_frames) logger.log("抽帧成功", program="main") logger.step(video_name, "抽帧完成", f"{actual_frames}帧, {backend}, {time.time()-_t:.0f}s, 视频{duration:.0f}s") except Exception as e: raise RuntimeError(f"视频{video_name}抽帧处理失败: {e}") from e # 抽帧成功但一帧没出: 视频没被 min/max_duration_seconds 过滤掉、真去抽了、但结果是空—— # 不是"没字幕"的正常情况, 是抽帧本身出了问题(比如时长探测失败导致期望帧数算成0, 见 # split_frame_ffmpeg 的时长兜底), 应该报错让人查, 而不是让空帧列表流进 process_images() # 炸出一个看不懂的 "list index out of range"。 if actual_frames == 0: raise RuntimeError(f"视频{video_name}抽帧结果为0帧(时长{duration:.0f}s), 抽帧本身有问题, 非正常无字幕") try: _t = time.time() result = process_images( f"./split/{video_name}", f"./split/{video_name}", logger, interval_seconds=interval_seconds, video_path=local_video_path) except Exception as e: raise RuntimeError(f"视频{video_name}图片拼接失败: {e}") from e duration, subtitle_region, tile_width, tile_height, compositenum, audio_text, sample_indices, watermark, candidate_regions = result if subtitle_region is None: logger.step(video_name, "已过滤", "未检测到字幕区域") raise ValueError(f"视频{video_name}被过滤: 未检测到字幕区域") logger.step(video_name, "拼接完成", f"{compositenum}张合成图, {time.time()-_t:.0f}s") # 4. 删除本地抽帧文件(保留 composite 子文件夹) # delete_folder = os.path.join("./split", video_name) # if not delete_local_folder(delete_folder): # logger.log("本地文件夹删除失败", level="warning", program="main") delete_flag = split_frame_config.get('delete_video') if delete_flag: try: if os.path.exists(local_video_path): os.remove(local_video_path) logger.log(f"已删除本地视频文件: {local_video_path}", program="main") except OSError as e: logger.log(f"删除视频文件失败: {e}", level="warning", program="main") # 5. 转字幕 composite_path = os.path.join("./split", video_name) os.makedirs(composite_path, exist_ok=True) if not os.path.exists(composite_path): raise FileNotFoundError(f"视频{video_name}的 composite 文件夹不存在: {composite_path}") try: _t = time.time() score, conf, srt_path, contents, best_grids, audio_frame, h = processsrt( composite_path, "ocr_result.jsonl", f"{video_name}.ocr.srt", interval_seconds, logger) _getsrt_elapsed = time.time() - _t except Exception as e: raise RuntimeError(f"视频{video_name}字幕生成失败: {e}") from e # 7. 记录元数据 if score is None: filter_reason = srt_path if isinstance(srt_path, str) else "未知原因" logger.step(video_name, "已过滤", str(filter_reason)) logger.log(f"视频{video_name}被过滤,原因:{filter_reason}", level="warning", program="main") _save_filter_metadata( cos_source, video_name, filter_reason=filter_reason, download_flag=download_flag, interval_seconds=interval_seconds, duration=duration, subtitle_region=subtitle_region, tile_width=tile_width, tile_height=tile_height, compositenum=compositenum, audio_text=audio_text, sample_indices=sample_indices, watermark=watermark, candidate_regions=candidate_regions, contents=contents, best_grids=best_grids, audio_frame=audio_frame, h=h, score=score, conf=conf ) logger.end_program("main") logger.save_logs() return False _save_success_metadata( cos_source, video_name, srt_path=srt_path, download_flag=download_flag, interval_seconds=interval_seconds, duration=duration, subtitle_region=subtitle_region, tile_width=tile_width, tile_height=tile_height, compositenum=compositenum, audio_text=audio_text, sample_indices=sample_indices, watermark=watermark, candidate_regions=candidate_regions, contents=contents, best_grids=best_grids, audio_frame=audio_frame, h=h, score=score, conf=conf ) logger.step(video_name, "字幕完成", f"质量分{score:.0f}, {_getsrt_elapsed:.0f}s, 等待上传") logger.log(f"视频{video_name}处理完成", program="main") logger.end_program("main") logger.save_logs() return True def _build_metadata_common(download_flag, interval_seconds, duration, subtitle_region, tile_width, tile_height, compositenum, audio_text, sample_indices, watermark, candidate_regions, contents, best_grids, audio_frame, h): """构建元数据的公共部分,避免代码重复""" best_grid_data = best_grids.get("composite") if best_grids else None return { "download_phase": { "download_flag": download_flag }, "frame_extract": { "interval": interval_seconds, "duration": duration, "success": True }, "subtitle_localize": { "sample_indices": sample_indices, "watermark": watermark, "candidate_regions": candidate_regions, "subtitle_position": { "dilation": "65", "final_region": subtitle_region }, "audio_extract": audio_text }, "composite_build": { "BORDER_SIZE": 40, "tile_width": tile_width, "tile_height": tile_height, "composite_num": compositenum, "success": True }, "ocr_process": { "ocr_result": contents, "best_grid": { "composite": { "grid_position": best_grid_data[0] if best_grid_data else None, "grid_width": best_grid_data[1] if best_grid_data else None, "grid_height": best_grid_data[2] if best_grid_data else None, "grid_top_bound": best_grid_data[3] if best_grid_data else None, "grid_left_bound": best_grid_data[4] if best_grid_data else None } } }, "frame_processing": { "audio_frame": audio_frame, "average_height": h, "merge_params": { "confidence_bottom_line": 75, "merge_srt_similarity_bottom_line": 0.75, "audio_similarity_bottom_line": 0.8 } }, "custom_score": None, "ocr_confidence": None } def _save_success_metadata(cos_source, video_name, srt_path, **kwargs): """保存成功的元数据到 info 目录""" os.makedirs("./info", exist_ok=True) score = kwargs.pop("score") conf = kwargs.pop("conf") common = _build_metadata_common(**kwargs) common["custom_score"] = score common["ocr_confidence"] = conf common["srt_output"] = srt_path metadata = {"main_process": {"input_video": cos_source}} metadata["main_process"].update(common) metadata_js = os.path.join("./info", f"{video_name}.json") with open(metadata_js, 'w', encoding='utf-8') as f: json.dump(metadata, f, ensure_ascii=False, indent=2) def _save_filter_metadata(cos_source, video_name, filter_reason, **kwargs): """保存被过滤视频的元数据到 info_error 目录""" os.makedirs("./info_error", exist_ok=True) score = kwargs.pop("score") conf = kwargs.pop("conf") common = _build_metadata_common(**kwargs) common["custom_score"] = score common["ocr_confidence"] = conf common["error"] = filter_reason metadata = {"main_process": {"input_video": cos_source}} metadata["main_process"].update(common) metadata_js = os.path.join("./info_error", f"{video_name}.json") with open(metadata_js, 'w', encoding='utf-8') as f: json.dump(metadata, f, ensure_ascii=False, indent=2) def _save_error_metadata(cos_source, video_name, error_reason, download_flag, interval_seconds): """保存处理异常视频的元数据到 info_error 目录""" os.makedirs("./info_error", exist_ok=True) metadata = { "main_process": { "input_video": cos_source, "download_phase": {"download_flag": download_flag}, "frame_extract": {"interval": interval_seconds}, "error": error_reason } } metadata_js = os.path.join("./info_error", f"{video_name}.json") with open(metadata_js, 'w', encoding='utf-8') as f: json.dump(metadata, f, ensure_ascii=False, indent=2)