首次提交

This commit is contained in:
2026-09-28 16:09:31 +08:00
commit 4a6839319a
71 changed files with 12144 additions and 0 deletions

471
tool/process.py Normal file
View File

@@ -0,0 +1,471 @@
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)