Files
xiaoxia-saas/apps/api/app/api/routes/generation_variant_plans.py
T
xiaoxia 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
fix(#1749): variant-plans clips 补 status 字段(P0 批量预览过滤修复) (#1753)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-07 11:08:46 +08:00

179 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""轻量选片接口 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))