mirror of
https://github.com/linyqh/NarratoAI.git
synced 2025-12-11 10:32:49 +00:00
feat(video): 实现统一视频裁剪策略并移除旧逻辑
重构视频处理流程,引入基于OST类型的统一裁剪策略: - 新增 clip_video_unified 函数处理三种OST类型 - 移除预裁剪步骤和相关UI组件 - 优化任务处理流程,减少重复裁剪 - 添加详细的错误处理和日志记录
This commit is contained in:
parent
e1f45db95a
commit
cd1ee1441e
@ -546,6 +546,359 @@ def try_fallback_encoding(
|
|||||||
return execute_simple_command(fallback_cmd, timestamp, "通用Fallback")
|
return execute_simple_command(fallback_cmd, timestamp, "通用Fallback")
|
||||||
|
|
||||||
|
|
||||||
|
def _process_narration_only_segment(
|
||||||
|
video_origin_path: str,
|
||||||
|
script_item: Dict,
|
||||||
|
tts_map: Dict,
|
||||||
|
output_dir: str,
|
||||||
|
encoder_config: Dict,
|
||||||
|
hwaccel_args: List[str]
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
处理OST=0的纯解说片段
|
||||||
|
- 根据TTS音频时长动态裁剪
|
||||||
|
- 移除原声,生成静音视频
|
||||||
|
"""
|
||||||
|
_id = script_item["_id"]
|
||||||
|
timestamp = script_item["timestamp"]
|
||||||
|
|
||||||
|
# 获取对应的TTS结果
|
||||||
|
tts_item = tts_map.get(_id)
|
||||||
|
if not tts_item:
|
||||||
|
logger.error(f"未找到片段 {_id} 的TTS结果")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 解析起始时间,使用TTS音频时长计算结束时间
|
||||||
|
start_time, _ = parse_timestamp(timestamp)
|
||||||
|
duration = tts_item["duration"]
|
||||||
|
calculated_end_time = calculate_end_time(start_time, duration, extra_seconds=0)
|
||||||
|
|
||||||
|
# 转换为FFmpeg兼容的时间格式
|
||||||
|
ffmpeg_start_time = start_time.replace(',', '.')
|
||||||
|
ffmpeg_end_time = calculated_end_time.replace(',', '.')
|
||||||
|
|
||||||
|
# 生成输出文件名
|
||||||
|
safe_start_time = start_time.replace(':', '-').replace(',', '-')
|
||||||
|
safe_end_time = calculated_end_time.replace(':', '-').replace(',', '-')
|
||||||
|
output_filename = f"ost0_vid_{safe_start_time}@{safe_end_time}.mp4"
|
||||||
|
output_path = os.path.join(output_dir, output_filename)
|
||||||
|
|
||||||
|
# 构建FFmpeg命令 - 移除音频
|
||||||
|
cmd = _build_ffmpeg_command_with_audio_control(
|
||||||
|
video_origin_path, output_path, ffmpeg_start_time, ffmpeg_end_time,
|
||||||
|
encoder_config, hwaccel_args, remove_audio=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# 执行命令
|
||||||
|
success = execute_ffmpeg_with_fallback(
|
||||||
|
cmd, timestamp, video_origin_path, output_path,
|
||||||
|
ffmpeg_start_time, ffmpeg_end_time
|
||||||
|
)
|
||||||
|
|
||||||
|
return output_path if success else None
|
||||||
|
|
||||||
|
|
||||||
|
def _process_original_audio_segment(
|
||||||
|
video_origin_path: str,
|
||||||
|
script_item: Dict,
|
||||||
|
output_dir: str,
|
||||||
|
encoder_config: Dict,
|
||||||
|
hwaccel_args: List[str]
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
处理OST=1的纯原声片段
|
||||||
|
- 严格按照脚本timestamp精确裁剪
|
||||||
|
- 保持原声不变
|
||||||
|
"""
|
||||||
|
_id = script_item["_id"]
|
||||||
|
timestamp = script_item["timestamp"]
|
||||||
|
|
||||||
|
# 严格按照timestamp进行裁剪
|
||||||
|
start_time, end_time = parse_timestamp(timestamp)
|
||||||
|
|
||||||
|
# 转换为FFmpeg兼容的时间格式
|
||||||
|
ffmpeg_start_time = start_time.replace(',', '.')
|
||||||
|
ffmpeg_end_time = end_time.replace(',', '.')
|
||||||
|
|
||||||
|
# 生成输出文件名
|
||||||
|
safe_start_time = start_time.replace(':', '-').replace(',', '-')
|
||||||
|
safe_end_time = end_time.replace(':', '-').replace(',', '-')
|
||||||
|
output_filename = f"ost1_vid_{safe_start_time}@{safe_end_time}.mp4"
|
||||||
|
output_path = os.path.join(output_dir, output_filename)
|
||||||
|
|
||||||
|
# 构建FFmpeg命令 - 保持原声
|
||||||
|
cmd = _build_ffmpeg_command_with_audio_control(
|
||||||
|
video_origin_path, output_path, ffmpeg_start_time, ffmpeg_end_time,
|
||||||
|
encoder_config, hwaccel_args, remove_audio=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 执行命令
|
||||||
|
success = execute_ffmpeg_with_fallback(
|
||||||
|
cmd, timestamp, video_origin_path, output_path,
|
||||||
|
ffmpeg_start_time, ffmpeg_end_time
|
||||||
|
)
|
||||||
|
|
||||||
|
return output_path if success else None
|
||||||
|
|
||||||
|
|
||||||
|
def _process_mixed_segment(
|
||||||
|
video_origin_path: str,
|
||||||
|
script_item: Dict,
|
||||||
|
tts_map: Dict,
|
||||||
|
output_dir: str,
|
||||||
|
encoder_config: Dict,
|
||||||
|
hwaccel_args: List[str]
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
处理OST=2的解说+原声混合片段
|
||||||
|
- 根据TTS音频时长动态裁剪
|
||||||
|
- 保持原声,确保视频时长等于TTS音频时长
|
||||||
|
"""
|
||||||
|
_id = script_item["_id"]
|
||||||
|
timestamp = script_item["timestamp"]
|
||||||
|
|
||||||
|
# 获取对应的TTS结果
|
||||||
|
tts_item = tts_map.get(_id)
|
||||||
|
if not tts_item:
|
||||||
|
logger.error(f"未找到片段 {_id} 的TTS结果")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 解析起始时间,使用TTS音频时长计算结束时间
|
||||||
|
start_time, _ = parse_timestamp(timestamp)
|
||||||
|
duration = tts_item["duration"]
|
||||||
|
calculated_end_time = calculate_end_time(start_time, duration, extra_seconds=0)
|
||||||
|
|
||||||
|
# 转换为FFmpeg兼容的时间格式
|
||||||
|
ffmpeg_start_time = start_time.replace(',', '.')
|
||||||
|
ffmpeg_end_time = calculated_end_time.replace(',', '.')
|
||||||
|
|
||||||
|
# 生成输出文件名
|
||||||
|
safe_start_time = start_time.replace(':', '-').replace(',', '-')
|
||||||
|
safe_end_time = calculated_end_time.replace(':', '-').replace(',', '-')
|
||||||
|
output_filename = f"ost2_vid_{safe_start_time}@{safe_end_time}.mp4"
|
||||||
|
output_path = os.path.join(output_dir, output_filename)
|
||||||
|
|
||||||
|
# 构建FFmpeg命令 - 保持原声
|
||||||
|
cmd = _build_ffmpeg_command_with_audio_control(
|
||||||
|
video_origin_path, output_path, ffmpeg_start_time, ffmpeg_end_time,
|
||||||
|
encoder_config, hwaccel_args, remove_audio=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 执行命令
|
||||||
|
success = execute_ffmpeg_with_fallback(
|
||||||
|
cmd, timestamp, video_origin_path, output_path,
|
||||||
|
ffmpeg_start_time, ffmpeg_end_time
|
||||||
|
)
|
||||||
|
|
||||||
|
return output_path if success else None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ffmpeg_command_with_audio_control(
|
||||||
|
input_path: str,
|
||||||
|
output_path: str,
|
||||||
|
start_time: str,
|
||||||
|
end_time: str,
|
||||||
|
encoder_config: Dict[str, str],
|
||||||
|
hwaccel_args: List[str] = None,
|
||||||
|
remove_audio: bool = False
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
构建支持音频控制的FFmpeg命令
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path: 输入视频路径
|
||||||
|
output_path: 输出视频路径
|
||||||
|
start_time: 开始时间
|
||||||
|
end_time: 结束时间
|
||||||
|
encoder_config: 编码器配置
|
||||||
|
hwaccel_args: 硬件加速参数
|
||||||
|
remove_audio: 是否移除音频(OST=0时为True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[str]: ffmpeg命令列表
|
||||||
|
"""
|
||||||
|
cmd = ["ffmpeg", "-y"]
|
||||||
|
|
||||||
|
# 硬件加速设置(参考原有逻辑)
|
||||||
|
if encoder_config["video_codec"] == "h264_nvenc":
|
||||||
|
# 对于NVENC,不使用硬件解码以避免滤镜链问题
|
||||||
|
pass
|
||||||
|
elif hwaccel_args:
|
||||||
|
cmd.extend(hwaccel_args)
|
||||||
|
|
||||||
|
# 输入文件
|
||||||
|
cmd.extend(["-i", input_path])
|
||||||
|
|
||||||
|
# 时间范围
|
||||||
|
cmd.extend(["-ss", start_time, "-to", end_time])
|
||||||
|
|
||||||
|
# 视频编码器设置
|
||||||
|
cmd.extend(["-c:v", encoder_config["video_codec"]])
|
||||||
|
|
||||||
|
# 音频处理
|
||||||
|
if remove_audio:
|
||||||
|
# OST=0: 移除音频
|
||||||
|
cmd.extend(["-an"]) # -an 表示不包含音频流
|
||||||
|
logger.debug("OST=0: 移除音频流")
|
||||||
|
else:
|
||||||
|
# OST=1,2: 保持原声
|
||||||
|
cmd.extend(["-c:a", encoder_config["audio_codec"]])
|
||||||
|
cmd.extend(["-ar", "44100", "-ac", "2"])
|
||||||
|
logger.debug("OST=1/2: 保持原声")
|
||||||
|
|
||||||
|
# 像素格式
|
||||||
|
cmd.extend(["-pix_fmt", encoder_config["pixel_format"]])
|
||||||
|
|
||||||
|
# 质量和预设参数(参考原有逻辑)
|
||||||
|
if encoder_config["video_codec"] == "h264_nvenc":
|
||||||
|
cmd.extend(["-preset", encoder_config["preset"]])
|
||||||
|
cmd.extend(["-cq", encoder_config["quality_value"]])
|
||||||
|
cmd.extend(["-profile:v", "main"])
|
||||||
|
elif encoder_config["video_codec"] == "h264_amf":
|
||||||
|
cmd.extend(["-quality", encoder_config["preset"]])
|
||||||
|
cmd.extend(["-qp_i", encoder_config["quality_value"]])
|
||||||
|
elif encoder_config["video_codec"] == "h264_qsv":
|
||||||
|
cmd.extend(["-preset", encoder_config["preset"]])
|
||||||
|
cmd.extend(["-global_quality", encoder_config["quality_value"]])
|
||||||
|
elif encoder_config["video_codec"] == "h264_videotoolbox":
|
||||||
|
cmd.extend(["-profile:v", "high"])
|
||||||
|
cmd.extend(["-b:v", encoder_config["quality_value"]])
|
||||||
|
else:
|
||||||
|
# 软件编码器(libx264)
|
||||||
|
cmd.extend(["-preset", encoder_config["preset"]])
|
||||||
|
cmd.extend(["-crf", encoder_config["quality_value"]])
|
||||||
|
|
||||||
|
# 优化参数
|
||||||
|
cmd.extend(["-avoid_negative_ts", "make_zero"])
|
||||||
|
cmd.extend(["-movflags", "+faststart"])
|
||||||
|
|
||||||
|
# 输出文件
|
||||||
|
cmd.append(output_path)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def clip_video_unified(
|
||||||
|
video_origin_path: str,
|
||||||
|
script_list: List[Dict],
|
||||||
|
tts_results: List[Dict],
|
||||||
|
output_dir: Optional[str] = None,
|
||||||
|
task_id: Optional[str] = None
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
基于OST类型的统一视频裁剪策略 - 消除双重裁剪问题
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_origin_path: 原始视频的路径
|
||||||
|
script_list: 完整的脚本列表,包含所有片段信息
|
||||||
|
tts_results: TTS结果列表,仅包含OST=0和OST=2的片段
|
||||||
|
output_dir: 输出目录路径,默认为None时会自动生成
|
||||||
|
task_id: 任务ID,用于生成唯一的输出目录,默认为None时会自动生成
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, str]: 片段ID到裁剪后视频路径的映射
|
||||||
|
"""
|
||||||
|
# 检查视频文件是否存在
|
||||||
|
if not os.path.exists(video_origin_path):
|
||||||
|
raise FileNotFoundError(f"视频文件不存在: {video_origin_path}")
|
||||||
|
|
||||||
|
# 如果未提供task_id,则根据输入生成一个唯一ID
|
||||||
|
if task_id is None:
|
||||||
|
content_for_hash = f"{video_origin_path}_{json.dumps(script_list)}"
|
||||||
|
task_id = hashlib.md5(content_for_hash.encode()).hexdigest()
|
||||||
|
|
||||||
|
# 设置输出目录
|
||||||
|
if output_dir is None:
|
||||||
|
output_dir = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"storage", "temp", "clip_video_unified", task_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# 确保输出目录存在
|
||||||
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 创建TTS结果的快速查找映射
|
||||||
|
tts_map = {item['_id']: item for item in tts_results}
|
||||||
|
|
||||||
|
# 获取硬件加速支持
|
||||||
|
hwaccel_type = check_hardware_acceleration()
|
||||||
|
hwaccel_args = []
|
||||||
|
|
||||||
|
if hwaccel_type:
|
||||||
|
hwaccel_args = ffmpeg_utils.get_ffmpeg_hwaccel_args()
|
||||||
|
hwaccel_info = ffmpeg_utils.get_ffmpeg_hwaccel_info()
|
||||||
|
logger.info(f"🚀 使用硬件加速: {hwaccel_type} ({hwaccel_info.get('message', '')})")
|
||||||
|
else:
|
||||||
|
logger.info("🔧 使用软件编码")
|
||||||
|
|
||||||
|
# 获取编码器配置
|
||||||
|
encoder_config = get_safe_encoder_config(hwaccel_type)
|
||||||
|
logger.debug(f"编码器配置: {encoder_config}")
|
||||||
|
|
||||||
|
# 统计信息
|
||||||
|
total_clips = len(script_list)
|
||||||
|
result = {}
|
||||||
|
failed_clips = []
|
||||||
|
success_count = 0
|
||||||
|
|
||||||
|
logger.info(f"📹 开始统一视频裁剪,总共{total_clips}个片段")
|
||||||
|
|
||||||
|
for i, script_item in enumerate(script_list, 1):
|
||||||
|
_id = script_item.get("_id")
|
||||||
|
ost = script_item.get("OST", 0)
|
||||||
|
timestamp = script_item["timestamp"]
|
||||||
|
|
||||||
|
logger.info(f"📹 [{i}/{total_clips}] 处理片段 ID:{_id}, OST:{ost}, 时间戳:{timestamp}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if ost == 0: # 纯解说片段
|
||||||
|
output_path = _process_narration_only_segment(
|
||||||
|
video_origin_path, script_item, tts_map, output_dir,
|
||||||
|
encoder_config, hwaccel_args
|
||||||
|
)
|
||||||
|
elif ost == 1: # 纯原声片段
|
||||||
|
output_path = _process_original_audio_segment(
|
||||||
|
video_origin_path, script_item, output_dir,
|
||||||
|
encoder_config, hwaccel_args
|
||||||
|
)
|
||||||
|
elif ost == 2: # 解说+原声混合片段
|
||||||
|
output_path = _process_mixed_segment(
|
||||||
|
video_origin_path, script_item, tts_map, output_dir,
|
||||||
|
encoder_config, hwaccel_args
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(f"未知的OST类型: {ost},跳过片段 {_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if output_path and os.path.exists(output_path) and os.path.getsize(output_path) > 0:
|
||||||
|
result[_id] = output_path
|
||||||
|
success_count += 1
|
||||||
|
logger.info(f"✅ [{i}/{total_clips}] 片段处理成功: OST={ost}, ID={_id}")
|
||||||
|
else:
|
||||||
|
failed_clips.append(f"ID:{_id}, OST:{ost}")
|
||||||
|
logger.error(f"❌ [{i}/{total_clips}] 片段处理失败: OST={ost}, ID={_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failed_clips.append(f"ID:{_id}, OST:{ost}")
|
||||||
|
logger.error(f"❌ [{i}/{total_clips}] 片段处理异常: OST={ost}, ID={_id}, 错误: {str(e)}")
|
||||||
|
|
||||||
|
# 最终统计
|
||||||
|
logger.info(f"📊 统一视频裁剪完成: 成功 {success_count}/{total_clips}, 失败 {len(failed_clips)}")
|
||||||
|
|
||||||
|
# 检查是否有失败的片段
|
||||||
|
if failed_clips:
|
||||||
|
logger.warning(f"⚠️ 以下片段处理失败: {failed_clips}")
|
||||||
|
if len(failed_clips) == total_clips:
|
||||||
|
raise RuntimeError("所有视频片段处理都失败了,请检查视频文件和ffmpeg配置")
|
||||||
|
elif len(failed_clips) > total_clips / 2:
|
||||||
|
logger.warning(f"⚠️ 超过一半的片段处理失败 ({len(failed_clips)}/{total_clips}),请检查硬件加速配置")
|
||||||
|
|
||||||
|
if success_count > 0:
|
||||||
|
logger.info(f"🎉 统一视频裁剪任务完成! 输出目录: {output_dir}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def clip_video(
|
def clip_video(
|
||||||
video_origin_path: str,
|
video_origin_path: str,
|
||||||
tts_result: List[Dict],
|
tts_result: List[Dict],
|
||||||
|
|||||||
@ -15,13 +15,19 @@ from app.services import state as sm
|
|||||||
from app.utils import utils
|
from app.utils import utils
|
||||||
|
|
||||||
|
|
||||||
def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: dict):
|
def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: dict = None):
|
||||||
"""
|
"""
|
||||||
后台任务(自动剪辑视频进行剪辑)
|
后台任务(统一视频裁剪处理)- 优化版本
|
||||||
|
|
||||||
|
实施基于OST类型的统一视频裁剪策略,消除双重裁剪问题:
|
||||||
|
- OST=0: 根据TTS音频时长动态裁剪,移除原声
|
||||||
|
- OST=1: 严格按照脚本timestamp精确裁剪,保持原声
|
||||||
|
- OST=2: 根据TTS音频时长动态裁剪,保持原声
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: 任务ID
|
task_id: 任务ID
|
||||||
params: 视频参数
|
params: 视频参数
|
||||||
subclip_path_videos: 视频片段路径
|
subclip_path_videos: 视频片段路径(可选,仅作为备用方案)
|
||||||
"""
|
"""
|
||||||
global merged_audio_path, merged_subtitle_path
|
global merged_audio_path, merged_subtitle_path
|
||||||
|
|
||||||
@ -94,17 +100,26 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
|
|||||||
# sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=40)
|
# sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=40)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
3. 裁剪视频 - 将超出音频长度的视频进行裁剪
|
3. 统一视频裁剪 - 基于OST类型的差异化裁剪策略
|
||||||
"""
|
"""
|
||||||
logger.info("\n\n## 3. 裁剪视频")
|
logger.info("\n\n## 3. 统一视频裁剪(基于OST类型)")
|
||||||
video_clip_result = clip_video.clip_video(params.video_origin_path, tts_results)
|
|
||||||
# 更新 list_script 中的时间戳
|
# 使用新的统一裁剪策略
|
||||||
|
video_clip_result = clip_video.clip_video_unified(
|
||||||
|
video_origin_path=params.video_origin_path,
|
||||||
|
script_list=list_script,
|
||||||
|
tts_results=tts_results
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新 list_script 中的时间戳和路径信息
|
||||||
tts_clip_result = {tts_result['_id']: tts_result['audio_file'] for tts_result in tts_results}
|
tts_clip_result = {tts_result['_id']: tts_result['audio_file'] for tts_result in tts_results}
|
||||||
subclip_clip_result = {
|
subclip_clip_result = {
|
||||||
tts_result['_id']: tts_result['subtitle_file'] for tts_result in tts_results
|
tts_result['_id']: tts_result['subtitle_file'] for tts_result in tts_results
|
||||||
}
|
}
|
||||||
new_script_list = update_script.update_script_timestamps(list_script, video_clip_result, tts_clip_result, subclip_clip_result)
|
new_script_list = update_script.update_script_timestamps(list_script, video_clip_result, tts_clip_result, subclip_clip_result)
|
||||||
|
|
||||||
|
logger.info(f"统一裁剪完成,处理了 {len(video_clip_result)} 个视频片段")
|
||||||
|
|
||||||
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=60)
|
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=60)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@ -139,8 +154,27 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
|
|||||||
|
|
||||||
combined_video_path = path.join(utils.task_dir(task_id), f"merger.mp4")
|
combined_video_path = path.join(utils.task_dir(task_id), f"merger.mp4")
|
||||||
logger.info(f"\n\n## 5. 合并视频: => {combined_video_path}")
|
logger.info(f"\n\n## 5. 合并视频: => {combined_video_path}")
|
||||||
# 如果 new_script_list 中没有 video,则使用 subclip_path_videos 中的视频
|
|
||||||
video_clips = [new_script['video'] if new_script.get('video') else subclip_path_videos.get(new_script.get('_id', '')) for new_script in new_script_list]
|
# 使用统一裁剪后的视频片段
|
||||||
|
video_clips = []
|
||||||
|
for new_script in new_script_list:
|
||||||
|
video_path = new_script.get('video')
|
||||||
|
if video_path and os.path.exists(video_path):
|
||||||
|
video_clips.append(video_path)
|
||||||
|
else:
|
||||||
|
logger.warning(f"片段 {new_script.get('_id')} 的视频文件不存在或未生成: {video_path}")
|
||||||
|
# 如果统一裁剪失败,尝试使用备用方案(如果提供了subclip_path_videos)
|
||||||
|
if subclip_path_videos and new_script.get('_id') in subclip_path_videos:
|
||||||
|
backup_video = subclip_path_videos[new_script.get('_id')]
|
||||||
|
if os.path.exists(backup_video):
|
||||||
|
video_clips.append(backup_video)
|
||||||
|
logger.info(f"使用备用视频: {backup_video}")
|
||||||
|
else:
|
||||||
|
logger.error(f"备用视频也不存在: {backup_video}")
|
||||||
|
else:
|
||||||
|
logger.error(f"无法找到片段 {new_script.get('_id')} 的视频文件")
|
||||||
|
|
||||||
|
logger.info(f"准备合并 {len(video_clips)} 个视频片段")
|
||||||
|
|
||||||
merger_video.combine_clip_videos(
|
merger_video.combine_clip_videos(
|
||||||
output_video_path=combined_video_path,
|
output_video_path=combined_video_path,
|
||||||
@ -208,6 +242,199 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
|
|||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def start_subclip_unified(task_id: str, params: VideoClipParams):
|
||||||
|
"""
|
||||||
|
统一视频裁剪处理函数 - 完全基于OST类型的新实现
|
||||||
|
|
||||||
|
这是优化后的版本,完全移除了对预裁剪视频的依赖,
|
||||||
|
实现真正的统一裁剪策略。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: 任务ID
|
||||||
|
params: 视频参数
|
||||||
|
"""
|
||||||
|
global merged_audio_path, merged_subtitle_path
|
||||||
|
|
||||||
|
logger.info(f"\n\n## 开始统一视频处理任务: {task_id}")
|
||||||
|
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=0)
|
||||||
|
|
||||||
|
"""
|
||||||
|
1. 加载剪辑脚本
|
||||||
|
"""
|
||||||
|
logger.info("\n\n## 1. 加载视频脚本")
|
||||||
|
video_script_path = path.join(params.video_clip_json_path)
|
||||||
|
|
||||||
|
if path.exists(video_script_path):
|
||||||
|
try:
|
||||||
|
with open(video_script_path, "r", encoding="utf-8") as f:
|
||||||
|
list_script = json.load(f)
|
||||||
|
video_list = [i['narration'] for i in list_script]
|
||||||
|
video_ost = [i['OST'] for i in list_script]
|
||||||
|
time_list = [i['timestamp'] for i in list_script]
|
||||||
|
|
||||||
|
video_script = " ".join(video_list)
|
||||||
|
logger.debug(f"解说完整脚本: \n{video_script}")
|
||||||
|
logger.debug(f"解说 OST 列表: \n{video_ost}")
|
||||||
|
logger.debug(f"解说时间戳列表: \n{time_list}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"无法读取视频json脚本,请检查脚本格式是否正确")
|
||||||
|
raise ValueError("无法读取视频json脚本,请检查脚本格式是否正确")
|
||||||
|
else:
|
||||||
|
logger.error(f"video_script_path: {video_script_path}")
|
||||||
|
raise ValueError("解说脚本不存在!请检查配置是否正确。")
|
||||||
|
|
||||||
|
"""
|
||||||
|
2. 使用 TTS 生成音频素材
|
||||||
|
"""
|
||||||
|
logger.info("\n\n## 2. 根据OST设置生成音频列表")
|
||||||
|
# 只为OST=0 or 2的判断生成音频, OST=0 仅保留解说 OST=2 保留解说和原声
|
||||||
|
tts_segments = [
|
||||||
|
segment for segment in list_script
|
||||||
|
if segment['OST'] in [0, 2]
|
||||||
|
]
|
||||||
|
logger.debug(f"需要生成TTS的片段数: {len(tts_segments)}")
|
||||||
|
|
||||||
|
tts_results = voice.tts_multiple(
|
||||||
|
task_id=task_id,
|
||||||
|
list_script=tts_segments, # 只传入需要TTS的片段
|
||||||
|
voice_name=params.voice_name,
|
||||||
|
voice_rate=params.voice_rate,
|
||||||
|
voice_pitch=params.voice_pitch,
|
||||||
|
)
|
||||||
|
|
||||||
|
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=20)
|
||||||
|
|
||||||
|
"""
|
||||||
|
3. 统一视频裁剪 - 基于OST类型的差异化裁剪策略
|
||||||
|
"""
|
||||||
|
logger.info("\n\n## 3. 统一视频裁剪(基于OST类型)")
|
||||||
|
|
||||||
|
# 使用新的统一裁剪策略
|
||||||
|
video_clip_result = clip_video.clip_video_unified(
|
||||||
|
video_origin_path=params.video_origin_path,
|
||||||
|
script_list=list_script,
|
||||||
|
tts_results=tts_results
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新 list_script 中的时间戳和路径信息
|
||||||
|
tts_clip_result = {tts_result['_id']: tts_result['audio_file'] for tts_result in tts_results}
|
||||||
|
subclip_clip_result = {
|
||||||
|
tts_result['_id']: tts_result['subtitle_file'] for tts_result in tts_results
|
||||||
|
}
|
||||||
|
new_script_list = update_script.update_script_timestamps(list_script, video_clip_result, tts_clip_result, subclip_clip_result)
|
||||||
|
|
||||||
|
logger.info(f"统一裁剪完成,处理了 {len(video_clip_result)} 个视频片段")
|
||||||
|
|
||||||
|
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=60)
|
||||||
|
|
||||||
|
"""
|
||||||
|
4. 合并音频和字幕
|
||||||
|
"""
|
||||||
|
logger.info("\n\n## 4. 合并音频和字幕")
|
||||||
|
total_duration = sum([script["duration"] for script in new_script_list])
|
||||||
|
if tts_segments:
|
||||||
|
try:
|
||||||
|
# 合并音频文件
|
||||||
|
merged_audio_path = audio_merger.merge_audio_files(
|
||||||
|
task_id=task_id,
|
||||||
|
total_duration=total_duration,
|
||||||
|
list_script=new_script_list
|
||||||
|
)
|
||||||
|
logger.info(f"音频文件合并成功->{merged_audio_path}")
|
||||||
|
# 合并字幕文件
|
||||||
|
merged_subtitle_path = subtitle_merger.merge_subtitle_files(new_script_list)
|
||||||
|
logger.info(f"字幕文件合并成功->{merged_subtitle_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"合并音频文件失败: {str(e)}")
|
||||||
|
else:
|
||||||
|
logger.warning("没有需要合并的音频/字幕")
|
||||||
|
merged_audio_path = ""
|
||||||
|
merged_subtitle_path = ""
|
||||||
|
|
||||||
|
"""
|
||||||
|
5. 合并视频
|
||||||
|
"""
|
||||||
|
final_video_paths = []
|
||||||
|
combined_video_paths = []
|
||||||
|
|
||||||
|
combined_video_path = path.join(utils.task_dir(task_id), f"merger.mp4")
|
||||||
|
logger.info(f"\n\n## 5. 合并视频: => {combined_video_path}")
|
||||||
|
|
||||||
|
# 使用统一裁剪后的视频片段
|
||||||
|
video_clips = []
|
||||||
|
for new_script in new_script_list:
|
||||||
|
video_path = new_script.get('video')
|
||||||
|
if video_path and os.path.exists(video_path):
|
||||||
|
video_clips.append(video_path)
|
||||||
|
else:
|
||||||
|
logger.error(f"片段 {new_script.get('_id')} 的视频文件不存在: {video_path}")
|
||||||
|
|
||||||
|
logger.info(f"准备合并 {len(video_clips)} 个视频片段")
|
||||||
|
|
||||||
|
merger_video.combine_clip_videos(
|
||||||
|
output_video_path=combined_video_path,
|
||||||
|
video_paths=video_clips,
|
||||||
|
video_ost_list=video_ost,
|
||||||
|
video_aspect=params.video_aspect,
|
||||||
|
threads=params.n_threads
|
||||||
|
)
|
||||||
|
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=80)
|
||||||
|
|
||||||
|
"""
|
||||||
|
6. 合并字幕/BGM/配音/视频
|
||||||
|
"""
|
||||||
|
output_video_path = path.join(utils.task_dir(task_id), f"combined.mp4")
|
||||||
|
logger.info(f"\n\n## 6. 最后一步: 合并字幕/BGM/配音/视频 -> {output_video_path}")
|
||||||
|
|
||||||
|
bgm_path = utils.get_bgm_file()
|
||||||
|
|
||||||
|
# 获取优化的音量配置
|
||||||
|
optimized_volumes = get_recommended_volumes_for_content('mixed')
|
||||||
|
|
||||||
|
# 应用用户设置和优化建议的组合
|
||||||
|
final_tts_volume = params.tts_volume if hasattr(params, 'tts_volume') and params.tts_volume != 1.0 else optimized_volumes['tts_volume']
|
||||||
|
final_original_volume = params.original_volume if hasattr(params, 'original_volume') and params.original_volume != 0.7 else optimized_volumes['original_volume']
|
||||||
|
final_bgm_volume = params.bgm_volume if hasattr(params, 'bgm_volume') and params.bgm_volume != 0.3 else optimized_volumes['bgm_volume']
|
||||||
|
|
||||||
|
logger.info(f"音量配置 - TTS: {final_tts_volume}, 原声: {final_original_volume}, BGM: {final_bgm_volume}")
|
||||||
|
|
||||||
|
# 调用示例
|
||||||
|
options = {
|
||||||
|
'voice_volume': final_tts_volume,
|
||||||
|
'bgm_volume': final_bgm_volume,
|
||||||
|
'original_audio_volume': final_original_volume,
|
||||||
|
'keep_original_audio': True,
|
||||||
|
'subtitle_enabled': params.subtitle_enabled,
|
||||||
|
'subtitle_font': params.font_name,
|
||||||
|
'subtitle_font_size': params.font_size,
|
||||||
|
'subtitle_color': params.text_fore_color,
|
||||||
|
'subtitle_bg_color': None,
|
||||||
|
'subtitle_position': params.subtitle_position,
|
||||||
|
'custom_position': params.custom_position,
|
||||||
|
'threads': params.n_threads
|
||||||
|
}
|
||||||
|
generate_video.merge_materials(
|
||||||
|
video_path=combined_video_path,
|
||||||
|
audio_path=merged_audio_path,
|
||||||
|
subtitle_path=merged_subtitle_path,
|
||||||
|
bgm_path=bgm_path,
|
||||||
|
output_path=output_video_path,
|
||||||
|
options=options
|
||||||
|
)
|
||||||
|
|
||||||
|
final_video_paths.append(output_video_path)
|
||||||
|
combined_video_paths.append(combined_video_path)
|
||||||
|
|
||||||
|
logger.success(f"统一处理任务 {task_id} 已完成, 生成 {len(final_video_paths)} 个视频.")
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
"videos": final_video_paths,
|
||||||
|
"combined_videos": combined_video_paths
|
||||||
|
}
|
||||||
|
sm.state.update_task(task_id, state=const.TASK_STATE_COMPLETE, progress=100, **kwargs)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
def validate_params(video_path, audio_path, output_file, params):
|
def validate_params(video_path, audio_path, output_file, params):
|
||||||
"""
|
"""
|
||||||
验证输入参数
|
验证输入参数
|
||||||
|
|||||||
@ -509,6 +509,12 @@ def clean_model_output(output):
|
|||||||
|
|
||||||
|
|
||||||
def cut_video(params, progress_callback=None):
|
def cut_video(params, progress_callback=None):
|
||||||
|
"""
|
||||||
|
旧的视频裁剪函数 - 已弃用
|
||||||
|
|
||||||
|
注意:此函数已被统一裁剪策略取代,不再推荐使用。
|
||||||
|
新的实现请使用 task.start_subclip_unified() 函数。
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
task_id = str(uuid4())
|
task_id = str(uuid4())
|
||||||
st.session_state['task_id'] = task_id
|
st.session_state['task_id'] = task_id
|
||||||
|
|||||||
19
webui.py
19
webui.py
@ -106,8 +106,7 @@ def init_global_state():
|
|||||||
st.session_state['video_plot'] = ''
|
st.session_state['video_plot'] = ''
|
||||||
if 'ui_language' not in st.session_state:
|
if 'ui_language' not in st.session_state:
|
||||||
st.session_state['ui_language'] = config.ui.get("language", utils.get_system_locale())
|
st.session_state['ui_language'] = config.ui.get("language", utils.get_system_locale())
|
||||||
if 'subclip_videos' not in st.session_state:
|
# 移除subclip_videos初始化 - 现在使用统一裁剪策略
|
||||||
st.session_state['subclip_videos'] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def tr(key):
|
def tr(key):
|
||||||
@ -136,11 +135,9 @@ def render_generate_button():
|
|||||||
logger.add(log_received)
|
logger.add(log_received)
|
||||||
|
|
||||||
config.save_config()
|
config.save_config()
|
||||||
task_id = st.session_state.get('task_id')
|
|
||||||
|
|
||||||
if not task_id:
|
# 移除task_id检查 - 现在使用统一裁剪策略,不再需要预裁剪
|
||||||
st.error(tr("请先裁剪视频"))
|
# 直接检查必要的文件是否存在
|
||||||
return
|
|
||||||
if not st.session_state.get('video_clip_json_path'):
|
if not st.session_state.get('video_clip_json_path'):
|
||||||
st.error(tr("脚本文件不能为空"))
|
st.error(tr("脚本文件不能为空"))
|
||||||
return
|
return
|
||||||
@ -168,10 +165,14 @@ def render_generate_button():
|
|||||||
# 创建参数对象
|
# 创建参数对象
|
||||||
params = VideoClipParams(**all_params)
|
params = VideoClipParams(**all_params)
|
||||||
|
|
||||||
result = tm.start_subclip(
|
# 使用新的统一裁剪策略,不再需要预裁剪的subclip_videos
|
||||||
|
# 生成一个新的task_id用于本次处理
|
||||||
|
import uuid
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
result = tm.start_subclip_unified(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
params=params,
|
params=params
|
||||||
subclip_path_videos=st.session_state['subclip_videos']
|
|
||||||
)
|
)
|
||||||
|
|
||||||
video_files = result.get("videos", [])
|
video_files = result.get("videos", [])
|
||||||
|
|||||||
@ -336,8 +336,8 @@ def render_script_buttons(tr, params):
|
|||||||
height=180
|
height=180
|
||||||
)
|
)
|
||||||
|
|
||||||
# 操作按钮行
|
# 操作按钮行 - 移除裁剪视频按钮,使用统一裁剪策略
|
||||||
button_cols = st.columns(3)
|
button_cols = st.columns(2) # 改为2列布局
|
||||||
with button_cols[0]:
|
with button_cols[0]:
|
||||||
if st.button(tr("Check Format"), key="check_format", use_container_width=True):
|
if st.button(tr("Check Format"), key="check_format", use_container_width=True):
|
||||||
check_script_format(tr, video_clip_json_details)
|
check_script_format(tr, video_clip_json_details)
|
||||||
@ -346,11 +346,6 @@ def render_script_buttons(tr, params):
|
|||||||
if st.button(tr("Save Script"), key="save_script", use_container_width=True):
|
if st.button(tr("Save Script"), key="save_script", use_container_width=True):
|
||||||
save_script(tr, video_clip_json_details)
|
save_script(tr, video_clip_json_details)
|
||||||
|
|
||||||
with button_cols[2]:
|
|
||||||
script_valid = st.session_state.get('script_format_valid', False)
|
|
||||||
if st.button(tr("Crop Video"), key="crop_video", disabled=not script_valid, use_container_width=True):
|
|
||||||
crop_video(tr, params)
|
|
||||||
|
|
||||||
|
|
||||||
def check_script_format(tr, script_content):
|
def check_script_format(tr, script_content):
|
||||||
"""检查脚本格式"""
|
"""检查脚本格式"""
|
||||||
@ -414,26 +409,7 @@ def save_script(tr, video_clip_json_details):
|
|||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
|
|
||||||
def crop_video(tr, params):
|
# crop_video函数已移除 - 现在使用统一裁剪策略,不再需要预裁剪步骤
|
||||||
"""裁剪视频"""
|
|
||||||
progress_bar = st.progress(0)
|
|
||||||
status_text = st.empty()
|
|
||||||
|
|
||||||
def update_progress(progress):
|
|
||||||
progress_bar.progress(progress)
|
|
||||||
status_text.text(f"剪辑进度: {progress}%")
|
|
||||||
|
|
||||||
try:
|
|
||||||
utils.cut_video(params, update_progress)
|
|
||||||
time.sleep(0.5)
|
|
||||||
progress_bar.progress(100)
|
|
||||||
st.success("视频剪辑成功完成!")
|
|
||||||
except Exception as e:
|
|
||||||
st.error(f"剪辑过程中发生错误: {str(e)}")
|
|
||||||
finally:
|
|
||||||
time.sleep(1)
|
|
||||||
progress_bar.empty()
|
|
||||||
status_text.empty()
|
|
||||||
|
|
||||||
|
|
||||||
def get_script_params():
|
def get_script_params():
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
"Video Theme": "视频主题",
|
"Video Theme": "视频主题",
|
||||||
"Generation Prompt": "自定义提示词",
|
"Generation Prompt": "自定义提示词",
|
||||||
"Save Script": "保存脚本",
|
"Save Script": "保存脚本",
|
||||||
"Crop Video": "裁剪视频",
|
|
||||||
"Video File": "视频文件(:blue[1️⃣支持上传视频文件(限制2G) 2️⃣大文件建议直接导入 ./resource/videos 目录])",
|
"Video File": "视频文件(:blue[1️⃣支持上传视频文件(限制2G) 2️⃣大文件建议直接导入 ./resource/videos 目录])",
|
||||||
"Plot Description": "剧情描述 (:blue[可从 https://www.tvmao.com/ 获取])",
|
"Plot Description": "剧情描述 (:blue[可从 https://www.tvmao.com/ 获取])",
|
||||||
"Generate Video Keywords": "点击使用AI根据**文案**生成【视频关键】",
|
"Generate Video Keywords": "点击使用AI根据**文案**生成【视频关键】",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user