14580f536a
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / Check push changed paths (push) Successful in 13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m27s
CI/CD Pipeline / Integration Tests (push) Successful in 3m38s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m45s
CI/CD Pipeline / Validate - Style (push) Successful in 4m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m42s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m58s
CI/CD Pipeline / Validate - Security (push) Successful in 8m1s
CI/CD Pipeline / Unit Tests (push) Successful in 9m0s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m6s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m52s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m36s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m31s
AI Code Review / AI Code Review (pull_request) Successful in 6m21s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m48s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 51s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m4s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 53h34m26s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 53h42m14s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 53h42m15s
CI/CD Pipeline / Deploy Production (push) Failing after 59h59m30s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 60h8m2s
CI/CD Pipeline / PR Build Web Image (push) Failing after 60h8m44s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 53h41m47s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 53h41m49s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 53h41m50s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 53h41m50s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 53h41m50s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 53h41m55s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 53h34m0s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 53h34m0s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 53h33m56s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 53h33m56s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 53h41m43s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 53h41m43s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 53h41m43s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 53h41m49s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 53h41m47s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 60h8m17s
CI/CD Pipeline / PR Build API Image (push) Failing after 60h8m18s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 60h7m36s
CI/CD Pipeline / CI Gate (push) Failing after 59h59m9s
CI/CD Pipeline / Build Production Web Image (push) Failing after 59h59m9s
CI/CD Pipeline / Canary Release to Production (push) Failing after 59h59m4s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 59h59m9s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 60h7m36s
CI/CD Pipeline / Frontend Lint (push) Failing after 60h8m10s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 60h8m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 54h17m5s
CI/CD Pipeline / Build Production API Image (push) Failing after 60h34m30s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
1026 lines
46 KiB
Python
Executable File
1026 lines
46 KiB
Python
Executable File
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,
|
||
build_rate_limit_detail,
|
||
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 _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
|
||
|
||
|
||
def _query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]:
|
||
"""[已下沉] 路由层兼容别名 → app.services.generation_common.query_voice_durations。"""
|
||
from app.services.generation_common import query_voice_durations
|
||
|
||
return query_voice_durations(db, voice_ids)
|
||
|
||
|
||
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,
|
||
rng=None,
|
||
) -> list[str]:
|
||
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
|
||
|
||
Args:
|
||
assets: 素材库中所有素材(Asset 实体列表)
|
||
mode: 选取模式 — all=全部, smart=智能匹配(多维度评分+多样性)
|
||
count: 选取数量,0 表示全部(仅 smart 模式有效)
|
||
rng: 可选随机源(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%)
|
||
# 排序注入随机噪声(#1743):同分素材每次选出不同组合,从素材组合层面降重
|
||
limit = count if count > 0 else None
|
||
results = smart_select_assets(ready_video_assets, limit=limit, kind="video", rng=rng)
|
||
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:
|
||
"""[已下沉] 路由层兼容别名 → app.services.generation_common.writeback_edit_plan_config。"""
|
||
from app.services.generation_common import writeback_edit_plan_config
|
||
|
||
return writeback_edit_plan_config(plan_id, task_id, title_config, db)
|
||
|
||
|
||
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=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
|
||
|
||
# 画中画已下线: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"
|
||
|
||
# 批量生成(count>1):每个变体必须走与单视频完全相同的独立选片流程(#1743/#1749)。
|
||
# - 变体 0:clone 源 plan(不污染源 plan),变体 1..N-1 用 reselect_plan_for_variant
|
||
# 完整重跑选片(素材级去重:fresh 优先 → 受控复用 overlap≤20% → 短素材禁复用);
|
||
# - #1749:前端可回传 variant-plans 接口预生成的 plan_id(variant_plan_ids),直接复用;
|
||
# 回传 plan 仍按各变体配音幂等重分配段长(防 variant-plans 阶段未带配音/占位时长);
|
||
# - 配音时长:独立配音各自时长、统一配音同值,逐变体 apply_voice_duration_to_plan,
|
||
# 成片总时长=配音时长(素材短→末帧冻结,禁慢放/禁截配音);
|
||
# - count>1 但没有源 plan 时,不允许 N 个任务兜底共用同一 plan,直接 4xx 中断。
|
||
# 在创建任何任务【之前】预生成/校验全部变体 plan:失败直接中断(此时无脏数据)。
|
||
variant_plan_ids: list[str] = []
|
||
if count > 1:
|
||
from app.services.edit_plan_service import EditPlanService
|
||
|
||
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
|
||
|
||
_plan_svc = EditPlanService(db)
|
||
|
||
# 解析每变体配音(严格守卫:独立配音长度/缺值 → 400,禁静默 fallback)
|
||
try:
|
||
variant_voices = resolve_variant_voice_ids(
|
||
count=count,
|
||
voice_library_id=request.voice_library_id,
|
||
voice_library_ids=request.voice_library_ids or None,
|
||
)
|
||
except VariantVoiceError as ve:
|
||
raise HTTPException(status_code=400, detail=str(ve)) from ve
|
||
# 各变体配音时长(查询硬化:异常 → 0.0 不阻断)
|
||
voice_durations = _query_voice_durations(db, variant_voices)
|
||
|
||
# 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(公共函数)
|
||
from app.services.generation_common import resolve_latest_plan_by_template
|
||
|
||
batch_source_plan_id = (
|
||
request.source_edit_plan_id
|
||
or resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id)
|
||
or ""
|
||
)
|
||
|
||
if not batch_source_plan_id and not request.variant_plan_ids:
|
||
# 无任何可用源 plan:批量变体无从选片,明确报错,严禁静默共用/同源
|
||
logger.error("[生成任务] 批量 count=%d 但无可编辑计划(无 source_edit_plan_id/template plan)", count)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="批量生成需要先完成预览生成(缺少剪辑计划)。请先生成预览后再批量创建。",
|
||
)
|
||
|
||
# 批次素材池:请求显式素材 + 库自动匹配素材(resolved_asset_ids)
|
||
batch_asset_pool = list(dict.fromkeys(resolved_asset_ids or []))
|
||
|
||
if request.variant_plan_ids:
|
||
# ① 前端回传 variant-plans 预生成结果:直接复用(轻量选片接口已建好 plan)
|
||
if len(request.variant_plan_ids) != count:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"variant_plan_ids 数量({len(request.variant_plan_ids)})与视频数量({count})不一致",
|
||
)
|
||
# 校验归属权
|
||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||
|
||
for _pid in request.variant_plan_ids:
|
||
_pm = db.query(EditPlanModel).filter(EditPlanModel.id == _pid).first()
|
||
if _pm is None:
|
||
raise HTTPException(status_code=400, detail=f"剪辑计划不存在: {_pid}")
|
||
if _pm.created_by_user_id and _pm.created_by_user_id != user_id:
|
||
raise HTTPException(status_code=403, detail=f"无权使用剪辑计划: {_pid}")
|
||
variant_plan_ids = list(request.variant_plan_ids)
|
||
else:
|
||
# ② 服务端选片:变体 0 clone 源 plan(不污染源 plan)
|
||
try:
|
||
_plan0 = _plan_svc.clone_plan_for_variant(
|
||
batch_source_plan_id,
|
||
created_by_user_id=user_id,
|
||
name_suffix="批量1",
|
||
)
|
||
except Exception as clone_err:
|
||
logger.error("[生成任务] 变体0 clone 失败: %s", clone_err, exc_info=True)
|
||
raise HTTPException(
|
||
status_code=500, detail="创建批量任务失败:无法生成独立剪辑计划,请重试"
|
||
) from clone_err
|
||
variant_plan_ids.append(_plan0.id)
|
||
|
||
# #1855 P0:批次区间避让表,从变体0实际clips构建初始值(公共函数)
|
||
from app.services.generation_common import collect_plan_segments as _collect_segments
|
||
|
||
_batch_segments = _collect_segments(_plan0.id, _plan_svc._clip_repo)
|
||
|
||
# 变体 1..N-1 独立选片(传入累积batch_segments做素材区间避让)
|
||
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.reselect_plan_for_variant(
|
||
batch_source_plan_id,
|
||
batch_asset_pool,
|
||
created_by_user_id=user_id,
|
||
name_suffix=f"批量{task_index + 1}",
|
||
voice_duration=voice_durations[task_index] if task_index < len(voice_durations) else 0.0,
|
||
batch_segments=_batch_segments,
|
||
)
|
||
break
|
||
except ValueError as ve:
|
||
# 素材不足等可预期错误:不重试,直接中断并给出明确提示
|
||
logger.warning("[生成任务] 变体独立选片失败(素材不足): %s", ve)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"批量生成第 {task_index + 1} 个视频无法独立选片:{ve}。"
|
||
"请增加素材库中的视频素材后重试。",
|
||
) from ve
|
||
except Exception as reselection_err: # noqa: PERF203
|
||
last_err = reselection_err
|
||
logger.warning(
|
||
"[生成任务] 变体独立选片失败(尝试%d/2): source=%s error=%s",
|
||
_attempt + 1,
|
||
batch_source_plan_id,
|
||
reselection_err,
|
||
exc_info=True,
|
||
)
|
||
if variant is None:
|
||
logger.error(
|
||
"[生成任务] 变体独立选片重试仍失败,中断批量创建: source=%s",
|
||
batch_source_plan_id,
|
||
exc_info=last_err,
|
||
)
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="创建批量任务失败:无法生成独立剪辑计划,请重试",
|
||
) from last_err
|
||
variant_plan_ids.append(variant.id)
|
||
|
||
# #1855 P0:把新变体的clips区间追加到batch_segments,供下一变体避让
|
||
try:
|
||
_new_segs = _collect_segments(variant.id, _plan_svc._clip_repo)
|
||
for _aid, _ivs in _new_segs.items():
|
||
_batch_segments.setdefault(_aid, []).extend(_ivs)
|
||
except Exception:
|
||
logger.exception("[生成任务] 变体%d 区间收集失败(不阻断)", task_index)
|
||
|
||
# ③ 配音时长分配(回传 plan / clone 变体0 均需幂等分配;reselect 已在选片时分配,
|
||
# #1855:apply_voice_duration_to_plan 已内置幂等判断,重复调用安全)
|
||
for _vi, _pid in enumerate(variant_plan_ids):
|
||
_vd = voice_durations[_vi] if _vi < len(voice_durations) else 0.0
|
||
if _vd > 0:
|
||
try:
|
||
_plan_svc.apply_voice_duration_to_plan(_pid, _vd)
|
||
except Exception:
|
||
logger.exception("[生成任务] 变体%d 配音时长分配失败(不阻断): plan=%s", _vi, _pid)
|
||
|
||
# N=1 正式生成:渲染侧全局慢放兜底已删除(#1749),enqueue 前也必须按配音分配段长
|
||
if count == 1 and not request.is_preview:
|
||
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
|
||
|
||
try:
|
||
_voices = resolve_variant_voice_ids(
|
||
count=1,
|
||
voice_library_id=request.voice_library_id,
|
||
voice_library_ids=request.voice_library_ids or None,
|
||
)
|
||
_single_vd: list[float] = _query_voice_durations(db, _voices)
|
||
_single_dur = _single_vd[0] if _single_vd else 0.0
|
||
from app.services.generation_common import resolve_latest_plan_by_template
|
||
|
||
_single_plan = (
|
||
request.source_edit_plan_id
|
||
or resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id)
|
||
or ""
|
||
)
|
||
if _single_dur > 0 and _single_plan:
|
||
from app.services.edit_plan_service import EditPlanService
|
||
|
||
try:
|
||
EditPlanService(db).apply_voice_duration_to_plan(_single_plan, _single_dur)
|
||
except Exception:
|
||
logger.exception("[生成任务] N=1 配音时长分配失败(不阻断): plan=%s", _single_plan)
|
||
except VariantVoiceError as ve:
|
||
raise HTTPException(status_code=400, detail=str(ve)) from ve
|
||
except Exception:
|
||
logger.exception("[生成任务] N=1 配音分配兜底异常(不阻断)")
|
||
|
||
try:
|
||
for task_index in range(count):
|
||
# #1749:count>1 时每个变体(含变体0)都关联各自独立 plan(clone/reselect/variant-plans)。
|
||
if count > 1 and variant_plan_ids:
|
||
effective_plan_id = variant_plan_ids[task_index]
|
||
else:
|
||
effective_plan_id = request.source_edit_plan_id
|
||
|
||
# 变体级独立配置:titles[]/voice_library_ids[]/cover_urls[]
|
||
# 长度1=所有变体共用,长度=count=每个变体独立,空数组=回退单值字段
|
||
variant_title_text = _variant_value(request.titles, task_index, "")
|
||
variant_title_config = dict(request.title_config or {})
|
||
if variant_title_text.strip():
|
||
variant_title_config["text"] = variant_title_text.strip()
|
||
variant_voice_library_id = _variant_value(request.voice_library_ids, task_index, request.voice_library_id)
|
||
variant_cover_url = _variant_value(request.cover_urls, task_index, request.cover_url)
|
||
|
||
task = use_case.execute(
|
||
CreateGenerationTaskCommand(
|
||
project_id=project_id,
|
||
asset_library_id=asset_library_id,
|
||
strategy_id=effective_strategy_id,
|
||
voice_library_id=variant_voice_library_id,
|
||
template_id=request.template_id,
|
||
asset_ids=resolved_asset_ids,
|
||
title_ids=request.title_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=variant_cover_url,
|
||
title_config=variant_title_config,
|
||
)
|
||
)
|
||
# 变体序号写入 extra_meta(响应/排查时可辨识)
|
||
task.extra_meta["variant_index"] = task_index
|
||
try:
|
||
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
|
||
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
|
||
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)。
|
||
# #1743:批量(count>1)场景严禁兜底共用——变体 plan 已在上方预生成,
|
||
# 走到这里还缺 plan 说明预生成漏配,直接报错中断,不允许 N 任务关联同一 plan。
|
||
if not task.source_edit_plan_id and count > 1:
|
||
logger.error(
|
||
"[生成任务] 批量任务缺少独立 plan(禁止共用兜底): task_index=%d task_id=%s",
|
||
task_index,
|
||
task.id,
|
||
)
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="创建批量任务失败:变体剪辑计划缺失,请重新预览后再批量生成。",
|
||
)
|
||
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。
|
||
# 批量场景下每个变体关联独立 plan,需各自回写自己的变体标题配置。
|
||
_effective_plan_id = task.source_edit_plan_id
|
||
if _effective_plan_id:
|
||
_writeback_edit_plan_config(
|
||
plan_id=_effective_plan_id,
|
||
task_id=task.id,
|
||
title_config=variant_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=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||
) from _e
|
||
break
|
||
except GlobalQueueFull as _e:
|
||
failed_tasks.append(task)
|
||
if not created_tasks:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
|
||
) 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,
|
||
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 as _e:
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||
) from None
|
||
except GlobalQueueFull as _e:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
|
||
) 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=build_rate_limit_detail(
|
||
UserPendingLimitExceeded(
|
||
user_id=user_id,
|
||
pending_count=user_pending,
|
||
limit=USER_PENDING_LIMIT,
|
||
),
|
||
generation_task_repository,
|
||
scope="user",
|
||
),
|
||
)
|
||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail=build_rate_limit_detail(
|
||
GlobalQueueFull(pending_count=global_pending, limit=GLOBAL_PENDING_LIMIT),
|
||
generation_task_repository,
|
||
scope="global",
|
||
),
|
||
)
|
||
|
||
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,
|
||
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 as _e:
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||
) from None
|
||
except GlobalQueueFull as _e:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
|
||
) 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)
|