21c26b5b26
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API 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 API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 21s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 23s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 32s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
CI/CD Pipeline / Build Staging API Image (push) Successful in 32s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m14s
CI/CD Pipeline / Integration Tests (push) Successful in 2m31s
CI/CD Pipeline / Validate - Style (push) Successful in 2m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m31s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m29s
CI/CD Pipeline / Validate - Security (push) Successful in 6m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
AI Code Review / AI Code Review (pull_request) Successful in 6m28s
CI/CD Pipeline / Unit Tests (push) Successful in 8m25s
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to 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>
584 lines
24 KiB
Python
Executable File
584 lines
24 KiB
Python
Executable File
"""预览生成路由 — Phase 1:单版本预览接口(创建 + 查询)。
|
||
|
||
路径前缀:/api/v1/generation/preview(与 /generation/tasks 同体系)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
from app.auth import AuthenticatedUser, get_current_user
|
||
from app.core.storage import get_storage_service
|
||
from app.core.task_enqueue import (
|
||
GLOBAL_PENDING_LIMIT,
|
||
USER_PENDING_LIMIT,
|
||
GlobalQueueFull,
|
||
UserPendingLimitExceeded,
|
||
build_rate_limit_detail,
|
||
safe_enqueue_generation_task,
|
||
)
|
||
from app.dependencies import (
|
||
get_asset_repository,
|
||
get_db_session,
|
||
get_generated_video_repository,
|
||
get_generation_task_repository,
|
||
)
|
||
from app.schemas.generation_task import (
|
||
BatchPreviewGenerationTaskResponse,
|
||
CreatePreviewGenerationTaskRequest,
|
||
PreviewGenerationTaskResponse,
|
||
)
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from sqlalchemy.orm import Session
|
||
|
||
from packages.adapters.sqlalchemy_impl.edit_template_repository import (
|
||
SQLAlchemyEditTemplateRepository,
|
||
)
|
||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||
SQLAlchemyTemplateRepository,
|
||
)
|
||
from packages.application import (
|
||
CreateGenerationTaskCommand,
|
||
CreateGenerationTaskUseCase,
|
||
GetGenerationTaskUseCase,
|
||
ListGeneratedVideosByTaskUseCase,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# 模板 mode → 视频比例映射
|
||
_TEMPLATE_MODE_TO_RATIO = {
|
||
"pip": "9:16",
|
||
"standard": "16:9",
|
||
"square": "1:1",
|
||
}
|
||
|
||
|
||
def _infer_video_ratio_from_template(template_id: str, db: Session, user_id: str = "") -> str:
|
||
"""从模板 mode 推断视频比例,前端未传 video_ratio 时使用。
|
||
|
||
Returns:
|
||
视频比例字符串(如 "9:16"),查询失败返回空字符串。
|
||
"""
|
||
if not template_id:
|
||
return ""
|
||
try:
|
||
repo = SQLAlchemyTemplateRepository(db)
|
||
template = repo.get(template_id, user_id)
|
||
if template:
|
||
mode = getattr(template, "mode", "") or ""
|
||
ratio = _TEMPLATE_MODE_TO_RATIO.get(mode.strip(), "")
|
||
if ratio:
|
||
logger.info(
|
||
"[预览生成] 从模板 mode=%s 推断 video_ratio=%s",
|
||
mode,
|
||
ratio,
|
||
)
|
||
return ratio
|
||
except Exception:
|
||
logger.warning(
|
||
"[预览生成] 查询模板失败,跳过 video_ratio 推断: template_id=%s",
|
||
template_id,
|
||
exc_info=True,
|
||
)
|
||
return ""
|
||
|
||
|
||
def _resolve_strategy_id_from_template(template_id: str, db: Session, user_id: str = "") -> str:
|
||
"""从模板读取 editing_mode / mode 作为 strategy_id。
|
||
|
||
优先查新模板系统(EditTemplate.editing_mode),fallback 旧模板(Template.mode)。
|
||
Worker 端使用 strategy_id 作为渲染 mode,为空则默认 one_take。
|
||
"""
|
||
if not template_id:
|
||
return ""
|
||
|
||
# 优先查新模板系统
|
||
try:
|
||
new_repo = SQLAlchemyEditTemplateRepository(db)
|
||
new_template = new_repo.get(template_id)
|
||
if new_template and getattr(new_template, "editing_mode", ""): # type: ignore[arg-type]
|
||
mode = new_template.editing_mode.strip()
|
||
if mode:
|
||
logger.info(
|
||
"[预览生成] 从新模板 editing_mode=%s (template_id=%s)",
|
||
mode,
|
||
template_id,
|
||
)
|
||
# 画中画已下线,pip/voice_pip 统一映射为 one_take
|
||
if mode in ("pip", "voice_pip"):
|
||
logger.info("[预览生成] %s → one_take (画中画已下线)", mode)
|
||
mode = "one_take"
|
||
return mode
|
||
except Exception:
|
||
logger.debug(
|
||
"[预览生成] 新模板查询失败,尝试旧模板: template_id=%s",
|
||
template_id,
|
||
exc_info=True,
|
||
)
|
||
|
||
# fallback 旧模板系统
|
||
try:
|
||
old_repo = SQLAlchemyTemplateRepository(db)
|
||
old_template = old_repo.get(template_id, user_id)
|
||
if old_template:
|
||
mode = getattr(old_template, "mode", "") or ""
|
||
mode = mode.strip()
|
||
if mode:
|
||
logger.info(
|
||
"[预览生成] 从旧模板 mode=%s (template_id=%s)",
|
||
mode,
|
||
template_id,
|
||
)
|
||
# 画中画已下线,pip/voice_pip 统一映射为 one_take
|
||
if mode in ("pip", "voice_pip"):
|
||
logger.info("[预览生成] %s → one_take (画中画已下线)", mode)
|
||
mode = "one_take"
|
||
return mode
|
||
except Exception:
|
||
logger.warning(
|
||
"[预览生成] 旧模板查询也失败,strategy_id 留空: template_id=%s",
|
||
template_id,
|
||
exc_info=True,
|
||
)
|
||
|
||
return ""
|
||
|
||
|
||
def _mark_task_failed(repo, task, reason: str) -> None:
|
||
"""入队失败时将任务标记为 failed,避免产生僵尸 pending 数据。"""
|
||
try:
|
||
task.mark_failed(error_message=f"入队失败:{reason}")
|
||
repo.update(task)
|
||
except Exception:
|
||
logger.exception("[预览生成] 标记任务失败时异常: task_id=%s", task.id)
|
||
|
||
|
||
def _to_preview_response(task, generated_videos: list | None = None) -> PreviewGenerationTaskResponse:
|
||
"""将领域任务对象转换为预览响应 DTO。
|
||
|
||
Args:
|
||
task: GenerationTask 领域对象
|
||
generated_videos: 生成的视频列表(可选),取第一个作为 video_url
|
||
|
||
Returns:
|
||
PreviewGenerationTaskResponse
|
||
"""
|
||
video_url = ""
|
||
duration = 0.0
|
||
file_size = 0
|
||
if generated_videos:
|
||
first_video = generated_videos[0]
|
||
raw_url = getattr(first_video, "file_url", "") or ""
|
||
# rendered/* 已配置公开读,直接用裸 URL
|
||
if raw_url.startswith("http"):
|
||
video_url = raw_url
|
||
else:
|
||
storage = get_storage_service()
|
||
video_url = storage.get_url(raw_url)
|
||
duration = float(getattr(first_video, "duration", 0.0) or 0.0)
|
||
file_size = int(getattr(first_video, "file_size", 0) or 0)
|
||
|
||
# 从 extra_meta / metadata 中提取统计信息(如果有)
|
||
extra_meta = getattr(task, "extra_meta", {}) or {}
|
||
clip_count = int(extra_meta.get("clip_count", len(getattr(task, "asset_ids", [])) or 0))
|
||
transition_count = int(extra_meta.get("transition_count", max(0, clip_count - 1)))
|
||
material_usage = extra_meta.get("material_usage", {}) or {}
|
||
|
||
# 计算生成耗时
|
||
generate_duration = 0.0
|
||
started_at = getattr(task, "started_at", None)
|
||
completed_at = getattr(task, "completed_at", None)
|
||
if started_at and completed_at:
|
||
generate_duration = (completed_at - started_at).total_seconds()
|
||
|
||
title_cfg = getattr(task, "title_config", None)
|
||
title_cfg = title_cfg if isinstance(title_cfg, dict) else {}
|
||
extra_meta = getattr(task, "extra_meta", None)
|
||
extra_meta = extra_meta if isinstance(extra_meta, dict) else {}
|
||
voice_library_id = getattr(task, "voice_library_id", "") or ""
|
||
if not isinstance(voice_library_id, str):
|
||
voice_library_id = str(voice_library_id) if voice_library_id else ""
|
||
return PreviewGenerationTaskResponse(
|
||
task_id=task.id,
|
||
status=task.status.value if hasattr(task.status, "value") else str(task.status),
|
||
progress=float(task.progress or 0.0),
|
||
is_preview=bool(getattr(task, "is_preview", True)),
|
||
variant_index=int(extra_meta.get("variant_index", 0) or 0),
|
||
resolution=getattr(task, "resolution", "") or "",
|
||
video_url=video_url,
|
||
duration=duration,
|
||
file_size=file_size,
|
||
clip_count=clip_count,
|
||
transition_count=transition_count,
|
||
material_usage=material_usage,
|
||
error_message=task.error_message or "",
|
||
title_text=str(title_cfg.get("text", "") or ""),
|
||
voice_library_id=voice_library_id,
|
||
created_at=task.created_at,
|
||
started_at=started_at,
|
||
finished_at=completed_at,
|
||
generate_duration=generate_duration,
|
||
)
|
||
|
||
|
||
def _resolve_preview_edit_plan_id(
|
||
*,
|
||
request: CreatePreviewGenerationTaskRequest,
|
||
task,
|
||
db: Session,
|
||
user_id: str,
|
||
) -> str:
|
||
"""确定任务关联的编辑计划ID:优先前端传入,否则按 template_id+user 兜底查找。"""
|
||
if task.source_edit_plan_id:
|
||
return task.source_edit_plan_id
|
||
if not request.template_id:
|
||
return ""
|
||
try:
|
||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||
SQLAlchemyEditPlanRepository,
|
||
)
|
||
|
||
_plan_repo = SQLAlchemyEditPlanRepository(db)
|
||
_plans = _plan_repo.list_by_template(request.template_id, limit=20)
|
||
for _p in _plans:
|
||
if (_p.created_by_user_id or "") == user_id:
|
||
logger.info(
|
||
"[预览生成] 自动关联编辑计划: task_id=%s plan_id=%s",
|
||
task.id,
|
||
_p.id,
|
||
)
|
||
return _p.id
|
||
except Exception:
|
||
logger.warning(
|
||
"[预览生成] 查找关联编辑计划失败(不影响主流程): task_id=%s",
|
||
task.id,
|
||
exc_info=True,
|
||
)
|
||
return ""
|
||
|
||
|
||
def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
|
||
"""从变体数组中取值:长度1=共用,长度>N=按索引,空数组=回退 fallback。"""
|
||
if not values:
|
||
return fallback
|
||
if len(values) == 1:
|
||
return values[0]
|
||
return values[index] if index < len(values) else fallback
|
||
|
||
|
||
@router.post("/preview", response_model=BatchPreviewGenerationTaskResponse, status_code=201)
|
||
def create_preview_generation_task(
|
||
request: CreatePreviewGenerationTaskRequest,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
generation_task_repository=Depends(get_generation_task_repository),
|
||
db: Session = Depends(get_db_session),
|
||
asset_repo=Depends(get_asset_repository),
|
||
) -> BatchPreviewGenerationTaskResponse:
|
||
"""创建预览生成任务(支持批量)。
|
||
|
||
preview_count=1 时行为与旧版完全一致(创建 1 个任务);
|
||
preview_count=N 时一次创建 N 个独立变体任务:
|
||
- 每个变体克隆独立编辑计划(独立 clips、独立随机素材起点),N 个预览内容互不相同
|
||
- 每个变体拥有独立 task_id / 状态 / 预览视频 URL,前端按 task_id 分别轮询
|
||
- 标题样式(font/color/position 等)全局共用;标题文字/配音/封面可按变体独立
|
||
(titles[] / voice_library_ids[] / cover_urls[],长度1=共用,长度N=独立)
|
||
|
||
Returns:
|
||
201 + 变体任务数组 {items: [...], total: N}
|
||
"""
|
||
user_id = authenticated_user.user.id
|
||
count = max(1, request.preview_count)
|
||
logger.info(
|
||
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d, preview_count=%d",
|
||
user_id,
|
||
request.template_id,
|
||
len(request.asset_ids),
|
||
count,
|
||
)
|
||
|
||
# 预检查队列限流(按变体总数计)
|
||
try:
|
||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||
global_pending = generation_task_repository.count_pending_total()
|
||
if user_pending + count > USER_PENDING_LIMIT:
|
||
raise UserPendingLimitExceeded(
|
||
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
|
||
)
|
||
if global_pending + count > GLOBAL_PENDING_LIMIT:
|
||
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
|
||
except UserPendingLimitExceeded as e:
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
|
||
) from e
|
||
except GlobalQueueFull as e:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail=build_rate_limit_detail(e, generation_task_repository, scope="global"),
|
||
) from e
|
||
|
||
# 确定视频比例:优先前端传入,否则从模板 mode 推断
|
||
video_ratio = request.video_ratio or ""
|
||
if not video_ratio and request.template_id:
|
||
video_ratio = _infer_video_ratio_from_template(request.template_id, db, user_id)
|
||
|
||
# 根据 video_ratio 计算输出分辨率(默认竖屏 1080x1920)
|
||
output_width, output_height = 1080, 1920
|
||
if video_ratio:
|
||
parts = video_ratio.split(":")
|
||
if len(parts) == 2:
|
||
try:
|
||
w, h = int(parts[0]), int(parts[1])
|
||
base = 1920
|
||
if w < h:
|
||
output_width = round(base * w / h)
|
||
output_height = base
|
||
else:
|
||
output_width = base
|
||
output_height = round(base * h / w)
|
||
output_width = output_width - output_width % 2
|
||
output_height = output_height - output_height % 2
|
||
except (ValueError, ZeroDivisionError):
|
||
output_width, output_height = 1080, 1920
|
||
resolution = f"{output_width}x{output_height}"
|
||
|
||
logger.info(
|
||
"[预览生成] 分辨率: video_ratio=%s → %s (%dx%d)",
|
||
video_ratio,
|
||
resolution,
|
||
output_width,
|
||
output_height,
|
||
)
|
||
|
||
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
|
||
base_title_config = request.title_config or {}
|
||
|
||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||
|
||
# ── 预创建第一个任务,仅用于解析源编辑计划(不落库为最终任务)──
|
||
# 先创建一个临时任务拿到 task 对象上下文,实际 N 个任务在循环中统一创建;
|
||
# 为保持与旧版一致的源 plan 解析逻辑,先创建任务0、解析源 plan,
|
||
# 再预克隆 N 个变体 plan,最后重建任务关联。
|
||
# 简化实现:直接创建全部任务,plan 关联在创建后、入队前完成。
|
||
|
||
created_tasks: list = []
|
||
variant_plan_ids: list[str] = [] # 每个变体最终关联的 plan_id(按变体顺序)
|
||
|
||
try:
|
||
for variant_index in range(count):
|
||
# 变体独立标题文字:titles[] 覆盖 title_config.text
|
||
variant_title_text = _variant_value(request.titles, variant_index, "")
|
||
variant_title_config = dict(base_title_config)
|
||
if variant_title_text.strip():
|
||
variant_title_config["text"] = variant_title_text.strip()
|
||
|
||
# 变体独立配音
|
||
variant_voice_library_id = _variant_value(
|
||
request.voice_library_ids, variant_index, request.voice_library_id
|
||
)
|
||
|
||
task = use_case.execute(
|
||
CreateGenerationTaskCommand(
|
||
project_id="",
|
||
asset_library_id="",
|
||
strategy_id=strategy_id,
|
||
voice_library_id=variant_voice_library_id,
|
||
template_id=request.template_id,
|
||
asset_ids=list(request.asset_ids),
|
||
title_ids=list(request.title_ids),
|
||
voice_ids=list(request.voice_ids),
|
||
created_by_user_id=user_id,
|
||
source_edit_plan_id=request.source_edit_plan_id,
|
||
asset_select_mode="",
|
||
batch_id="",
|
||
video_title=request.video_title,
|
||
resolution=resolution,
|
||
bgm_config=request.bgm_config or {},
|
||
auto_retry_enabled=False,
|
||
auto_retry_max=0,
|
||
is_preview=True,
|
||
title_config=variant_title_config,
|
||
output_width=output_width,
|
||
output_height=output_height,
|
||
)
|
||
)
|
||
task.extra_meta["variant_index"] = variant_index
|
||
|
||
# 解析源编辑计划(前端传入或按模板兜底查找)
|
||
source_plan_id = _resolve_preview_edit_plan_id(request=request, task=task, db=db, user_id=user_id)
|
||
task.source_edit_plan_id = source_plan_id
|
||
generation_task_repository.update(task)
|
||
created_tasks.append(task)
|
||
except ValueError as e:
|
||
logger.warning("[预览生成] 创建失败: %s", e)
|
||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||
except Exception as e:
|
||
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
|
||
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e
|
||
|
||
# ── 克隆独立变体 plan:N 个预览全部克隆(预览不污染源 plan)──
|
||
# 源 plan 不存在(无编辑历史)时各任务走自身随机选片流程,不克隆。
|
||
source_plan_id = created_tasks[0].source_edit_plan_id if created_tasks else ""
|
||
if source_plan_id:
|
||
try:
|
||
from app.services.edit_plan_service import EditPlanService
|
||
|
||
_plan_svc = EditPlanService(db)
|
||
for variant_index in range(count):
|
||
last_err: Exception | None = None
|
||
variant_plan = None
|
||
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
|
||
try:
|
||
variant_plan = _plan_svc.clone_plan_for_variant(
|
||
source_plan_id,
|
||
created_by_user_id=user_id,
|
||
name_suffix=f"预览变体{variant_index + 1}" if count > 1 else "预览变体",
|
||
)
|
||
break
|
||
except Exception as clone_err: # noqa: PERF203
|
||
last_err = clone_err
|
||
logger.warning(
|
||
"[预览生成] 克隆变体 plan 失败(尝试%d/2): variant=%d error=%s",
|
||
_attempt + 1,
|
||
variant_index,
|
||
clone_err,
|
||
exc_info=True,
|
||
)
|
||
if variant_plan is None:
|
||
logger.error(
|
||
"[预览生成] 克隆预览变体 plan 重试仍失败: variant=%d source=%s",
|
||
variant_index,
|
||
source_plan_id,
|
||
exc_info=last_err,
|
||
)
|
||
# 标记已创建任务失败
|
||
for t in created_tasks:
|
||
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
|
||
) from last_err
|
||
variant_plan_ids.append(variant_plan.id)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error("[预览生成] 克隆变体 plan 异常: %s", e, exc_info=True)
|
||
for t in created_tasks:
|
||
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
|
||
) from e
|
||
|
||
# 关联变体 plan 并回写标题配置
|
||
for variant_index, task in enumerate(created_tasks):
|
||
if variant_plan_ids:
|
||
task.source_edit_plan_id = variant_plan_ids[variant_index]
|
||
generation_task_repository.update(task)
|
||
# 回写变体标题到 plan config(worker 渲染时从 plan 读取 title 配置)
|
||
if task.source_edit_plan_id and (task.title_config or {}).get("text", "").strip():
|
||
try:
|
||
from app.api.routes.generation_tasks import _writeback_edit_plan_config
|
||
|
||
_writeback_edit_plan_config(
|
||
plan_id=task.source_edit_plan_id,
|
||
task_id=task.id,
|
||
title_config=task.title_config,
|
||
db=db,
|
||
)
|
||
except Exception:
|
||
logger.warning(
|
||
"[预览生成] 回写标题配置失败(不影响主流程): task_id=%s",
|
||
task.id,
|
||
exc_info=True,
|
||
)
|
||
|
||
# ── 入队 ──
|
||
responses: list[PreviewGenerationTaskResponse] = []
|
||
rate_limit_exc: Exception | None = None # 记录首个限流异常,全部失败时返回结构化提示
|
||
for variant_index, task in enumerate(created_tasks):
|
||
try:
|
||
enqueued = safe_enqueue_generation_task(
|
||
task,
|
||
generation_task_repository,
|
||
user_id=user_id,
|
||
log_prefix=f"[预览生成][变体{variant_index + 1}]",
|
||
log_task_status=True,
|
||
)
|
||
if not enqueued:
|
||
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
|
||
_mark_task_failed(generation_task_repository, task, "任务入队失败")
|
||
except UserPendingLimitExceeded as e:
|
||
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
|
||
rate_limit_exc = rate_limit_exc or e
|
||
except GlobalQueueFull as e:
|
||
_mark_task_failed(generation_task_repository, task, "系统队列已满")
|
||
rate_limit_exc = rate_limit_exc or e
|
||
except Exception:
|
||
logger.exception("[预览生成] 入队异常: task_id=%s", task.id)
|
||
_mark_task_failed(generation_task_repository, task, "任务入队异常")
|
||
# enqueue 会原地更新 task 状态/进度,直接用 task 构造响应
|
||
responses.append(_to_preview_response(task))
|
||
|
||
# 队列满/限流时若全部失败,返回结构化错误码(前端区分"排队"与"创建失败")
|
||
if all(r.status == "failed" for r in responses) and rate_limit_exc is not None:
|
||
if isinstance(rate_limit_exc, UserPendingLimitExceeded):
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="user"),
|
||
)
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="global"),
|
||
)
|
||
|
||
logger.info(
|
||
"[预览生成] 创建完成: %d 个变体任务, task_ids=%s",
|
||
len(responses),
|
||
[r.task_id for r in responses],
|
||
)
|
||
return BatchPreviewGenerationTaskResponse(items=responses, total=len(responses))
|
||
|
||
|
||
@router.get("/preview/{task_id}", response_model=PreviewGenerationTaskResponse)
|
||
def get_preview_generation_task(
|
||
task_id: str,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
generation_task_repository=Depends(get_generation_task_repository),
|
||
generated_video_repository=Depends(get_generated_video_repository),
|
||
) -> PreviewGenerationTaskResponse:
|
||
"""查询预览生成任务状态。
|
||
|
||
Args:
|
||
task_id: 任务 ID
|
||
|
||
Returns:
|
||
预览任务详情(含状态、进度、结果 URL 等)
|
||
"""
|
||
use_case = GetGenerationTaskUseCase(generation_task_repository)
|
||
task = use_case.execute(task_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
|
||
|
||
# 权限校验:任务必须属于当前用户(统一转 str 比较,避免 UUID/str 类型差异)
|
||
task_user_id = str(getattr(task, "created_by_user_id", "") or "")
|
||
if not task_user_id or task_user_id != str(authenticated_user.user.id):
|
||
raise HTTPException(status_code=403, detail="无权访问该任务")
|
||
|
||
# 校验是否为预览任务
|
||
if not getattr(task, "is_preview", False):
|
||
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
|
||
|
||
# 查询生成的视频(取第一个)
|
||
generated_videos = []
|
||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||
if status_val == "completed":
|
||
list_use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
||
generated_videos = list_use_case.execute(task_id)
|
||
|
||
return _to_preview_response(task, generated_videos=generated_videos)
|