feat(#1970): 新 API 字段 + 叙事模式 PR3 - assembly_mode/script_id/tts_*/video_ratio (#1976)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 40s
CI/CD Pipeline / Build Staging API Image (push) Successful in 45s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m21s
CI/CD Pipeline / Validate - Style (push) Successful in 3m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m34s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 38s
CI/CD Pipeline / Integration Tests (push) Successful in 5m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m25s
CI/CD Pipeline / Validate - Security (push) Successful in 6m52s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m6s
CI/CD Pipeline / Unit Tests (push) Successful in 9m14s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped

Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
This commit was merged in pull request #1976.
This commit is contained in:
2026-09-18 07:30:43 +08:00
committed by auto-approve-bot
parent a59a6a588a
commit 4fa3e4eb92
9 changed files with 1521 additions and 10 deletions
+145 -4
View File
@@ -16,10 +16,12 @@ from app.core.task_enqueue import (
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_cosyvoice_service,
get_db_session,
get_generated_video_repository,
get_generation_task_repository,
get_project_repository,
get_voice_clone_profile_repository,
)
from app.schemas.generated_video import (
GeneratedVideoResponse,
@@ -132,6 +134,8 @@ def _select_assets_from_library(
mode: str,
count: int,
rng=None,
script_tags: list | None = None,
tag_names_by_id: dict | None = None,
) -> list[str]:
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
@@ -141,6 +145,8 @@ def _select_assets_from_library(
count: 选取数量,0 表示全部(仅 smart 模式有效)
rng: 可选随机源(smart 模式排序噪声用),生产环境不传则内部随机;
测试可注入固定种子或零噪声随机源获得确定性结果。
script_tags: #1970 叙事模式文案标签;非空时标签命中素材优先,不足再用其余素材兜底。
tag_names_by_id: asset_id → 素材标签名列表(素材只存 tag_ids 时由调用方查名称注入)。
Returns:
选中的素材 ID 列表
@@ -150,6 +156,20 @@ def _select_assets_from_library(
if not ready_video_assets:
return []
# 叙事模式(#1970 PR3):文案标签命中池优先;无任何命中时完全降级为现有随机逻辑。
if script_tags:
from packages.domain.narrative_match import pick_narrative_assets
limit = count if count > 0 else None
picked = pick_narrative_assets(
ready_video_assets,
script_tags=script_tags,
tag_names_by_id=tag_names_by_id,
limit=limit,
rng=rng,
)
return [a.id for a in picked]
if mode == "smart":
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
@@ -162,6 +182,53 @@ def _select_assets_from_library(
return [a.id for a in ready_video_assets]
# #1970 PR3:video_ratio → 默认输出分辨率(显式 output_width/output_height 优先)
_VIDEO_RATIO_DIMENSIONS = {
"9:16": (1080, 1920),
"16:9": (1920, 1080),
"1:1": (1080, 1080),
"3:4": (1080, 1440),
"4:3": (1440, 1080),
}
def _resolve_output_dimensions(request: CreateGenerationTaskRequest) -> tuple[int, int]:
"""解析输出分辨率:显式 output_width/output_height 非旧默认值时优先,否则按 video_ratio。
前端 #1973 总是同时传 video_ratio 与具体分辨率,两者一致;此函数主要服务
只传比例的调用方,并保证旧调用(不传比例)维持 1280x720 行为。
"""
width, height = request.output_width, request.output_height
ratio = (request.video_ratio or "").strip()
if ratio in _VIDEO_RATIO_DIMENSIONS and (width, height) == (1280, 720):
return _VIDEO_RATIO_DIMENSIONS[ratio]
return width, height
def _load_asset_tag_names(db: Session, assets: list, user_id: str) -> dict[str, list[str]]:
"""叙事模式:查 TagModel 名称,构造 asset_id → 标签名列表(失败返回空 dict 降级随机)。"""
try:
from packages.adapters.sqlalchemy_impl.models import AssetTagModel, TagModel
tag_ids = {tid for a in assets for tid in (getattr(a, "tag_ids", None) or [])}
if not tag_ids:
return {}
name_rows = (
db.query(TagModel.id, TagModel.name).filter(TagModel.id.in_(tag_ids), TagModel.user_id == user_id).all()
)
name_by_id = {row.id: row.name for row in name_rows}
links = db.query(AssetTagModel.asset_id, AssetTagModel.tag_id).filter(AssetTagModel.tag_id.in_(tag_ids)).all()
index: dict[str, list[str]] = {}
for asset_id, tag_id in links:
name = name_by_id.get(tag_id)
if name:
index.setdefault(asset_id, []).append(name)
return index
except Exception: # noqa: BLE001 - 标签匹配是加分项,查询失败不阻断生成
logger.warning("[叙事模式] 素材标签查询失败,降级随机选片", exc_info=True)
return {}
def _writeback_edit_plan_config(
plan_id: str,
task_id: str,
@@ -169,12 +236,23 @@ def _writeback_edit_plan_config(
db: Session,
dedup_enabled: bool | None = None,
video_index: int | None = None,
assembly_mode: str | None = None,
script_id: str | None = None,
video_ratio: str | None = None,
) -> None:
"""[已下沉] 路由层兼容别名 → app.services.generation_common.writeback_edit_plan_config。"""
from app.services.generation_common import writeback_edit_plan_config
return writeback_edit_plan_config(
plan_id, task_id, title_config, db, dedup_enabled=dedup_enabled, video_index=video_index
plan_id,
task_id,
title_config,
db,
dedup_enabled=dedup_enabled,
video_index=video_index,
assembly_mode=assembly_mode,
script_id=script_id,
video_ratio=video_ratio,
)
@@ -225,16 +303,63 @@ def create_generation_task(
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
db: Session = Depends(get_db_session),
cosyvoice_service: Any = Depends(get_cosyvoice_service),
voice_clone_repository: Any = Depends(get_voice_clone_profile_repository),
) -> BatchGenerationTaskResponse:
logger.info(
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, assembly=%s, count=%d",
authenticated_user.user.id,
request.template_id,
len(request.asset_ids),
request.asset_select_mode,
request.assembly_mode,
request.count,
)
# video_ratio → 默认分辨率(显式分辨率优先)
request.output_width, request.output_height = _resolve_output_dimensions(request)
# ── #1970 PR3 叙事模式:入队前同步合成配音并落为 audio asset ──
# 合成结果覆盖 voice_library_id(下游按 audio asset id 消费),失败直接 4xx 不入队。
narrative_script_tags: list = []
if request.assembly_mode == "narrative":
from app.config import settings as _settings
from app.services.narrative_service import NarrativeError, prepare_narrative_voice
from packages.adapters.sqlalchemy_impl.tts_job_repository import SQLAlchemyTTSJobRepository
try:
narrative_ctx = prepare_narrative_voice(
db=db,
user_id=authenticated_user.user.id,
script_id=request.script_id,
tts_voice_id=request.tts_voice_id,
tts_voice_source=request.tts_voice_source,
tts_repository=SQLAlchemyTTSJobRepository(db),
cosyvoice_service=cosyvoice_service,
voice_clone_repository=voice_clone_repository,
asset_repository=asset_repository,
asset_library_repository=asset_library_repository,
project_repository=project_repository,
storage_service=get_storage_service(),
points_enabled=bool(getattr(_settings, "points_enabled", False)),
is_member=bool(getattr(authenticated_user.user, "is_member", False)),
member_type=getattr(authenticated_user.user, "member_type", None),
)
except NarrativeError as e:
logger.warning("[叙事模式] 配音前置处理失败: %s", e.message)
raise HTTPException(status_code=e.status_code, detail=e.message) from e
request.voice_library_id = narrative_ctx.voice_asset_id
narrative_script_tags = list(getattr(narrative_ctx.script, "tags", None) or [])
logger.info(
"[叙事模式] 配音已就绪: script_id=%s, tts_job=%s, voice_asset=%s, duration=%.2f",
request.script_id,
narrative_ctx.tts_job_id,
narrative_ctx.voice_asset_id,
narrative_ctx.audio_duration,
)
try:
project_id, asset_library_id = _resolve_project_and_library(
request, project_repository, asset_library_repository, asset_repository, authenticated_user
@@ -260,19 +385,29 @@ def create_generation_task(
# 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
if not resolved_asset_ids:
_tag_index = (
_load_asset_tag_names(db, assets, authenticated_user.user.id) if narrative_script_tags else None
)
resolved_asset_ids = _select_assets_from_library(
assets,
mode=request.asset_select_mode,
count=request.asset_select_count,
script_tags=narrative_script_tags or None,
tag_names_by_id=_tag_index,
)
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("smart",):
# 项目级模式:未指定 asset_ids 且选择了 smart 模式时,也自动选取
elif project_id and not resolved_asset_ids and (request.asset_select_mode in ("smart",) or narrative_script_tags):
# 项目级模式:未指定 asset_ids 且选择了 smart 模式(或叙事模式按标签匹配)时自动选取
assets = asset_repository.find_by_project(project_id)
if assets:
_tag_index = (
_load_asset_tag_names(db, assets, authenticated_user.user.id) if narrative_script_tags else None
)
resolved_asset_ids = _select_assets_from_library(
assets,
mode=request.asset_select_mode,
count=request.asset_select_count,
script_tags=narrative_script_tags or None,
tag_names_by_id=_tag_index,
)
if not resolved_asset_ids:
raise HTTPException(
@@ -337,6 +472,9 @@ def create_generation_task(
title_config=fallback_title_config,
db=db,
dedup_enabled=request.dedup_enabled,
assembly_mode=request.assembly_mode,
script_id=request.script_id or None,
video_ratio=request.video_ratio or None,
)
logger.info(
@@ -685,6 +823,9 @@ def create_generation_task(
db=db,
dedup_enabled=request.dedup_enabled,
video_index=task_index,
assembly_mode=request.assembly_mode,
script_id=request.script_id or None,
video_ratio=request.video_ratio or None,
)
if safe_enqueue_generation_task(