a56b3f7b42
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 13s
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 49s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m41s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m48s
CI/CD Pipeline / Integration Tests (push) Successful in 3m54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m44s
CI/CD Pipeline / Validate - Style (push) Successful in 4m52s
CI/CD Pipeline / Unit Tests (push) Successful in 9m2s
CI/CD Pipeline / Validate - Security (push) Successful in 12m14s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Failing after 37h47m53s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 37h59m30s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 38h0m13s
CI/CD Pipeline / PR Build Web Image (push) Failing after 37h59m40s
CI/CD Pipeline / PR Build API Image (push) Failing after 37h59m40s
CI/CD Pipeline / Deploy Production (push) Failing after 37h47m15s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 37h47m20s
CI/CD Pipeline / Build Production Web Image (push) Failing after 37h47m20s
CI/CD Pipeline / Build Production API Image (push) Failing after 37h47m20s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 37h58m57s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 37h58m57s
CI/CD Pipeline / Frontend Lint (push) Failing after 37h59m37s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 37h59m41s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
241 lines
9.3 KiB
Python
241 lines
9.3 KiB
Python
"""轻量选片接口 POST /generation/variant-plans(#1749)。
|
||
|
||
与正式生成共用同一套选片函数(EditPlanService.ensure_variant_plans →
|
||
clone_plan_for_variant / reselect_plan_for_variant → variant_plan_selector),
|
||
但**不建任务、不入队、不渲染**:
|
||
|
||
- 仅为 N 个变体创建/选好 EditPlan + clips,返回 plan_id 与片段列表;
|
||
- 前端确认后调正式生成接口回传 variant_plan_ids,直接复用这些 plan,
|
||
不再重复选片(回传后仍按各变体配音幂等重分配段长);
|
||
- 配音守卫:voice_library_ids 长度/缺值 → 400(variant_voice_resolver),
|
||
禁静默 fallback;
|
||
- 素材不足等选片失败 → 400(与正式生成同口径);除此之外不报错打断。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
from app.auth import AuthenticatedUser, get_current_user
|
||
from app.dependencies import get_db_session
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel, Field, model_validator
|
||
from sqlalchemy.orm import Session
|
||
|
||
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
def _get_or_create_default_template_id(db: Session, user_id: str) -> str | None:
|
||
"""为用户查找一个有效模板;若不存在则自动创建默认配音模板。
|
||
|
||
前端 #1911 删除了模板选择 UI,当调用方未传 template_id/source_edit_plan_id
|
||
时(如剪辑页首次进入直接选片),后端兜底查找/创建默认模板,避免 400。
|
||
|
||
Returns:
|
||
template_id(字符串);失败时返回 None。
|
||
"""
|
||
|
||
from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel, TemplateModel
|
||
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
|
||
from packages.application.template.commands import CreateTemplateCommand, SegmentCommand
|
||
from packages.application.template.use_cases import CreateTemplateUseCase
|
||
|
||
# 1. 先查已有有效模板(is_active=True 且存在片段配置)
|
||
existing = (
|
||
db.query(TemplateModel)
|
||
.filter(
|
||
TemplateModel.user_id == user_id,
|
||
TemplateModel.is_active.is_(True),
|
||
)
|
||
.order_by(TemplateModel.created_at.asc())
|
||
.first()
|
||
)
|
||
if existing is not None:
|
||
# 验证该模板是否有片段配置;若没有继续尝试创建默认
|
||
has_seg = (
|
||
db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first()
|
||
)
|
||
if has_seg:
|
||
return existing.id
|
||
|
||
# 2. 无有效模板 → 自动创建默认配音模板
|
||
try:
|
||
repo = SQLAlchemyTemplateRepository(db)
|
||
cmd = CreateTemplateCommand(
|
||
user_id=user_id,
|
||
name="默认配音模板",
|
||
mode="voice_over",
|
||
category="default",
|
||
tags=[],
|
||
title_config={},
|
||
subtitle_config={},
|
||
bgm_config={},
|
||
estimated_duration=0.0,
|
||
segments=[
|
||
SegmentCommand(
|
||
segment_order=0,
|
||
duration_min=1.0,
|
||
duration_max=30.0,
|
||
material_type=None,
|
||
),
|
||
],
|
||
)
|
||
use_case = CreateTemplateUseCase(repo)
|
||
tpl = use_case.execute(cmd)
|
||
logger.info(
|
||
"[variant-plans] 自动创建默认模板: user=%s tpl=%s",
|
||
user_id,
|
||
tpl.id,
|
||
)
|
||
return tpl.id
|
||
except Exception:
|
||
logger.exception("[variant-plans] 自动创建默认模板失败: user=%s", user_id)
|
||
return None
|
||
|
||
|
||
class VariantPlanRequest(BaseModel):
|
||
"""轻量选片请求体(与前端 variantPlans.ts 契约一致)。"""
|
||
|
||
template_id: str = Field(default="", description="模板 ID(无 source_edit_plan_id 时用于查找骨架 plan)")
|
||
asset_ids: list[str] = Field(default_factory=list, description="批次素材池")
|
||
count: int = Field(default=1, ge=1, le=50, description="变体数量")
|
||
source_edit_plan_id: str = Field(default="", description="源剪辑计划 ID(优先)")
|
||
# 配音(可选;传独立配音时严格守卫)
|
||
voice_library_id: str = Field(default="", description="统一配音 ID")
|
||
voice_library_ids: list[str] = Field(default_factory=list, description="独立配音 ID 列表(长度须=count)")
|
||
|
||
@model_validator(mode="after")
|
||
def _validate(self) -> "VariantPlanRequest":
|
||
# 不再强制要求 template_id / source_edit_plan_id:
|
||
# 后端在路由内会自动查找/创建默认模板兜底(#1911 后前端不再显式选模板)。
|
||
try:
|
||
resolve_variant_voice_ids(
|
||
count=self.count,
|
||
voice_library_id=self.voice_library_id,
|
||
voice_library_ids=self.voice_library_ids or None,
|
||
)
|
||
except VariantVoiceError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
|
||
class VariantPlanItem(BaseModel):
|
||
variant_index: int
|
||
plan_id: str
|
||
clips: list[dict[str, Any]] = Field(default_factory=list)
|
||
|
||
|
||
class VariantPlanResponse(BaseModel):
|
||
items: list[VariantPlanItem]
|
||
total: int
|
||
|
||
|
||
@router.post("/variant-plans", response_model=VariantPlanResponse)
|
||
def create_variant_plans(
|
||
request: VariantPlanRequest,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
db: Session = Depends(get_db_session),
|
||
) -> VariantPlanResponse:
|
||
"""轻量选片:为 N 个变体创建独立 EditPlan + clips,不建任务/不渲染。
|
||
|
||
Returns:
|
||
200 + {items: [{variant_index, plan_id, clips}], total}
|
||
"""
|
||
user_id = authenticated_user.user.id
|
||
|
||
# 配音严格守卫(schema 已校验,此处复用解析取每变体配音)
|
||
try:
|
||
voices = resolve_variant_voice_ids(
|
||
count=request.count,
|
||
voice_library_id=request.voice_library_id,
|
||
voice_library_ids=request.voice_library_ids or None,
|
||
)
|
||
except VariantVoiceError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
# 解析源 plan:显式传入优先;否则按 template_id + user 查最新(公共函数)
|
||
from app.services.generation_common import resolve_latest_plan_by_template
|
||
|
||
source_plan_id = request.source_edit_plan_id.strip()
|
||
template_id = request.template_id.strip()
|
||
|
||
# P0 兜底:前端 #1911 已删除模板选择 UI,调用方可能不传 template_id;
|
||
# 此时自动为该用户查找/创建默认模板。
|
||
if not source_plan_id and not template_id:
|
||
template_id = _get_or_create_default_template_id(db, user_id) or ""
|
||
|
||
if not source_plan_id and template_id:
|
||
source_plan_id = resolve_latest_plan_by_template(db, template_id=template_id, user_id=user_id) or ""
|
||
|
||
if not source_plan_id:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="缺少剪辑计划:请先完成一次预览生成(或传入 source_edit_plan_id)后再试。",
|
||
)
|
||
|
||
# 配音时长(硬化:异常 → 0.0 不阻断选片)
|
||
try:
|
||
from app.api.routes.generation_tasks import _query_voice_durations
|
||
|
||
voice_durations = _query_voice_durations(db, voices)
|
||
except Exception:
|
||
logger.exception("[variant-plans] 配音时长查询失败(按占位段长选片)")
|
||
voice_durations = [0.0] * request.count
|
||
|
||
from app.services.edit_plan_service import EditPlanService
|
||
|
||
svc = EditPlanService(db)
|
||
try:
|
||
plan_ids = svc.ensure_variant_plans(
|
||
source_plan_id,
|
||
request.count,
|
||
list(dict.fromkeys(request.asset_ids or [])),
|
||
created_by_user_id=user_id,
|
||
voice_durations=voice_durations,
|
||
)
|
||
except ValueError as ve:
|
||
# 素材池为空/时长全未知等可预期错误 → 400(与正式生成同口径)
|
||
logger.warning("[variant-plans] 选片失败: %s", ve)
|
||
raise HTTPException(status_code=400, detail=f"变体选片失败:{ve}。请增加素材后重试。") from ve
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.exception("[variant-plans] 选片异常")
|
||
raise HTTPException(status_code=500, detail="选片失败,请稍后重试") from e
|
||
|
||
# 组装 clips 响应
|
||
items: list[VariantPlanItem] = []
|
||
for idx, pid in enumerate(plan_ids):
|
||
clips = svc.list_clips(pid)
|
||
clip_dicts = [
|
||
{
|
||
"id": c.id,
|
||
"order": c.order,
|
||
"asset_id": c.asset_id,
|
||
"start_time": float(c.start_time or 0.0),
|
||
"duration": float(c.duration or 0.0),
|
||
"clip_type": c.clip_type,
|
||
"transition_effect": c.transition_effect,
|
||
"transition_duration": float(c.transition_duration or 0.0),
|
||
"playback_speed": float(c.playback_speed or 1.0),
|
||
"text_content": c.text_content or "",
|
||
"status": c.status or "ready",
|
||
}
|
||
for c in clips
|
||
]
|
||
items.append(VariantPlanItem(variant_index=idx, plan_id=pid, clips=clip_dicts))
|
||
|
||
logger.info(
|
||
"[variant-plans] 轻量选片完成: user=%s source=%s count=%d plans=%d",
|
||
user_id,
|
||
source_plan_id,
|
||
request.count,
|
||
len(plan_ids),
|
||
)
|
||
return VariantPlanResponse(items=items, total=len(items))
|