fix: 渲染失败三重修复 - legacy fps滤镜 + 横屏过滤 + legacy分辨率读取 #500
@@ -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)
|
||||
|
||||
Regular → Executable
+10
-4
@@ -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,
|
||||
|
||||
Regular → Executable
+115
@@ -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"]
|
||||
|
||||
Regular → Executable
+27
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user