Files
xiaoxia-saas/apps/api/app/api/routes/generation_tasks.py
T
xiaoxia 4725d94c7e
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 3s
CI/CD Pipeline / Check push changed paths (pull_request) 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 / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 14s
CI/CD Pipeline / Check push changed paths (push) Successful in 15s
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 / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 28s
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 / PR Build Web Image (pull_request) Successful in 34s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 36s
CI/CD Pipeline / Build Staging API Image (push) Successful in 37s
CI/CD Pipeline / Integration Tests (push) Successful in 1m53s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m17s
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 / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 39s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 3m4s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m31s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m42s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m53s
CI/CD Pipeline / Validate - Security (push) Successful in 6m19s
AI Code Review / AI Code Review (pull_request) Failing after 6m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m9s
CI/CD Pipeline / Unit Tests (push) Successful in 8m47s
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 / Canary Release to Production (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 / Staging E2E Tests (push) Successful in 6m8s
feat(api): 成品视频接口补全查重字段 duplicate_rate/visual_similarity/match_count #1660 (#1678)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-04 01:03:35 +08:00

887 lines
37 KiB
Python
Executable File
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.
import logging
import uuid
from typing import Any
from app.api.routes._helpers import check_project_access
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, 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_library_repository,
get_asset_repository,
get_db_session,
get_generated_video_repository,
get_generation_task_repository,
get_project_repository,
)
from app.schemas.generated_video import (
GeneratedVideoResponse,
ListGeneratedVideosResponse,
)
from app.schemas.generation_task import (
BatchGenerationTaskResponse,
ConfirmGenerationRequest,
CreateGenerationTaskRequest,
GenerationTaskResponse,
ListGenerationTasksResponse,
)
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from packages.application import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
GetGenerationTaskUseCase,
ListGeneratedVideosByTaskUseCase,
)
from packages.domain.smart_match import smart_select_assets
logger = logging.getLogger(__name__)
router = APIRouter()
def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse(
id=task.id,
project_id=task.project_id,
asset_library_id=task.asset_library_id,
strategy_id=task.strategy_id,
voice_library_id=task.voice_library_id,
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
batch_id=getattr(task, "batch_id", ""),
video_title=getattr(task, "video_title", ""),
resolution=getattr(task, "resolution", ""),
bgm_config=getattr(task, "bgm_config", {}) or {},
is_preview=getattr(task, "is_preview", False),
source_task_id=getattr(task, "source_task_id", ""),
output_width=getattr(task, "output_width", 1280),
output_height=getattr(task, "output_height", 720),
cover_url=getattr(task, "cover_url", ""),
title_config=getattr(task, "title_config", {}) or {},
logs=getattr(task, "logs", "[]"),
status=task.status,
progress=task.progress,
result_count=task.result_count,
error_message=task.error_message,
)
def _to_generated_video_response(item, download_url: str | None = None) -> GeneratedVideoResponse:
return GeneratedVideoResponse(
id=item.id,
project_id=item.project_id,
generation_task_id=item.generation_task_id,
name=item.name,
file_url=item.file_url,
file_size=item.file_size,
duration=item.duration,
thumbnail_url=item.thumbnail_url,
width=item.width,
height=item.height,
fps=item.fps,
download_url=download_url,
duplicate_rate=getattr(item, "duplicate_rate", None),
visual_similarity=getattr(item, "visual_similarity", None),
match_count=getattr(item, "match_count", None),
)
def _ensure_library_has_ready_video_assets(assets) -> None:
ready_video_assets = [
asset for asset in assets if asset.status.value == "ready" and asset.mime_type.startswith("video")
]
if not ready_video_assets:
raise HTTPException(
status_code=422,
detail="当前素材库没有 ready 状态的视频素材,请先上传并等待导入完成后再生成。",
)
def _select_assets_from_library(
assets: list,
mode: str,
count: int,
) -> list[str]:
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
Args:
assets: 素材库中所有素材(Asset 实体列表)
mode: 选取模式 — all=全部, smart=智能匹配(多维度评分+多样性)
count: 选取数量,0 表示全部(仅 smart 模式有效)
Returns:
选中的素材 ID 列表
"""
ready_video_assets = [a for a in assets if a.status.value == "ready" and a.mime_type.startswith("video")]
if not ready_video_assets:
return []
if mode == "smart":
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
limit = count if count > 0 else None
results = smart_select_assets(ready_video_assets, limit=limit, kind="video")
return [r.asset.id for r in results]
# 默认 all 模式:返回全部 ready 视频素材
return [a.id for a in ready_video_assets]
def _writeback_edit_plan_config(
plan_id: str,
task_id: str,
title_config: dict | None,
db: Session,
) -> None:
"""任务入队成功后,回写 EditPlan.configgeneration_task_id + title_config。
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
失败只记日志,不影响任务创建。
"""
if not plan_id:
return
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
if plan_model is None:
logger.warning("[生成任务] 回写plan.config失败: plan不存在 plan_id=%s", plan_id)
return
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config)
merged["generation_task_id"] = task_id
# 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面
if title_config:
old_title_config = merged.get("title_config", {}) or {}
old_title_text = (old_title_config.get("text") or "").strip()
new_title_text = (title_config.get("text") or "").strip()
if old_title_text != new_title_text:
# 标题变化,清除旧封面
if "cover" in merged:
del merged["cover"]
logger.info(
"[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
plan_id,
old_title_text,
new_title_text,
)
merged["title_config"] = title_config
plan_model.config = merged
db.commit()
logger.info(
"[生成任务] 回写plan.config成功: plan_id=%s task_id=%s keys=%s",
plan_id,
task_id,
list(merged.keys()),
)
except Exception as e:
logger.warning(
"[生成任务] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s",
plan_id,
e,
exc_info=True,
)
try:
db.rollback()
except Exception:
pass
def _resolve_project_and_library(
request: CreateGenerationTaskRequest,
project_repository: Any,
asset_library_repository: Any,
asset_repository: Any,
authenticated_user: AuthenticatedUser,
) -> tuple[str, str]:
"""解析 project_id 和 asset_library_id。
支持两种模式:
- 显式传入(向后兼容)
- 从 asset_ids 反查 asset_library(模板模式)
返回 (project_id, asset_library_id)。
"""
project_id = request.project_id.strip()
asset_library_id = request.asset_library_id.strip()
# 模板模式:project_id 未提供时,从 asset_ids 反查所属 project
if not project_id and request.asset_ids:
first_asset_id = request.asset_ids[0]
asset = asset_repository.find_by_id(first_asset_id)
if asset is not None:
project_id = asset.project_id
if not asset_library_id:
asset_library_id = asset.library_id
# 向后兼容校验:project_id 已提供时验证权限
if project_id:
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(authenticated_user.user.id):
raise HTTPException(status_code=403, detail="Access denied to project")
return project_id, asset_library_id
@router.post("/tasks", response_model=BatchGenerationTaskResponse)
def create_generation_task(
request: CreateGenerationTaskRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
db: Session = Depends(get_db_session),
) -> BatchGenerationTaskResponse:
logger.info(
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
authenticated_user.user.id,
request.template_id,
len(request.asset_ids),
request.asset_select_mode,
request.count,
)
try:
project_id, asset_library_id = _resolve_project_and_library(
request, project_repository, asset_library_repository, asset_repository, authenticated_user
)
except HTTPException as e:
logger.warning("[生成任务] 校验失败: %s", e.detail)
raise
# asset_library 存在性校验(仅在提供了 asset_library_id 时)
resolved_asset_ids: list[str] = list(request.asset_ids)
if asset_library_id:
library = asset_library_repository.get(asset_library_id)
if library is None or (project_id and library.project_id != project_id):
logger.warning("[生成任务] 素材库不存在: library_id=%s", asset_library_id)
raise HTTPException(status_code=404, detail=f"AssetLibrary {asset_library_id} not found")
assets = asset_repository.find_by_library(asset_library_id)
try:
_ensure_library_has_ready_video_assets(assets)
except HTTPException as e:
logger.warning("[生成任务] 素材校验失败: %s", e.detail)
raise
# 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
if not resolved_asset_ids:
resolved_asset_ids = _select_assets_from_library(
assets,
mode=request.asset_select_mode,
count=request.asset_select_count,
)
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("smart",):
# 项目级模式:未指定 asset_ids 且选择了 smart 模式时,也自动选取
assets = asset_repository.find_by_project(project_id)
if assets:
resolved_asset_ids = _select_assets_from_library(
assets,
mode=request.asset_select_mode,
count=request.asset_select_count,
)
if not resolved_asset_ids:
raise HTTPException(
status_code=422,
detail="当前项目没有符合条件的视频素材,请先上传并等待导入完成后再生成。",
)
# ── 兜底复用预览产物 ──
# 前端刷新后 previewTaskId 丢失,降级调 create 接口时,
# 如果同一 edit_plan 有已完成的预览任务,直接复用(秒出)。
if request.source_edit_plan_id and not request.is_preview:
try:
from packages.adapters.sqlalchemy_impl.models import (
GenerationTaskModel,
)
_preview_model = (
db.query(GenerationTaskModel)
.filter(
GenerationTaskModel.source_edit_plan_id == request.source_edit_plan_id,
GenerationTaskModel.is_preview.is_(True),
GenerationTaskModel.status == "completed",
GenerationTaskModel.created_by_user_id == authenticated_user.user.id,
)
.order_by(GenerationTaskModel.created_at.desc())
.first()
)
if _preview_model is not None:
# 校验分辨率一致性(与 confirm 端点逻辑相同)
req_w = request.output_width or 0
req_h = request.output_height or 0
src_w = getattr(_preview_model, "output_width", 0) or 0
src_h = getattr(_preview_model, "output_height", 0) or 0
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
if resolution_match:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
_to_domain,
)
preview_task = _to_domain(_preview_model)
# 如果传了标题,更新 title_config
fallback_title_config = None
if request.title_config and request.title_config.get("text", "").strip():
fallback_title_config = dict(preview_task.title_config or {})
fallback_title_config.update(request.title_config)
preview_task.mark_confirmed(
cover_url=request.cover_url or preview_task.cover_url,
output_width=request.output_width or preview_task.output_width,
output_height=request.output_height or preview_task.output_height,
title_config=fallback_title_config,
)
generation_task_repository.update(preview_task)
# 同步标题到 EditPlan.config
if fallback_title_config:
_writeback_edit_plan_config(
plan_id=request.source_edit_plan_id,
task_id=preview_task.id,
title_config=fallback_title_config,
db=db,
)
logger.info(
"[生成任务] 兜底复用预览产物: preview_task_id=%s, plan_id=%s",
preview_task.id,
request.source_edit_plan_id,
)
return BatchGenerationTaskResponse(
items=[_to_generation_task_response(preview_task)],
total=1,
)
else:
logger.info(
"[生成任务] 兜底复用跳过(分辨率不一致): plan_id=%s, src=%sx%s, req=%sx%s",
request.source_edit_plan_id,
src_w,
src_h,
req_w,
req_h,
)
except Exception:
logger.warning(
"[生成任务] 兜底复用预览产物异常(不影响主流程): plan_id=%s",
request.source_edit_plan_id,
exc_info=True,
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
count = request.count
created_tasks: list = []
failed_tasks = []
user_id = authenticated_user.user.id
# 同批次任务共享 batch_id,用于视频查重时批次内比对
batch_id = uuid.uuid4().hex if count > 1 else ""
# 预检查:批量提交前先看会不会超限,避免建一半才拒
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=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待完成后再提交",
) from e
except GlobalQueueFull as e:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from e
# 画中画已下线:strategy_id 中的 pip/voice_pip 统一映射为 one_take
effective_strategy_id = request.strategy_id
if effective_strategy_id in ("pip", "voice_pip"):
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
effective_strategy_id = "one_take"
# 批量生成时每个任务关联独立克隆 plan(片段起点重算),
# 禁止 N 条任务共用同一 source_edit_plan_id 导致片段一模一样。
# 在创建任何任务【之前】预克隆全部变体:克隆失败直接中断(此时无脏数据),
# 绝不静默退回共用源 plan(否则批量视频内容重复,违反去重诉求)。
variant_plan_ids: list[str] = []
if count > 1 and request.source_edit_plan_id:
from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db)
for task_index in range(1, count):
variant = None
last_err: Exception | None = None
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
try:
variant = _plan_svc.clone_plan_for_variant(
request.source_edit_plan_id,
created_by_user_id=user_id,
name_suffix=f"批量{task_index + 1}",
)
break
except Exception as clone_err: # noqa: PERF203
last_err = clone_err
logger.warning(
"[生成任务] 克隆变体 plan 失败(尝试%d/2): source=%s error=%s",
_attempt + 1,
request.source_edit_plan_id,
clone_err,
exc_info=True,
)
if variant is None:
logger.error(
"[生成任务] 克隆变体 plan 重试仍失败,中断批量创建: source=%s",
request.source_edit_plan_id,
exc_info=last_err,
)
raise HTTPException(
status_code=500,
detail="创建批量任务失败:无法生成独立剪辑计划,请重试",
) from last_err
variant_plan_ids.append(variant.id)
try:
for task_index in range(count):
# 第 1 条复用源 plan(保留用户编辑结果);其余使用预克隆的独立变体 plan。
# 无源 plansource_edit_plan_id 为空)时无可克隆对象,variant_plan_ids
# 为空列表:各任务走自身随机选片流程,不做索引访问(防 IndexError)
effective_plan_id = request.source_edit_plan_id
if task_index > 0 and variant_plan_ids:
effective_plan_id = variant_plan_ids[task_index - 1]
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=effective_strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=resolved_asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=user_id,
source_edit_plan_id=effective_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
video_title=request.video_title,
resolution=request.resolution,
bgm_config=request.bgm_config,
auto_retry_enabled=request.auto_retry_enabled,
auto_retry_max=request.auto_retry_max,
is_preview=request.is_preview,
source_task_id=request.source_task_id,
output_width=request.output_width,
output_height=request.output_height,
cover_url=request.cover_url,
title_config=request.title_config or {},
)
)
try:
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)
if not task.source_edit_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
_plan_model = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id,
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _plan_model:
task.source_edit_plan_id = _plan_model.id
generation_task_repository.update(task)
logger.info(
"[生成任务] 自动关联编辑计划: task_id=%s plan_id=%s",
task.id,
_plan_model.id,
)
except Exception:
logger.warning(
"[生成任务] 查找关联编辑计划失败(不影响主流程): task_id=%s",
task.id,
exc_info=True,
)
# 回写 plan.config:必须在 enqueue 之前执行,
# 确保 worker 读取 plan 时 config 中已包含 generation_task_id。
# 只在首个任务时回写一次,避免批量生成时循环覆盖。
_effective_plan_id = task.source_edit_plan_id
if _effective_plan_id and len(created_tasks) == 0:
_writeback_edit_plan_config(
plan_id=_effective_plan_id,
task_id=task.id,
title_config=request.title_config,
db=db,
)
if safe_enqueue_generation_task(
task,
generation_task_repository,
user_id=user_id,
log_prefix="[生成任务]",
log_task_status=True,
):
created_tasks.append(task)
else:
failed_tasks.append(task)
except UserPendingLimitExceeded as _e:
# 兜底:如果预检查后又并发提交了,在这里也拦住
failed_tasks.append(task)
if not created_tasks:
raise HTTPException(
status_code=429,
detail="您的待处理任务过多,请等待完成后再提交",
) from _e
break
except GlobalQueueFull as _e:
failed_tasks.append(task)
if not created_tasks:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from _e
break
except HTTPException:
raise
except Exception as e:
logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志") from e
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
return BatchGenerationTaskResponse(items=items, total=len(items))
@router.post("/tasks/{task_id}/confirm", response_model=BatchGenerationTaskResponse)
def confirm_generation(
task_id: str,
request: ConfirmGenerationRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
project_repository: Any = Depends(get_project_repository),
db: Session = Depends(get_db_session),
) -> BatchGenerationTaskResponse:
"""确认生成 -- 复用预览渲染产物(预览与正式品质一致)。
预览已使用 1080p / CRF 23 / medium 渲染,品质与正式生成一致。
确认时直接将预览任务标记为正式产出,无需重新渲染,实现秒出。
仅当预览任务未完成时,才创建新的正式任务走渲染流程。
"""
# 1. 查找源预览任务
source_task = generation_task_repository.get(task_id)
if source_task is None:
raise HTTPException(status_code=404, detail=f"Preview task {task_id} not found")
# 2. 权限检查
if source_task.created_by_user_id and source_task.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this task")
if source_task.project_id:
check_project_access(source_task.project_id, authenticated_user.user.id, project_repository)
# 3. 如果预览任务已完成,检查分辨率一致性后复用产物(秒出)
if source_task.is_completed and getattr(source_task, "is_preview", False):
# 校验请求的分辨率是否与预览实际渲染的分辨率一致
req_w = request.output_width or 0
req_h = request.output_height or 0
src_w = getattr(source_task, "output_width", 0) or 0
src_h = getattr(source_task, "output_height", 0) or 0
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
if resolution_match:
# 如果用户传了 custom_title,同步更新 title_config
confirmed_title_config = None
if request.custom_title and request.custom_title.strip():
confirmed_title_config = dict(getattr(source_task, "title_config", {}) or {})
confirmed_title_config["text"] = request.custom_title.strip()
source_task.mark_confirmed(
cover_url=request.cover_url,
output_width=request.output_width,
output_height=request.output_height,
title_config=confirmed_title_config,
)
generation_task_repository.update(source_task)
# 同步标题到 EditPlan.config
if confirmed_title_config and source_task.source_edit_plan_id:
_writeback_edit_plan_config(
plan_id=source_task.source_edit_plan_id,
task_id=source_task.id,
title_config=confirmed_title_config,
db=db,
)
logger.info(
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
task_id,
authenticated_user.user.id,
)
return BatchGenerationTaskResponse(
items=[_to_generation_task_response(source_task)],
total=1,
)
# 分辨率不一致,跳过复用,走新建任务流程
logger.info(
"[确认生成] 分辨率不一致,跳过复用: task_id=%s, src=%sx%s, req=%sx%s",
task_id,
src_w,
src_h,
req_w,
req_h,
)
# 4. 预览任务未完成,创建新的正式任务走渲染流程
use_case = CreateGenerationTaskUseCase(generation_task_repository)
new_task = use_case.execute(
CreateGenerationTaskCommand(
project_id=source_task.project_id,
asset_library_id=source_task.asset_library_id,
strategy_id=source_task.strategy_id,
voice_library_id=source_task.voice_library_id,
template_id=source_task.template_id,
asset_ids=source_task.asset_ids,
title_ids=source_task.title_ids,
voice_ids=source_task.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=source_task.source_edit_plan_id or "",
asset_select_mode=source_task.asset_select_mode,
video_title=getattr(source_task, "video_title", ""),
resolution=getattr(source_task, "resolution", ""),
is_preview=False,
source_task_id=task_id,
output_width=request.output_width,
output_height=request.output_height,
cover_url=request.cover_url,
)
)
# 5. 调度 worker
try:
if not safe_enqueue_generation_task(
new_task,
generation_task_repository,
user_id=authenticated_user.user.id,
log_prefix="[确认生成]",
log_task_status=True,
):
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
except UserPendingLimitExceeded:
raise HTTPException(
status_code=429,
detail="您的待处理任务过多,请等待完成后再提交",
) from None
except GlobalQueueFull:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from None
return BatchGenerationTaskResponse(
items=[_to_generation_task_response(new_task)],
total=1,
)
@router.get("/tasks", response_model=ListGenerationTasksResponse)
def list_generation_tasks(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> ListGenerationTasksResponse:
"""用户级生成任务列表(跨 project)。"""
tasks = generation_task_repository.list_by_user(authenticated_user.user.id)
items = [_to_generation_task_response(task) for task in tasks]
return ListGenerationTasksResponse(items=items)
@router.get("/tasks/{task_id}", response_model=GenerationTaskResponse)
def get_generation_task(
task_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
project_repository: Any = Depends(get_project_repository),
) -> GenerationTaskResponse:
use_case = GetGenerationTaskUseCase(generation_task_repository)
task = use_case.execute(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
if task.project_id:
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
return _to_generation_task_response(task)
@router.get("/tasks/{task_id}/results", response_model=ListGeneratedVideosResponse)
def list_generation_results(
task_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
generated_video_repository: Any = Depends(get_generated_video_repository),
project_repository: Any = Depends(get_project_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> ListGeneratedVideosResponse:
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
if task.project_id:
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
items = use_case.execute(task_id)
responses = []
for item in items:
download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400)
responses.append(_to_generated_video_response(item, download_url=download_url))
return ListGeneratedVideosResponse(items=responses)
@router.post("/tasks/{task_id}/retry", response_model=GenerationTaskResponse)
def retry_generation_task(
task_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> GenerationTaskResponse:
"""简化重试:通过 task_id 直接重试失败任务。"""
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this task")
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
if status_val != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
user_id = authenticated_user.user.id
# 预检查:创建前判断,>= 上限就拒绝
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
project_id=task.project_id,
asset_library_id=task.asset_library_id,
strategy_id=task.strategy_id,
voice_library_id=task.voice_library_id,
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=user_id,
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
video_title=getattr(task, "video_title", ""),
resolution=getattr(task, "resolution", ""),
is_preview=getattr(task, "is_preview", False),
source_task_id=getattr(task, "source_task_id", ""),
output_width=getattr(task, "output_width", 1280),
output_height=getattr(task, "output_height", 720),
cover_url=getattr(task, "cover_url", ""),
)
)
try:
if not safe_enqueue_generation_task(
retried,
generation_task_repository,
user_id=user_id,
log_prefix="[生成任务]",
log_task_status=True,
):
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
except UserPendingLimitExceeded:
raise HTTPException(
status_code=429,
detail="您的待处理任务过多,请等待完成后再提交",
) from None
except GlobalQueueFull:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from None
return _to_generation_task_response(retried)
@router.post("/tasks/{task_id}/cancel", response_model=GenerationTaskResponse)
def cancel_generation_task(
task_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> GenerationTaskResponse:
"""取消生成任务。
仅 pending / running 状态的任务可取消;取消后状态变为 cancelled。
对于已在运行的 Celery 任务,标记为 cancelled 后,worker 在下次检查点会中止执行。
"""
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
# 权限校验
if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this task")
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
# 终态不可取消
if status_val in ("completed", "failed", "cancelled"):
raise HTTPException(
status_code=409,
detail=f"Cannot cancel task in {status_val} status",
)
# 执行取消
try:
task.mark_cancelled()
task.append_log(
stage="cancelled",
message="用户主动取消任务",
level="INFO",
cancelled_by=authenticated_user.user.id,
)
generation_task_repository.update(task)
logger.info(
"生成任务已取消: task_id=%s user_id=%s previous_status=%s",
task_id,
authenticated_user.user.id,
status_val,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return _to_generation_task_response(task)