Compare commits

..

2 Commits

Author SHA1 Message Date
CI Bot 7325622104 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m43s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m39s
AI Code Review / AI Code Review (pull_request) Successful in 6m28s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m16s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 7m45s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 7m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 10m53s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m31s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 29m34s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 32s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 39s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 112h0m37s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 112h30m13s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 112h30m24s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 112h30m1s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 112h30m1s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 112h30m1s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 112h30m7s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 112h30m7s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 112h30m8s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 112h0m24s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 112h0m27s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 112h0m20s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 112h0m24s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 112h29m55s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 112h29m55s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 112h30m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 112h29m57s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 112h30m0s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 113h5m12s
2026-09-11 14:53:23 +00:00
xiaoxia bfee46d6ea fix(lipsync, P0): 修复对口型任务卡 running 状态不更新
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m35s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m42s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m50s
AI Code Review / AI Code Review (pull_request) Successful in 6m26s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 8m25s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 8m25s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 112h41m3s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 112h41m9s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 112h41m12s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 112h40m55s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 112h40m55s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 112h40m52s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 112h40m52s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 112h40m46s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 112h40m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 112h40m48s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 112h40m53s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 112h40m54s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 112h40m58s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 113h16m3s
根因:TTS+MediaKit都成功了,但状态回写完全依赖前端轮询触发的FastAPI background task,
background task中db.commit()异常时无try/except且无日志,静默失败导致completed永远写不回DB。

修复三层防御:
1. refresh_job_status: db.commit()包try/except+rollback,失败打完整堆栈日志
2. GET /lipsync/jobs/{id}: 距上次更新超30秒时同步刷新(不挂后台),确保stale任务被及时拉取
3. 新增Celery兜底轮询任务lipsync_tts.poll_mediakit_status:TTS提交MediaKit成功后
   链式触发,worker主动轮询到终态(最多10分钟),不依赖前端
2026-09-11 22:42:18 +08:00
7 changed files with 249 additions and 276 deletions
+16 -1
View File
@@ -144,7 +144,22 @@ def get_lipsync_job(
raise HTTPException(status_code=404, detail="任务不存在")
if job.status not in ("completed", "failed"):
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
# 如果距上次更新超过 30 秒,同步刷新一次(避免 background task 静默失败导致永久卡 running);
# 否则挂后台异步刷新(避免阻塞前端轮询)。
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
stale = (
job.updated_at is None
or (now - job.updated_at).total_seconds() > 30
)
if stale:
try:
job = svc.refresh_job_status(job_id, current_user.user.id) or job
except Exception as exc: # noqa: BLE001
logger.error("同步刷新对口型状态失败 job_id=%s err=%s", job_id, exc, exc_info=True)
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
else:
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
return job
@@ -25,7 +25,6 @@ from packages.adapters.sqlalchemy_impl.models import (
ScriptModel,
)
from packages.domain.video_filter_builder import (
build_broll_overlay_filter,
build_title_drawtext_filter,
)
from packages.shared.storage import get_shared_storage_service
@@ -229,49 +228,27 @@ class AiAvatarRenderService:
self.db.commit()
# 2. 构建 FFmpeg 滤镜链 (40%)
# 用 ffprobe 探测输入视频分辨率,确保 B-roll 缩放与标题位置与实际输出一致。
# AI 数字人对口型输出为 9:16 竖屏,默认兜底 720x1280;探测失败时使用默认值不阻断渲染。
output_width, output_height = self._probe_video_resolution(input_video_path)
if output_width <= 0 or output_height <= 0:
output_width, output_height = 720, 1280
logger.info(
"[数字人渲染] ffprobe 探测分辨率失败或无效,使用默认竖屏尺寸 %sx%s",
output_width,
output_height,
)
else:
logger.info("[数字人渲染] 探测输入视频分辨率: %sx%s", output_width, output_height)
from packages.domain.video_filter_builder import build_broll_overlay_filter
broll_filter, broll_label = build_broll_overlay_filter(
filter_complex = build_broll_overlay_filter(
b_roll_segments=job.b_roll_segments,
video_duration=lipsync_job.output_duration,
output_width=output_width,
output_height=output_height,
)
# 标题叠加(传入实际输出尺寸,保证位置计算正确)
title_filter = build_title_drawtext_filter(
job.title_config,
output_width=output_width,
output_height=output_height,
)
# 标题叠加
title_filter = build_title_drawtext_filter(job.title_config)
if title_filter:
if filter_complex:
filter_complex += f"[vout]{title_filter}[vout_titled];"
else:
filter_complex = f"[0:v]{title_filter}[vout_titled];"
filter_complex = ""
final_label = None
if broll_filter and title_filter:
# B-roll → 标题叠在 B-roll 输出上
filter_complex = broll_filter + f";[{broll_label}]{title_filter}[vout_titled]"
final_label = "vout_titled"
elif broll_filter:
filter_complex = broll_filter
final_label = broll_label
elif title_filter:
filter_complex = f"[0:v]{title_filter}[vout_titled]"
final_label = "vout_titled"
else:
# 无滤镜:直接拷贝视频流
filter_complex = ""
final_label = None
# 清理末尾分号
if filter_complex.endswith(";"):
filter_complex = filter_complex[:-1]
# 最终输出标签
final_label = "vout_titled" if title_filter else ("vout" if filter_complex else None)
job.progress = 40
self.db.commit()
@@ -450,37 +427,6 @@ class AiAvatarRenderService:
os.unlink(tmp.name)
raise
@staticmethod
def _probe_video_resolution(video_path: str) -> tuple[int, int]:
"""用 ffprobe 探测视频分辨率,返回 (width, height);失败返回 (0, 0)。"""
try:
result = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0:s=x",
video_path,
],
capture_output=True,
text=True,
timeout=15,
)
if result.returncode == 0 and result.stdout.strip():
parts = result.stdout.strip().split("x")
if len(parts) == 2:
w, h = int(parts[0]), int(parts[1])
if w > 0 and h > 0:
return w, h
except Exception as exc:
logger.warning("[数字人渲染] ffprobe 探测分辨率失败: %s", exc)
return 0, 0
def _build_ffmpeg_command(
self,
*,
@@ -504,16 +450,7 @@ class AiAvatarRenderService:
cmd.extend(["-i", asset_url])
if filter_complex and final_label:
cmd.extend(
[
"-filter_complex",
filter_complex,
"-map",
f"[{final_label}]",
"-map",
"0:a?",
]
)
cmd.extend(["-filter_complex", filter_complex, "-map", f"[{final_label}]"])
elif filter_complex:
cmd.extend(["-filter_complex", filter_complex])
@@ -525,10 +462,6 @@ class AiAvatarRenderService:
"veryfast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-y",
output_path,
]
+34 -20
View File
@@ -310,26 +310,40 @@ class LipsyncService:
mk_status = status_data.get("status", STATUS_RUNNING)
logger.info("MediaKit 对口型状态 [%s]: %s", job_id, mk_status)
if mk_status == STATUS_COMPLETED:
result = status_data.get("result", {})
job.status = STATUS_COMPLETED
output_url = result.get("video_url", "")
# MediaKit 输出为临时 URL,转存自家 OSS 防止过期(失败则回退临时 URL)
job.output_video_url = self._persist_output_video(output_url, job_id, user_id)
job.output_duration = result.get("duration", 0.0)
job.completed_at = datetime.now(timezone.utc)
elif mk_status == STATUS_FAILED:
error = status_data.get("error", {})
job.status = "failed"
job.error_message = error.get("message", "任务执行失败")
job.error_code = error.get("code", "TaskFailed")
job.completed_at = datetime.now(timezone.utc)
else:
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
if isinstance(mk_status, str) and mk_status:
job.status = mk_status
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
try:
if mk_status == STATUS_COMPLETED:
result = status_data.get("result", {})
job.status = STATUS_COMPLETED
output_url = result.get("video_url", "")
# MediaKit 输出为临时 URL,转存自家 OSS 防止过期(失败则回退临时 URL)
job.output_video_url = self._persist_output_video(output_url, job_id, user_id)
job.output_duration = result.get("duration", 0.0)
job.completed_at = datetime.now(timezone.utc)
elif mk_status == STATUS_FAILED:
error = status_data.get("error", {})
job.status = "failed"
job.error_message = error.get("message", "任务执行失败")
job.error_code = error.get("code", "TaskFailed")
job.completed_at = datetime.now(timezone.utc)
else:
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
if isinstance(mk_status, str) and mk_status:
job.status = mk_status
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
except Exception as exc: # noqa: BLE001 - DB 提交失败必须记录日志并重试,否则后台任务静默失败
logger.error(
"refresh_job_status 提交 DB 失败 job_id=%s mk_status=%s err=%s",
job_id,
mk_status,
exc,
exc_info=True,
)
try:
self.db.rollback()
except Exception:
pass
# DB commit 失败不 raise,返回当前 job 对象让下次轮询再试
self.db.refresh(job)
return job
+93
View File
@@ -207,6 +207,14 @@ def tts_synthesize_and_submit(
db.commit()
# 4. 链式触发 Celery 兜底轮询:MediaKit 提交成功后由 worker 主动拉取状态到终态,
# 不依赖前端轮询触发的 FastAPI background task(后台任务可能静默失败导致永久卡 running)
if job.status == "submitted" and job.mediakit_task_id:
poll_mediakit_status.apply_async(
kwargs={"job_id": job_id, "user_id": user_id},
countdown=10, # 10 秒后开始轮询(给 MediaKit 一点处理时间)
)
except Exception:
logger.exception("[lipsync_tts] 未预期的异常: job_id=%s", job_id)
try:
@@ -221,3 +229,88 @@ def tts_synthesize_and_submit(
logger.exception("[lipsync_tts] 回写失败状态时异常: job_id=%s", job_id)
finally:
db.close()
@shared_task(
bind=True,
name="lipsync_tts.poll_mediakit_status",
max_retries=60, # 最多轮询 60 次
default_retry_delay=10, # 每次间隔 10 秒(总兜底时长 10 分钟)
)
def poll_mediakit_status(self, job_id: str, user_id: str):
"""Celery 兜底轮询:TTS 提交 MediaKit 后,由 worker 主动拉取状态直到终态。
不依赖前端轮询,避免 background task 静默失败导致任务永久卡 running/submitted。
"""
from sqlalchemy.orm import Session as DBSession
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
try:
from worker_app.db import SessionLocal # type: ignore
except Exception: # noqa: BLE001
from app.db import SessionLocal # type: ignore
db: DBSession = SessionLocal()
try:
job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id).first()
if job is None:
logger.warning("[lipsync_poll] Job not found: job_id=%s", job_id)
return
# 已终态,不需要再轮询
if job.status in ("completed", "failed", "cancelled"):
return
if not job.mediakit_task_id:
logger.warning("[lipsync_poll] Job has no mediakit_task_id: job_id=%s status=%s", job_id, job.status)
return
from app.services.mediakit_client import MediaKitError, get_mediakit_client
client = get_mediakit_client()
try:
status_data = client.get_task_status(job.mediakit_task_id)
except MediaKitError as exc:
logger.warning("[lipsync_poll] 拉取 MediaKit 状态失败,将重试: job_id=%s err=%s", job_id, exc)
raise self.retry(exc=exc)
mk_status = status_data.get("status", "running")
if mk_status == "succeeded":
# 复用 LipsyncService 的持久化逻辑
from app.services.lipsync_service import LipsyncService
svc = LipsyncService(db)
result = status_data.get("result", {})
job.status = "completed"
output_url = result.get("video_url", "")
job.output_video_url = svc._persist_output_video(output_url, job_id, user_id)
job.output_duration = result.get("duration", 0.0)
job.completed_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(timezone.utc)
db.commit()
logger.info("[lipsync_poll] 任务完成: job_id=%s", job_id)
elif mk_status in ("failed", "error"):
error = status_data.get("error", {})
job.status = "failed"
job.error_message = error.get("message", "任务执行失败")
job.error_code = error.get("code", "TaskFailed")
job.completed_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(timezone.utc)
db.commit()
logger.info("[lipsync_poll] 任务失败: job_id=%s err=%s", job_id, job.error_message)
else:
# 中间状态,更新时间戳,继续重试
job.updated_at = datetime.now(timezone.utc)
if isinstance(mk_status, str) and mk_status:
job.status = mk_status
db.commit()
logger.debug("[lipsync_poll] 任务仍在 %s,继续轮询: job_id=%s", mk_status, job_id)
raise self.retry()
except Exception as exc:
logger.exception("[lipsync_poll] 未预期异常: job_id=%s", job_id)
db.rollback()
raise self.retry(exc=exc)
finally:
db.close()
+83 -151
View File
@@ -377,30 +377,26 @@ def _append_audio_concat(parts: list[str], clip_chains: list[ClipFilterChain]) -
# ── 标题 drawtext 滤镜构建(#1789)─────────────────────────────────────────────
# drawtext 字体搜索路径:按优先级从高到低排列
# 服务器使用 Noto Sans SC(思源黑体)作为默认字体。
# - NotoSansSC-VF.ttf 是 worker-base.Dockerfile 中 COPY 的 VF 字体(含所有字重,无 Mono 变体),优先级最高
# - .ttc 系列为 fonts-noto-cjk 包预装字体(Dockerfile 已删除含 Mono 变体的旧 .ttc,存在时作为 fallback)
# - DejaVuSans 仅含拉丁字符不支持中文,已移除
# drawtext 字体搜索路径:按优先级列出常见安装位置
# 服务器使用 Noto Sans SC(思源黑体)作为默认字体
DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [
"/usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansSC-Regular.ttf",
"/usr/share/fonts/noto/NotoSansSC-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
# 前端字体名 → drawtext 字体搜索关键字(匹配 DRAWTEXT_FONT_SEARCH_PATHS 中的文件名关键字)
# 前端字体名 → drawtext 字体搜索关键字
DRAWTEXT_FONT_MAP: dict[str, str] = {
"思源黑体": "NotoSansSC",
"思源黑体": "NotoSansCJK",
"思源宋体": "NotoSerifCJK",
"苹方": "NotoSansSC",
"PingFang": "NotoSansSC",
"微软雅黑": "NotoSansSC",
"苹方": "NotoSansCJK",
"PingFang": "NotoSansCJK",
"微软雅黑": "NotoSansCJK",
"楷体": "NotoSerifCJK",
"华康俪金黑": "NotoSansSC",
"华康俪金黑": "NotoSansCJK",
}
@@ -508,30 +504,26 @@ def build_title_drawtext_filter(
params.append(f"fontsize={font_size}")
params.append(f"fontcolor={font_color}")
# 粗体:drawtext 没有独立的 bold 参数,通过加大 borderw 模拟视觉粗体效果。
# 注意:不能使用 `font=bold`——FFmpeg drawtext 的 font 参数需要 fontconfig 能解析的
# 字体族名,而 "bold" 不是合法族名,会导致整个 filter_complex 解析失败(exit code 234)。
# 当用户未显式配置描边宽度时,bold 模式自动将 borderw 提升到 3 以模拟粗体。
# 粗体:bold 在 drawtext 中通过 font 的 Bold 变体实现
# 若字体有 Bold 变体可用 fontfont=bold;否则通过 borderw 模拟
if bold:
# 使用 font 参数尝试加载 Bold 变体(Noto Sans SC 有 Bold 变体文件)
params.append("font=bold")
# 描边(borderw 需要 libfreetype 支持)
# 粗体无显式描边时,自动用 borderw=3 + 近色描边模拟粗体;显式 stroke 按用户配置走
border_width = 0
border_color = "000000"
if stroke:
if isinstance(stroke, bool):
border_width = 2
border_color = "000000"
border_color = "black"
elif isinstance(stroke, dict):
if stroke.get("enabled", True):
border_width = int(stroke.get("width", 2))
border_color = (stroke.get("color") or "#000000").lstrip("#")
elif bold:
# 粗体模式且未配描边:加大描边宽度模拟粗体效果
border_width = 3
border_color = font_color # 用字体同色描边,视觉上加粗字形而非黑边
if border_width > 0:
params.append(f"borderw={border_width}")
params.append(f"bordercolor={border_color}")
border_width = int(stroke.get("width", 2)) if stroke.get("enabled", True) else 0
border_color = (stroke.get("color") or "#000000").lstrip("#")
else:
border_width = 0
border_color = "black"
if border_width > 0:
params.append(f"borderw={border_width}")
params.append(f"bordercolor={border_color}")
# 阴影(shadowcolor + shadowx/y)
if shadow:
@@ -581,7 +573,7 @@ def build_broll_overlay_filter(
video_duration: float,
output_width: int = DEFAULT_OUTPUT_WIDTH,
output_height: int = DEFAULT_OUTPUT_HEIGHT,
) -> tuple[str, str | None]:
) -> str:
"""构建 B-roll 叠加滤镜链。
支持两种模式:
@@ -589,181 +581,121 @@ def build_broll_overlay_filter(
- pip: 在对口型视频上叠加画中画 B-roll
Args:
b_roll_segments: B-roll 片段配置列表(原始顺序,决定 FFmpeg -i 输入顺序)
b_roll_segments: B-roll 片段配置列表
video_duration: 对口型视频总时长(秒)
output_width: 输出宽度(默认 1280;AI 数字人竖屏传 720)
output_height: 输出高度(默认 720;AI 数字人竖屏传 1280)
output_width: 输出宽度
output_height: 输出高度
Returns:
(filter_complex_str, final_label)
- filter_complex_str: filter_complex 片段字符串(末尾无分号)
- final_label: 最终输出 pad 标签名,如 "vout";无 B-roll 时返回 None
FFmpeg filter_complex 滤镜字符串片段
"""
if not b_roll_segments:
return "", None
# 建立原始列表下标 → FFmpeg 输入下标的映射:
# cmd 中 [0:v] 是主视频,随后按 b_roll_segments 原始顺序追加 -i,
# 因此第 i 个 segment 的输入是 [{i+1}:v]
def _input_label(seg: dict[str, Any]) -> str:
# seg 必须来自 b_roll_segments;通过 id() 在原列表中查找
for i, s in enumerate(b_roll_segments):
if s is seg:
return f"[{i + 1}:v]"
# fallback: 找不到时不应发生,保守返回
return "[1:v]"
return ""
parts: list[str] = []
sorted_segments = sorted(b_roll_segments, key=lambda s: s.get("start_time", 0))
# 按模式分组
# 按模式分组处理
fullscreen_segments = [s for s in sorted_segments if s.get("mode") == "fullscreen"]
pip_segments = [s for s in sorted_segments if s.get("mode") == "pip"]
final_label = None
# ── fullscreen 模式: 切分 + concat ──
if fullscreen_segments:
fs_filter, fs_label = _build_fullscreen_filters(
fullscreen_segments, b_roll_segments, video_duration, output_width, output_height, _input_label
)
parts.append(fs_filter)
final_label = fs_label
else:
fs_label = None
parts.append(_build_fullscreen_filters(fullscreen_segments, video_duration, output_width, output_height))
# ── pip 模式: overlay 滤镜 ──
if pip_segments:
pip_filter, pip_label = _build_pip_filters(
pip_segments, output_width, output_height, _input_label, base_label=fs_label
)
parts.append(pip_filter)
final_label = pip_label
for idx, seg in enumerate(pip_segments):
start = seg.get("start_time", 0)
end = seg.get("end_time", video_duration)
scale = seg.get("pip_scale", 0.3)
position = seg.get("pip_position", "bottom_right")
pip_w = int(output_width * scale)
pip_h = int(output_height * scale)
# 位置映射
pos_map = {
"top_left": "10:10",
"top_right": "W-w-10:10",
"bottom_left": "10:H-h-10",
"bottom_right": "W-w-10:H-h-10",
"center": "(W-w)/2:(H-h)/2",
}
pos_expr = pos_map.get(position, pos_map["bottom_right"])
broll_input_idx = len(sorted_segments) # placeholder for input index
parts.append(
f"[{broll_input_idx + idx}:v]scale={pip_w}:{pip_h}," f"enable='between(t,{start},{end})'[pip{idx}];"
)
# overlay onto main stream
if idx == 0:
base_label = "[vout]" if fullscreen_segments else "[0:v]"
else:
base_label = f"[pip{idx - 1}]"
parts.append(f"{base_label}[pip{idx}]overlay={pos_expr}:enable='between(t,{start},{end})'[vout{idx}];")
result = "".join(parts)
# 清理末尾多余分号
if result.endswith(";"):
result = result[:-1]
return result, final_label
return result
def _build_fullscreen_filters(
sorted_fs_segments: list[dict[str, Any]],
all_segments: list[dict[str, Any]],
segments: list[dict[str, Any]],
video_duration: float,
output_width: int,
output_height: int,
input_label_fn,
) -> tuple[str, str]:
"""构建 fullscreen 模式的切分 + concat 滤镜。
) -> str:
"""构建 fullscreen 模式的切分 + concat 滤镜.
将主视频按 B-roll 时间段切分,然后用 concat 拼接主视频片段和 B-roll 片段。
Returns:
(filter_str, final_label) 其中 final_label 是 concat 输出的 pad 标签
将对口型视频按 B-roll 时间段切分,然后用 concat 拼接 B-roll 片段。
"""
parts: list[str] = []
prev_end = 0.0
# 注意:这里的 idx 是 sorted_fs_segments 中的下标;
# 实际 FFmpeg 输入下标必须通过 input_label_fn 查询
for idx, seg in enumerate(sorted_fs_segments):
for idx, seg in enumerate(segments):
start = seg.get("start_time", 0)
end = seg.get("end_time", video_duration)
# 主视频片段(B-roll 之前)
# 保持原视频片段(B-roll 之前的部分)
if prev_end < start:
parts.append(f"[0:v]trim=start={prev_end}:end={start},setpts=PTS-STARTPTS[main{idx}];")
# B-roll 片段:缩放到输出分辨率并裁到对应时长
in_lbl = input_label_fn(seg)
# B-roll 片段:缩放至目标分辨率
parts.append(
f"{in_lbl}scale={output_width}:{output_height}"
f"[{idx + 1}:v]scale={output_width}:{output_height}"
f":force_original_aspect_ratio=decrease,"
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2,"
f"trim=start=0:end={end - start},setpts=PTS-STARTPTS[br{idx}];"
)
prev_end = end
# 尾部主视频片段
# 尾部片段
if prev_end < video_duration:
last_idx = len(sorted_fs_segments)
last_idx = len(segments)
parts.append(f"[0:v]trim=start={prev_end}:end={video_duration},setpts=PTS-STARTPTS[main{last_idx}];")
# concat 所有片段
segment_labels: list[str] = []
for idx, seg in enumerate(sorted_fs_segments):
start = seg.get("start_time", 0)
# 每段 B-roll 之前是否有主视频片段?
has_main_before = (idx == 0 and start > 0) or (idx > 0 and sorted_fs_segments[idx - 1].get("end_time", 0) < start)
if has_main_before:
segment_labels.append(f"[main{idx}]")
segment_labels = []
for idx in range(len(segments)):
start = segments[idx].get("start_time", 0)
if (idx == 0 and segments[0].get("start_time", 0) > 0) or idx > 0:
prev_end_prev = segments[idx - 1].get("end_time", 0) if idx > 0 else 0
if prev_end_prev < start:
segment_labels.append(f"[main{idx}]")
segment_labels.append(f"[br{idx}]")
if prev_end < video_duration:
segment_labels.append(f"[main{len(sorted_fs_segments)}]")
final_lbl = "vout_fs"
if prev_end < video_duration:
segment_labels.append(f"[main{len(segments)}]")
n = len(segment_labels)
if n > 0:
concat_inputs = "".join(segment_labels)
parts.append(f"{concat_inputs}concat=n={n}:v=1:a=0[{final_lbl}];")
return "".join(parts), final_lbl
def _build_pip_filters(
pip_segments: list[dict[str, Any]],
output_width: int,
output_height: int,
input_label_fn,
base_label: str | None,
) -> tuple[str, str]:
"""构建 PIP(画中画)overlay 滤镜链。
Args:
pip_segments: 按时间排序的 pip 片段
output_width: 输出宽度
output_height: 输出高度
input_label_fn: 片段 → 输入标签的映射函数
base_label: 前序滤镜链输出的标签(如 fullscreen 的 vout_fs),为 None 则基于 [0:v]
Returns:
(filter_str, final_label)
"""
parts: list[str] = []
cur_label = base_label # 当前叠加到的标签
pos_map = {
"top_left": "10:10",
"top_right": "W-w-10:10",
"bottom_left": "10:H-h-10",
"bottom_right": "W-w-10:H-h-10",
"center": "(W-w)/2:(H-h)/2",
}
for idx, seg in enumerate(pip_segments):
start = seg.get("start_time", 0)
end = seg.get("end_time", 0)
scale = seg.get("pip_scale", 0.3)
position = seg.get("pip_position", "bottom_right")
pos_expr = pos_map.get(position, pos_map["bottom_right"])
pip_w = max(1, int(output_width * scale))
pip_h = max(1, int(output_height * scale))
enable_expr = f"enable='between(t,{start},{end})'"
in_lbl = input_label_fn(seg)
pip_scaled = f"pip{idx}"
parts.append(f"{in_lbl}scale={pip_w}:{pip_h},{enable_expr}[{pip_scaled}];")
# overlay onto the current base
base = f"[{cur_label}]" if cur_label else "[0:v]"
out_lbl = f"vout_pip{idx}" if idx < len(pip_segments) - 1 else "vout"
parts.append(f"{base}[{pip_scaled}]overlay={pos_expr}:{enable_expr}[{out_lbl}];")
cur_label = out_lbl
return "".join(parts), cur_label or "vout"
parts.append(f"{concat_inputs}concat=n={n}:v=1:a=0[vout];")
return "".join(parts)
def build_cover_extract_command(
+3 -6
View File
@@ -258,9 +258,8 @@ class TestBrollOverlayFilter:
def test_empty_segments_returns_empty(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter
result, label = build_broll_overlay_filter([], 30.0)
result = build_broll_overlay_filter([], 30.0)
assert result == ""
assert label is None
def test_pip_mode_generates_overlay(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter
@@ -276,9 +275,8 @@ class TestBrollOverlayFilter:
"pip_scale": 0.3,
}
]
result, label = build_broll_overlay_filter(segments, 30.0)
result = build_broll_overlay_filter(segments, 30.0)
assert "overlay" in result or "scale=" in result
assert label == "vout"
def test_fullscreen_mode_generates_concat(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter
@@ -292,9 +290,8 @@ class TestBrollOverlayFilter:
"end_time": 10.0,
}
]
result, label = build_broll_overlay_filter(segments, 30.0)
result = build_broll_overlay_filter(segments, 30.0)
assert "trim" in result or "concat" in result
assert label == "vout_fs"
def test_cover_extract_command(self):
from packages.domain.video_filter_builder import build_cover_extract_command
+4 -15
View File
@@ -902,10 +902,9 @@ class TestResolveFontPath(unittest.TestCase):
@patch("os.path.isfile")
def test_unknown_font_fallback(self, mock_isfile):
# DejaVuSans 已从 fallback 列表移除(不支持 CJK),用 VF 路径模拟
mock_isfile.side_effect = lambda p: "NotoSansSC-VF" in p
mock_isfile.side_effect = lambda p: "DejaVu" in p
result = _resolve_font_path("UnknownFont")
self.assertIn("NotoSansSC-VF", result)
self.assertIn("DejaVu", result)
@patch("os.path.isfile")
def test_no_fonts_available(self, mock_isfile):
@@ -928,11 +927,9 @@ class TestResolveFontPath(unittest.TestCase):
@patch("os.path.isfile")
def test_font_fallback_skips_nonexistent(self, mock_isfile):
# 所有中文字体路径都不存在时,fallback 返回第一个存在的文件;
# DejaVuSans 已从列表移除(不支持 CJK),使用 VF 字体路径模拟存在文件
mock_isfile.side_effect = lambda p: "NotoSansSC-VF" in p
mock_isfile.side_effect = lambda p: "DejaVu" in p
result = _resolve_font_path("不存在字体")
self.assertIn("NotoSansSC-VF", result)
self.assertIn("DejaVu", result)
class TestDrawtextFontFileIncluded(unittest.TestCase):
@@ -1031,14 +1028,6 @@ class TestDrawtextBoldFalse(unittest.TestCase):
self.assertIsNotNone(result)
self.assertNotIn("font=bold", result)
def test_bold_true_does_not_use_font_bold_param(self):
"""粗体模式不得使用 `font=bold`——该参数无效,会导致 filter_complex 解析失败(exit 234)。"""
result = build_title_drawtext_filter({"text": "标题", "bold": True})
self.assertIsNotNone(result)
self.assertNotIn("font=bold", result)
# 粗体应通过 borderw 实现
self.assertIn("borderw=", result)
class TestDrawtextPositionBranches(unittest.TestCase):
"""位置相关分支覆盖。"""