fix: 渲染失败三重修复 - legacy fps滤镜 + 横屏过滤 + legacy分辨率读取 #500

Merged
auto-approve-bot merged 1 commits from fix/render-fps-and-landscape into develop 2026-07-18 13:11:59 +08:00
5 changed files with 204 additions and 9 deletions
+22 -2
View File
@@ -98,7 +98,7 @@ def auto_select_video_assets(
# 过滤不支持的编码格式(HEVC/VP9/AV1 等会导致渲染失败)
# 优先读 asset.codec 字段,其次从 metadata 里取(兼容存量数据)
filtered_videos = []
codec_filtered = []
skipped_codec = 0
for a in ready_videos:
codec = (a.codec or "").lower()
@@ -107,11 +107,31 @@ def auto_select_video_assets(
if codec and codec in UNSUPPORTED_CODECS:
skipped_codec += 1
continue
filtered_videos.append(a)
codec_filtered.append(a)
if skipped_codec and logger:
logger.warning("自动选素材: 跳过 %d 个不支持编码的素材", skipped_codec)
# 过滤横屏素材(只保留竖屏/正方形)
# 移动端短视频场景默认竖屏,横屏素材裁剪后画面不可用
filtered_videos = []
skipped_landscape = 0
for a in codec_filtered:
width = a.width if hasattr(a, "width") and a.width else 0
height = a.height if hasattr(a, "height") and a.height else 0
if not width or not height:
# 从 metadata 兜底
if a.metadata and isinstance(a.metadata, dict):
width = int(a.metadata.get("width", 0) or 0)
height = int(a.metadata.get("height", 0) or 0)
if width and height and width > height:
skipped_landscape += 1
continue
filtered_videos.append(a)
if skipped_landscape and logger:
logger.warning("自动选素材: 跳过 %d 个横屏素材", skipped_landscape)
if not filtered_videos:
if logger:
logger.warning("自动选素材: 素材库 %s 无可用视频素材", video_lib.name)
+10 -4
View File
@@ -219,7 +219,7 @@ class VideoComposeService:
根据 EditPlan 的所有 ready 片段,生成完整的 filter_complex 命令。
滤镜链逻辑:
- 每个片段:scale → crop → setpts → trim → atrim
- 每个片段:scale → crop → fps → setpts → trim → atrim
- 多片段之间:concat 滤镜 或 xfade 转场
- 最终输出:-map '[outv]' -map '[outa]'(如有音频)
"""
@@ -406,9 +406,10 @@ class VideoComposeService:
滤镜顺序:
1. scale — 等比缩放到目标分辨率(保证覆盖)
2. crop — 居中裁剪到目标分辨率
3. setpts — 重置时间戳 + 偏移
4. trim — 视频时长裁剪
5. atrim — 频时长裁剪(如有音频流)
3. fps — 统一输出帧率(concat 要求所有输入帧率一致)
4. setpts — 重置时间戳 + 偏移
5. trim — 频时长裁剪
6. atrim — 音频时长裁剪(如有音频流)
"""
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
start = clip.start_time
@@ -421,6 +422,11 @@ class VideoComposeService:
# 2. crop: 居中裁剪
filters.append(f"crop={output_width}:{output_height}")
# 2.5 fps: 统一帧率(concat 要求所有输入帧率一致)
# 放在 crop 之后、setpts 之前,确保分辨率和帧率都已统一
if fps and fps > 0:
filters.append(f"fps={fps}")
# 3. setpts: 重置时间戳
if start > 0:
filters.append(f"setpts=PTS-STARTPTS+{start}/TB")
@@ -332,7 +332,34 @@ def _render_with_legacy(
# 构建 FFmpeg 命令
output_dir = os.environ.get("VIDEO_OUTPUT_DIR", str(tmpdir_path))
output_path = Path(output_dir) / f"{plan_id}.mp4"
compose_cmd = compose_svc.build_compose_command(plan_id, str(output_path))
# 从 plan.config.export 读取输出分辨率,兼容 plan 自定义配置
plan_config = plan.config or {}
export_config = plan_config.get("export", {}) or {}
output_width = OUTPUT_WIDTH
output_height = OUTPUT_HEIGHT
resolution = export_config.get("resolution", "")
if resolution and "x" in resolution:
try:
w_str, h_str = resolution.lower().split("x", 1)
output_width = int(w_str)
output_height = int(h_str)
except (ValueError, TypeError):
pass
fps = export_config.get("fps", 25)
try:
fps = int(fps)
except (ValueError, TypeError):
fps = 25
compose_cmd = compose_svc.build_compose_command(
plan_id,
str(output_path),
output_width=output_width,
output_height=output_height,
fps=fps,
)
logger.info("执行 FFmpeg (legacy): plan_id=%s", plan_id)
try:
@@ -364,8 +391,8 @@ def _render_with_legacy(
storage_key=storage_key,
duration=duration,
file_size=file_size,
width=OUTPUT_WIDTH,
height=OUTPUT_HEIGHT,
width=output_width,
height=output_height,
rendered_clip_ids=rendered_clip_ids,
failed_clip_ids=failed_clip_ids,
generation_task_id=generation_task_id,
+115
View File
@@ -311,3 +311,118 @@ class TestAutoSelectVideoAssetsCodecFilter:
)
assert result == ["newest", "middle", "oldest"]
def test_landscape_videos_filtered_out(self):
"""横屏视频(width > height)被过滤掉."""
from app.api.routes._helpers import auto_select_video_assets
assets = [
_make_asset("portrait1", codec="h264", width=1080, height=1920),
_make_asset("landscape1", codec="h264", width=1920, height=1080),
_make_asset("portrait2", codec="h264", width=720, height=1280),
_make_asset("landscape2", codec="h264", width=1280, height=720),
]
asset_repo = _make_mock_repo(assets)
lib_repo = _make_mock_lib_repo()
result = auto_select_video_assets(
project_id="proj-1",
asset_library_repo=lib_repo,
asset_repo=asset_repo,
)
assert set(result) == {"portrait1", "portrait2"}
assert len(result) == 2
def test_square_videos_allowed(self):
"""正方形视频(width == height)不被过滤."""
from app.api.routes._helpers import auto_select_video_assets
assets = [
_make_asset("square", codec="h264", width=720, height=720),
_make_asset("portrait", codec="h264", width=1080, height=1920),
]
asset_repo = _make_mock_repo(assets)
lib_repo = _make_mock_lib_repo()
result = auto_select_video_assets(
project_id="proj-1",
asset_library_repo=lib_repo,
asset_repo=asset_repo,
)
assert "square" in result
assert "portrait" in result
def test_unknown_size_not_filtered(self):
"""尺寸未知的素材不被过滤(避免误杀存量数据)."""
from app.api.routes._helpers import auto_select_video_assets
assets = [
_make_asset("unknown", codec="h264", width=0, height=0),
_make_asset("portrait", codec="h264", width=1080, height=1920),
]
asset_repo = _make_mock_repo(assets)
lib_repo = _make_mock_lib_repo()
result = auto_select_video_assets(
project_id="proj-1",
asset_library_repo=lib_repo,
asset_repo=asset_repo,
)
assert "unknown" in result
assert "portrait" in result
def test_metadata_size_fallback(self):
"""width/height字段为空时从metadata兜底读取."""
from app.api.routes._helpers import auto_select_video_assets
assets = [
_make_asset(
"landscape_in_meta",
codec="h264",
width=0,
height=0,
metadata={"width": 1920, "height": 1080},
),
_make_asset(
"portrait_in_meta",
codec="h264",
width=0,
height=0,
metadata={"width": 1080, "height": 1920},
),
]
asset_repo = _make_mock_repo(assets)
lib_repo = _make_mock_lib_repo()
result = auto_select_video_assets(
project_id="proj-1",
asset_library_repo=lib_repo,
asset_repo=asset_repo,
)
assert "landscape_in_meta" not in result
assert "portrait_in_meta" in result
def test_codec_and_landscape_combined_filter(self):
"""编码过滤 + 横屏过滤同时生效."""
from app.api.routes._helpers import auto_select_video_assets
assets = [
_make_asset("good", codec="h264", width=1080, height=1920),
_make_asset("bad_codec", codec="hevc", width=1080, height=1920),
_make_asset("bad_landscape", codec="h264", width=1920, height=1080),
_make_asset("bad_both", codec="hevc", width=1920, height=1080),
]
asset_repo = _make_mock_repo(assets)
lib_repo = _make_mock_lib_repo()
result = auto_select_video_assets(
project_id="proj-1",
asset_library_repo=lib_repo,
asset_repo=asset_repo,
)
assert result == ["good"]
+27
View File
@@ -357,6 +357,33 @@ class TestBuildComposeCommand(TestCase):
self.assertIn("crop=", filter_text)
self.assertIn("trim=", filter_text)
def test_filter_chain_contains_fps(self):
"""滤镜链包含 fps 滤镜,用于统一帧率避免 concat 失败。"""
plan = _StubPlan()
clips = [_make_ready_clip(plan_id=plan.id)]
svc = _make_service(plan, clips)
cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4", fps=25)
chain = cmd.clip_chains[0]
filter_text = ",".join(chain.filters)
# fps 必须在 crop 之后、setpts 之前
crop_idx = filter_text.index("crop=")
fps_idx = filter_text.index("fps=25")
setpts_idx = filter_text.index("setpts=")
self.assertGreater(fps_idx, crop_idx, "fps 应该在 crop 之后")
self.assertLess(fps_idx, setpts_idx, "fps 应该在 setpts 之前")
def test_fps_zero_or_none_skips_fps_filter(self):
"""fps 为 0 或负值时不添加 fps 滤镜。"""
plan = _StubPlan()
clips = [_make_ready_clip(plan_id=plan.id)]
svc = _make_service(plan, clips)
cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4", fps=0)
chain = cmd.clip_chains[0]
filter_text = ",".join(chain.filters)
self.assertNotIn("fps=", filter_text)
def test_start_time_offset(self):
"""片段 start_time > 0 时生成 setpts 偏移。"""
plan = _StubPlan()