947b3ed86e
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
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 / Validate - Type Check (mypy) (push) Successful in 1m58s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m28s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m34s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m27s
CI/CD Pipeline / Integration Tests (push) Successful in 1m43s
CI/CD Pipeline / Unit Tests (push) Successful in 8m48s
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 / Build Production API 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 / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 40s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m55s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
P0 Bug: analyze_videos() 在 HTTP 请求中同步调用耗时 30-60s, 前端 10s 超时。 修复: - 从 generation_preview.py 移除 MediaKit 同步调用 - 在 Worker generate_video 任务中、渲染之前调用 - 分析结果保存到 task.extra_meta['asset_analyses']
367 lines
14 KiB
Python
Executable File
367 lines
14 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,
|
|
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 (
|
|
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", ""):
|
|
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()
|
|
|
|
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)),
|
|
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 "",
|
|
created_at=task.created_at,
|
|
started_at=started_at,
|
|
finished_at=completed_at,
|
|
generate_duration=generate_duration,
|
|
)
|
|
|
|
|
|
@router.post("/preview", response_model=PreviewGenerationTaskResponse, 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),
|
|
) -> PreviewGenerationTaskResponse:
|
|
"""创建预览生成任务。
|
|
|
|
预览渲染品质与正式生成一致(1080p, CRF 23, medium preset),确认生成时可直接复用预览产物。
|
|
|
|
Args:
|
|
request: 预览任务创建请求(template_id + asset_ids 等)
|
|
|
|
Returns:
|
|
201 + 预览任务详情
|
|
"""
|
|
user_id = authenticated_user.user.id
|
|
logger.info(
|
|
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d, preview_count=%d",
|
|
user_id,
|
|
request.template_id,
|
|
len(request.asset_ids),
|
|
request.preview_count,
|
|
)
|
|
|
|
# 预检查队列限流
|
|
try:
|
|
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
|
global_pending = generation_task_repository.count_pending_total()
|
|
if user_pending + 1 > USER_PENDING_LIMIT:
|
|
raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending + 1, limit=USER_PENDING_LIMIT)
|
|
if global_pending + 1 > GLOBAL_PENDING_LIMIT:
|
|
raise GlobalQueueFull(pending_count=global_pending + 1, limit=GLOBAL_PENDING_LIMIT)
|
|
except UserPendingLimitExceeded as e:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待后再提交",
|
|
) from e
|
|
except GlobalQueueFull as e:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="系统繁忙,请稍后再试",
|
|
) 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)
|
|
|
|
# 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数)
|
|
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
|
|
|
|
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
|
|
|
try:
|
|
task = use_case.execute(
|
|
CreateGenerationTaskCommand(
|
|
project_id="",
|
|
asset_library_id="",
|
|
strategy_id=strategy_id,
|
|
voice_library_id=request.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="",
|
|
bgm_config=request.bgm_config or {},
|
|
auto_retry_enabled=False,
|
|
auto_retry_max=0,
|
|
is_preview=True,
|
|
)
|
|
)
|
|
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
|
|
|
|
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
|
|
try:
|
|
if not safe_enqueue_generation_task(
|
|
task,
|
|
generation_task_repository,
|
|
user_id=user_id,
|
|
log_prefix="[预览生成]",
|
|
log_task_status=True,
|
|
):
|
|
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
|
|
_mark_task_failed(generation_task_repository, task, "任务入队失败")
|
|
raise HTTPException(status_code=500, detail="任务入队失败,请稍后重试")
|
|
except UserPendingLimitExceeded as e:
|
|
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待后再提交",
|
|
) from None
|
|
except GlobalQueueFull:
|
|
_mark_task_failed(generation_task_repository, task, "系统队列已满")
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="系统繁忙,请稍后再试",
|
|
) from None
|
|
|
|
return _to_preview_response(task)
|
|
|
|
|
|
@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)
|