3557635f93
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 29s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m40s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m43s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m27s
CI/CD Pipeline / Integration Tests (push) Successful in 1m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m1s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (push) Successful in 2m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m19s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m53s
CI/CD Pipeline / Validate - Security (push) Successful in 6m25s
AI Code Review / AI Code Review (pull_request) Successful in 6m32s
CI/CD Pipeline / Unit Tests (push) Successful in 7m34s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 20m25s
CI/CD Pipeline / Canary Release to Production (push) Failing after 220h7m48s
CI/CD Pipeline / Deploy Production (push) Failing after 220h7m48s
CI/CD Pipeline / Build Production API Image (push) Failing after 220h7m52s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 220h12m8s
CI/CD Pipeline / CI Gate (push) Failing after 220h7m52s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 220h13m23s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 220h7m52s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 220h13m26s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 220h14m46s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 220h14m55s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 220h14m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 220h14m55s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 220h14m48s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 220h15m12s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 220h15m15s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 220h15m17s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 220h15m20s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 220h15m24s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 220h15m24s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 220h15m26s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 220h15m26s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 220h15m25s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 220h15m26s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 220h15m27s
CI/CD Pipeline / Frontend Lint (push) Failing after 220h15m28s
CI/CD Pipeline / PR Build API Image (push) Failing after 220h15m32s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 220h15m31s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 220h15m32s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 220h15m33s
CI/CD Pipeline / Build Production Web Image (push) Failing after 220h42m29s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 220h48m1s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 220h49m26s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 220h49m49s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 220h49m52s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 220h50m1s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 220h50m2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 220h50m3s
CI/CD Pipeline / PR Build Web Image (push) Failing after 220h50m9s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
179 lines
7.0 KiB
Python
179 lines
7.0 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()
|
||
|
||
|
||
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":
|
||
if not self.template_id.strip() and not self.source_edit_plan_id.strip():
|
||
raise ValueError("template_id 与 source_edit_plan_id 至少需要提供一个")
|
||
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 查最新
|
||
source_plan_id = request.source_edit_plan_id.strip()
|
||
if not source_plan_id and request.template_id.strip():
|
||
try:
|
||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||
|
||
_latest = (
|
||
db.query(EditPlanModel)
|
||
.filter(
|
||
EditPlanModel.template_id == request.template_id.strip(),
|
||
EditPlanModel.created_by_user_id == user_id,
|
||
)
|
||
.order_by(EditPlanModel.created_at.desc())
|
||
.first()
|
||
)
|
||
if _latest:
|
||
source_plan_id = _latest.id
|
||
except Exception:
|
||
logger.warning("[variant-plans] 源 plan 解析失败", exc_info=True)
|
||
|
||
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.warning("[variant-plans] 配音时长查询失败(按占位段长选片)", exc_info=True)
|
||
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.error("[variant-plans] 选片异常: %s", e, exc_info=True)
|
||
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))
|