Compare commits

...

2 Commits

Author SHA1 Message Date
dd2d40c55b feat(logger): 重构日志系统配置
- 添加RotatingFileHandler支持日志轮转
- 配置多个日志处理器分别输出到控制台、全部日志文件和错误日志文件
- 设置不同日志级别的输出过滤
- 确保日志文件目录存在并正确初始化日志系统
- 移除原有的基础日志配置方式
2026-02-04 18:06:06 +08:00
c57524f174 feat(video): 添加源视频时长检测和帧冻结补足功能
- 探测源视频实际时长并计算变速后的有效时长
- 检测源视频时长不足的情况并记录警告日志
- 计算时长短缺并自动冻结最后一帧进行补足
- 更新 FFmpeg 命令构建逻辑以支持时长补足
- 合并转场 overlap 冻结和时长不足冻结的处理
- 添加必要的参数传递以支持时长检测功能
2026-02-04 17:59:46 +08:00
2 changed files with 146 additions and 27 deletions

View File

@@ -135,13 +135,36 @@ class RenderSegmentVideoHandler(BaseHandler):
logger.warning(f"[task:{task.task_id}] Failed to download overlay, continuing without it") logger.warning(f"[task:{task.task_id}] Failed to download overlay, continuing without it")
overlay_file = None overlay_file = None
# 6. 计算 overlap 时长(用于转场帧冻结 # 6. 探测源视频时长(仅对视频素材
# 用于检测时长不足并通过冻结最后一帧补足
source_duration_sec = None
if not is_image:
source_duration = self.probe_duration(input_file)
if source_duration:
source_duration_sec = source_duration
speed = float(render_spec.speed) if render_spec.speed else 1.0
if speed > 0:
# 计算变速后的有效时长
effective_duration_sec = source_duration_sec / speed
required_duration_sec = duration_ms / 1000.0
# 如果源视频时长不足,记录日志
if effective_duration_sec < required_duration_sec:
shortage_sec = required_duration_sec - effective_duration_sec
logger.warning(
f"[task:{task.task_id}] Source video duration insufficient: "
f"effective={effective_duration_sec:.2f}s (speed={speed}), "
f"required={required_duration_sec:.2f}s, "
f"will freeze last frame for {shortage_sec:.2f}s"
)
# 7. 计算 overlap 时长(用于转场帧冻结)
# 头部 overlap: 来自前一片段的出场转场 # 头部 overlap: 来自前一片段的出场转场
overlap_head_ms = task.get_overlap_head_ms() overlap_head_ms = task.get_overlap_head_ms()
# 尾部 overlap: 当前片段的出场转场 # 尾部 overlap: 当前片段的出场转场
overlap_tail_ms = task.get_overlap_tail_ms_v2() overlap_tail_ms = task.get_overlap_tail_ms_v2()
# 7. 构建 FFmpeg 命令 # 8. 构建 FFmpeg 命令
output_file = os.path.join(work_dir, 'output.mp4') output_file = os.path.join(work_dir, 'output.mp4')
cmd = self._build_command( cmd = self._build_command(
input_file=input_file, input_file=input_file,
@@ -152,28 +175,29 @@ class RenderSegmentVideoHandler(BaseHandler):
lut_file=lut_file, lut_file=lut_file,
overlay_file=overlay_file, overlay_file=overlay_file,
overlap_head_ms=overlap_head_ms, overlap_head_ms=overlap_head_ms,
overlap_tail_ms=overlap_tail_ms overlap_tail_ms=overlap_tail_ms,
source_duration_sec=source_duration_sec
) )
# 8. 执行 FFmpeg # 9. 执行 FFmpeg
if not self.run_ffmpeg(cmd, task.task_id): if not self.run_ffmpeg(cmd, task.task_id):
return TaskResult.fail( return TaskResult.fail(
ErrorCode.E_FFMPEG_FAILED, ErrorCode.E_FFMPEG_FAILED,
"FFmpeg rendering failed" "FFmpeg rendering failed"
) )
# 9. 验证输出文件 # 10. 验证输出文件
if not self.ensure_file_exists(output_file, min_size=4096): if not self.ensure_file_exists(output_file, min_size=4096):
return TaskResult.fail( return TaskResult.fail(
ErrorCode.E_FFMPEG_FAILED, ErrorCode.E_FFMPEG_FAILED,
"Output file is missing or too small" "Output file is missing or too small"
) )
# 10. 获取实际时长 # 11. 获取实际时长
actual_duration = self.probe_duration(output_file) actual_duration = self.probe_duration(output_file)
actual_duration_ms = int(actual_duration * 1000) if actual_duration else duration_ms actual_duration_ms = int(actual_duration * 1000) if actual_duration else duration_ms
# 11. 上传产物 # 12. 上传产物
video_url = self.upload_file(task.task_id, 'video', output_file) video_url = self.upload_file(task.task_id, 'video', output_file)
if not video_url: if not video_url:
return TaskResult.fail( return TaskResult.fail(
@@ -181,7 +205,7 @@ class RenderSegmentVideoHandler(BaseHandler):
"Failed to upload video" "Failed to upload video"
) )
# 12. 构建结果(包含 overlap 信息) # 13. 构建结果(包含 overlap 信息)
result_data = { result_data = {
'videoUrl': video_url, 'videoUrl': video_url,
'actualDurationMs': actual_duration_ms, 'actualDurationMs': actual_duration_ms,
@@ -310,7 +334,8 @@ class RenderSegmentVideoHandler(BaseHandler):
lut_file: Optional[str] = None, lut_file: Optional[str] = None,
overlay_file: Optional[str] = None, overlay_file: Optional[str] = None,
overlap_head_ms: int = 0, overlap_head_ms: int = 0,
overlap_tail_ms: int = 0 overlap_tail_ms: int = 0,
source_duration_sec: Optional[float] = None
) -> List[str]: ) -> List[str]:
""" """
构建 FFmpeg 渲染命令 构建 FFmpeg 渲染命令
@@ -325,6 +350,7 @@ class RenderSegmentVideoHandler(BaseHandler):
overlay_file: 叠加层文件路径(可选) overlay_file: 叠加层文件路径(可选)
overlap_head_ms: 头部 overlap 时长(毫秒) overlap_head_ms: 头部 overlap 时长(毫秒)
overlap_tail_ms: 尾部 overlap 时长(毫秒) overlap_tail_ms: 尾部 overlap 时长(毫秒)
source_duration_sec: 源视频实际时长(秒),用于检测时长不足
Returns: Returns:
FFmpeg 命令参数列表 FFmpeg 命令参数列表
@@ -347,10 +373,12 @@ class RenderSegmentVideoHandler(BaseHandler):
filters = self._build_video_filters( filters = self._build_video_filters(
render_spec=render_spec, render_spec=render_spec,
output_spec=output_spec, output_spec=output_spec,
duration_ms=duration_ms,
lut_file=lut_file, lut_file=lut_file,
overlay_file=overlay_file, overlay_file=overlay_file,
overlap_head_ms=overlap_head_ms, overlap_head_ms=overlap_head_ms,
overlap_tail_ms=overlap_tail_ms overlap_tail_ms=overlap_tail_ms,
source_duration_sec=source_duration_sec
) )
# 应用滤镜 # 应用滤镜
@@ -403,10 +431,12 @@ class RenderSegmentVideoHandler(BaseHandler):
self, self,
render_spec: RenderSpec, render_spec: RenderSpec,
output_spec: OutputSpec, output_spec: OutputSpec,
duration_ms: int,
lut_file: Optional[str] = None, lut_file: Optional[str] = None,
overlay_file: Optional[str] = None, overlay_file: Optional[str] = None,
overlap_head_ms: int = 0, overlap_head_ms: int = 0,
overlap_tail_ms: int = 0 overlap_tail_ms: int = 0,
source_duration_sec: Optional[float] = None
) -> str: ) -> str:
""" """
构建视频滤镜链 构建视频滤镜链
@@ -414,10 +444,12 @@ class RenderSegmentVideoHandler(BaseHandler):
Args: Args:
render_spec: 渲染规格 render_spec: 渲染规格
output_spec: 输出规格 output_spec: 输出规格
duration_ms: 目标时长(毫秒)
lut_file: LUT 文件路径 lut_file: LUT 文件路径
overlay_file: 叠加层文件路径(支持图片 png/jpg 和视频 mov) overlay_file: 叠加层文件路径(支持图片 png/jpg 和视频 mov)
overlap_head_ms: 头部 overlap 时长(毫秒) overlap_head_ms: 头部 overlap 时长(毫秒)
overlap_tail_ms: 尾部 overlap 时长(毫秒) overlap_tail_ms: 尾部 overlap 时长(毫秒)
source_duration_sec: 源视频实际时长(秒),用于检测时长不足
Returns: Returns:
滤镜字符串 滤镜字符串
@@ -496,20 +528,39 @@ class RenderSegmentVideoHandler(BaseHandler):
is_video_overlay=is_video_overlay, is_video_overlay=is_video_overlay,
overlap_head_ms=overlap_head_ms, overlap_head_ms=overlap_head_ms,
overlap_tail_ms=overlap_tail_ms, overlap_tail_ms=overlap_tail_ms,
use_hwdownload=bool(hwaccel_prefix) use_hwdownload=bool(hwaccel_prefix),
duration_ms=duration_ms,
render_spec=render_spec,
source_duration_sec=source_duration_sec
) )
# 6. 帧冻结(tpad)- 用于转场 overlap 区域 # 6. 帧冻结(tpad)- 用于转场 overlap 区域和时长不足补足
# 注意:tpad 必须在缩放之后应用 # 注意:tpad 必须在缩放之后应用
tpad_parts = [] tpad_parts = []
# 计算是否需要额外的尾部冻结(源视频时长不足)
extra_tail_freeze_sec = 0.0
if source_duration_sec is not None:
speed = float(render_spec.speed) if render_spec.speed else 1.0
if speed > 0:
# 计算变速后的有效时长
effective_duration_sec = source_duration_sec / speed
required_duration_sec = duration_ms / 1000.0
# 如果源视频时长不足,需要冻结最后一帧来补足
if effective_duration_sec < required_duration_sec:
extra_tail_freeze_sec = required_duration_sec - effective_duration_sec
if overlap_head_ms > 0: if overlap_head_ms > 0:
# 头部冻结:将第一帧冻结指定时长 # 头部冻结:将第一帧冻结指定时长
head_duration_sec = overlap_head_ms / 1000.0 head_duration_sec = overlap_head_ms / 1000.0
tpad_parts.append(f"start_mode=clone:start_duration={head_duration_sec}") tpad_parts.append(f"start_mode=clone:start_duration={head_duration_sec}")
if overlap_tail_ms > 0:
# 尾部冻结:将最后一帧冻结指定时长 # 尾部冻结:合并 overlap 和时长不足的冻结
tail_duration_sec = overlap_tail_ms / 1000.0 total_tail_freeze_sec = (overlap_tail_ms / 1000.0) + extra_tail_freeze_sec
tpad_parts.append(f"stop_mode=clone:stop_duration={tail_duration_sec}") if total_tail_freeze_sec > 0:
# 将最后一帧冻结指定时长
tpad_parts.append(f"stop_mode=clone:stop_duration={total_tail_freeze_sec}")
if tpad_parts: if tpad_parts:
filters.append(f"tpad={':'.join(tpad_parts)}") filters.append(f"tpad={':'.join(tpad_parts)}")
@@ -539,7 +590,10 @@ class RenderSegmentVideoHandler(BaseHandler):
is_video_overlay: bool = False, is_video_overlay: bool = False,
overlap_head_ms: int = 0, overlap_head_ms: int = 0,
overlap_tail_ms: int = 0, overlap_tail_ms: int = 0,
use_hwdownload: bool = False use_hwdownload: bool = False,
duration_ms: int = 0,
render_spec: Optional[RenderSpec] = None,
source_duration_sec: Optional[float] = None
) -> str: ) -> str:
""" """
构建包含特效的 filter_complex 滤镜图 构建包含特效的 filter_complex 滤镜图
@@ -557,6 +611,9 @@ class RenderSegmentVideoHandler(BaseHandler):
overlap_head_ms: 头部 overlap 时长 overlap_head_ms: 头部 overlap 时长
overlap_tail_ms: 尾部 overlap 时长 overlap_tail_ms: 尾部 overlap 时长
use_hwdownload: 是否使用了硬件加速解码(已在 base_filters 中包含 hwdownload) use_hwdownload: 是否使用了硬件加速解码(已在 base_filters 中包含 hwdownload)
duration_ms: 目标时长(毫秒)
render_spec: 渲染规格(用于获取变速参数)
source_duration_sec: 源视频实际时长(秒),用于检测时长不足
Returns: Returns:
filter_complex 格式的滤镜字符串 filter_complex 格式的滤镜字符串
@@ -615,14 +672,30 @@ class RenderSegmentVideoHandler(BaseHandler):
current_output = effect_output current_output = effect_output
effect_idx += 1 effect_idx += 1
# 帧冻结(tpad)- 用于转场 overlap 区域 # 帧冻结(tpad)- 用于转场 overlap 区域和时长不足补足
tpad_parts = [] tpad_parts = []
# 计算是否需要额外的尾部冻结(源视频时长不足)
extra_tail_freeze_sec = 0.0
if source_duration_sec is not None and render_spec is not None and duration_ms > 0:
speed = float(render_spec.speed) if render_spec.speed else 1.0
if speed > 0:
# 计算变速后的有效时长
effective_duration_sec = source_duration_sec / speed
required_duration_sec = duration_ms / 1000.0
# 如果源视频时长不足,需要冻结最后一帧来补足
if effective_duration_sec < required_duration_sec:
extra_tail_freeze_sec = required_duration_sec - effective_duration_sec
if overlap_head_ms > 0: if overlap_head_ms > 0:
head_duration_sec = overlap_head_ms / 1000.0 head_duration_sec = overlap_head_ms / 1000.0
tpad_parts.append(f"start_mode=clone:start_duration={head_duration_sec}") tpad_parts.append(f"start_mode=clone:start_duration={head_duration_sec}")
if overlap_tail_ms > 0:
tail_duration_sec = overlap_tail_ms / 1000.0 # 尾部冻结:合并 overlap 和时长不足的冻结
tpad_parts.append(f"stop_mode=clone:stop_duration={tail_duration_sec}") total_tail_freeze_sec = (overlap_tail_ms / 1000.0) + extra_tail_freeze_sec
if total_tail_freeze_sec > 0:
tpad_parts.append(f"stop_mode=clone:stop_duration={total_tail_freeze_sec}")
if tpad_parts: if tpad_parts:
tpad_output = '[v_tpad]' tpad_output = '[v_tpad]'

View File

@@ -25,6 +25,8 @@ import sys
import time import time
import signal import signal
import logging import logging
import os
from logging.handlers import RotatingFileHandler
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -34,11 +36,55 @@ from services.task_executor import TaskExecutor
from constant import SOFTWARE_VERSION from constant import SOFTWARE_VERSION
# 日志配置 # 日志配置
logging.basicConfig( def setup_logging():
level=logging.INFO, """配置日志系统,输出到控制台和文件"""
format='[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s', # 日志格式
datefmt='%Y-%m-%d %H:%M:%S' log_format = '[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s'
date_format = '%Y-%m-%d %H:%M:%S'
formatter = logging.Formatter(log_format, date_format)
# 获取根logger
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
# 清除已有的handlers(避免重复)
root_logger.handlers.clear()
# 1. 控制台handler(只输出WARNING及以上级别)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.WARNING)
console_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
# 确保日志文件所在目录存在
log_dir = os.path.dirname(os.path.abspath(__file__))
# 2. 所有日志文件handler(all_log.log)
all_log_path = os.path.join(log_dir, 'all_log.log')
all_log_handler = RotatingFileHandler(
all_log_path,
maxBytes=10*1024*1024, # 10MB
backupCount=5,
encoding='utf-8'
) )
all_log_handler.setLevel(logging.DEBUG) # 记录所有级别
all_log_handler.setFormatter(formatter)
root_logger.addHandler(all_log_handler)
# 3. 错误日志文件handler(error.log)
error_log_path = os.path.join(log_dir, 'error.log')
error_log_handler = RotatingFileHandler(
error_log_path,
maxBytes=10*1024*1024, # 10MB
backupCount=5,
encoding='utf-8'
)
error_log_handler.setLevel(logging.ERROR) # 只记录ERROR及以上
error_log_handler.setFormatter(formatter)
root_logger.addHandler(error_log_handler)
# 初始化日志系统
setup_logging()
logger = logging.getLogger('worker') logger = logging.getLogger('worker')