from __future__ import annotations from datetime import datetime from types import SimpleNamespace from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile from sqlalchemy import inspect as sa_inspect from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.dependencies import get_current_user, get_db from app.models.user import User from app.enums.common import ModuleEventTypeEnum from app.enums.generation_task import GenerationOwnerType from app.enums.shot_replicate import ( ModuleCodeEnum, ShotAnalysisStatusEnum, ShotReplicateLogEventEnum, ShotReplicateStepCodeEnum, ShotSegmentAnalysisStatusEnum, ShotSegmentReplicateStatusEnum, ShotSegmentSourceModeEnum, ShotSplitStatusEnum, ShotTaskSetStatusEnum, ) from app.schemas.shot_replicate import ( ShotReplicateActionOut, ShotReplicateDeleteOut, ShotReplicateGenerateImagePromptRequest, ShotReplicateGenerateImageRequest, ShotReplicateGenerateVideoPromptRequest, ShotReplicateGenerateVideoRequest, ShotReplicateImagePromptUpdateRequest, ShotReanalyzeOut, ShotReanalyzeRequest, ShotSegmentSplitRetryOut, ShotSplitRetryRequest, ShotReplicateMaterialUpdateRequest, ShotReplicateSpecOut, ShotReplicateTaskDetailOut, ShotReplicateVideoPromptSchemaUpdateRequest, ShotSegmentDeleteOut, ShotTaskSetDeleteOut, ShotSegmentDetailOut, ShotSegmentListOut, ShotSegmentReplicationCreateRequest, ShotSplitByAIOut, ShotSplitByAIRequest, ShotSplitCustomOut, ShotSplitCustomRequest, ShotTaskSetCreate, ShotTaskSetDetailOut, ShotTaskSetListOut, ) from app.services.shot_replicate_flow_service import ( _get_project_for_user, create_shot_replicate_project_from_segment, generate_image_from_prompt, generate_video_from_prompt, mark_shot_replicate_step_dispatch_failed, project_to_detail_out, submit_image_prompt_optimize, submit_video_prompt_optimize, update_shot_replicate_image_prompt, update_shot_replicate_material_input, update_shot_replicate_video_prompt_schema, ) from app.services.shot_replicate_taskset_service import ( create_custom_segment, create_segments_by_ai, create_task_set, delete_segment, delete_task_set, get_segment_for_user, list_segments, list_task_sets, prepare_reanalyze_segment, prepare_reanalyze_task_set, prepare_retry_split_segment, segment_detail, task_set_detail, ) from app.services.module_generation_log_service import log_module_error, log_module_event_file from app.services.module_async_recovery_service import ( TASK_SHOT_IMAGE_PROMPT, TASK_SHOT_VIDEO_PROMPT, register_module_step_task, register_shot_segment_analysis_task, register_shot_split_task, register_shot_task_set_analysis_task, ) from app.tasks.celery_app import celery_app from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum from app.services.upload_resource import upload_reference_file, bind_upload_resources, cleanup_upload_resource_files_after_commit from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log MODULE = ModuleCodeEnum.SHOT_REPLICATE.value def _safe_user_id(user: object | None) -> str | None: """从 ORM 对象中安全取用户ID,避免 rollback/commit 后访问过期属性触发 MissingGreenlet。""" if user is None: return None try: value = getattr(user, "__dict__", {}).get("id") if value is not None: return str(value) except Exception: pass try: identity = sa_inspect(user).identity if identity: return str(identity[0]) except Exception: pass return None def _safe_user_is_admin(user: object | None) -> bool: """安全判断管理员身份;如果对象属性已过期,保守按普通用户处理。""" if user is None: return False try: data = getattr(user, "__dict__", {}) if "is_admin" in data: return bool(data.get("is_admin")) except Exception: pass return False def _user_context(user: object | None) -> SimpleNamespace: return SimpleNamespace(id=_safe_user_id(user), is_admin=_safe_user_is_admin(user)) router = APIRouter(prefix="/shot-replications", tags=["shot-replications"]) def _log_api_error( *, event_type: str, current_user: User | None = None, project_id: str | None = None, step_id: str | None = None, message: str | None = None, exc: BaseException | None = None, detail: dict | None = None, ) -> None: log_module_error( module=MODULE, event_type=event_type, project_id=project_id, step_id=step_id, user_id=_safe_user_id(current_user), message=message, detail=detail, exc=exc, ) def _log_api_exception_from_locals(exc: BaseException, local_values: dict, message: str) -> None: current_user = local_values.get("current_user") project_id = local_values.get("project_id_value") or local_values.get("project_id") or local_values.get("task_set_id") or local_values.get("task_set_id_value") step_id = local_values.get("step_id_value") or local_values.get("step_id") or local_values.get("segment_id") or local_values.get("segment_id_value") req = local_values.get("req") detail = {"api": local_values.get("__name__"), "request": req.model_dump() if hasattr(req, "model_dump") else str(req) if req is not None else None} _log_api_error( event_type=ShotReplicateLogEventEnum.API_REQUEST_FAILED.value, current_user=current_user if isinstance(current_user, User) else None, project_id=str(project_id) if project_id else None, step_id=str(step_id) if step_id else None, message=message, detail=detail, exc=exc, ) def _ensure_celery_enabled(*, current_user: User | None = None, project_id: str | None = None, step_id: str | None = None) -> None: if celery_app is not None: return message = "Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker" _log_api_error( event_type=ShotReplicateLogEventEnum.CELERY_DISABLED.value, current_user=current_user, project_id=project_id, step_id=step_id, message=message, detail={"api": "shot_replicate"}, ) raise HTTPException(status_code=503, detail=message) async def _reload_project_detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut: project = await _get_project_for_user( db, project_id=project_id, user=_user_context(current_user), for_update=False, populate_existing=True, ) return await project_to_detail_out(db, project) async def _mark_dispatch_failed_and_raise( db: AsyncSession, *, current_user: User, project_id: str, step_id: str | None, message: str, ) -> None: if step_id: try: await mark_shot_replicate_step_dispatch_failed( db, current_user=_user_context(current_user), project_id=project_id, step_id=step_id, error_message=message, ) await db.commit() except Exception as exc: await db.rollback() _log_api_error( event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value, current_user=current_user, project_id=project_id, step_id=step_id, message="Celery 投递失败后标记步骤失败也失败", detail={"dispatch_error": message}, exc=exc, ) log_module_error( module=MODULE, event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, project_id=project_id, step_id=step_id, user_id=_safe_user_id(current_user), message=message, detail={"reason": "celery_dispatch_failed"}, error=message, ) raise HTTPException(status_code=503, detail=message) @router.get( "/spec", response_model=ShotReplicateSpecOut, summary="查询拆镜复刻模块状态枚举和步骤 JSON 结构说明", description="保留给调试和前端兜底读取。原业务接口已经在 Path、Query、Body 和响应模型字段上直接展示参数说明与枚举值。", ) async def get_spec(): return ShotReplicateSpecOut() @router.post( "/upload-image", summary="上传拆镜复刻图片素材", description="上传后先记录为 pending UploadResource;创建拆镜复刻项目后绑定业务记录。", ) async def upload_shot_replicate_image( file: UploadFile = File(...), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await upload_reference_file( db, file=file, current_user=current_user, module=UploadResourceModuleEnum.SHOT_REPLICATE.value, resource_type=UploadResourceTypeEnum.IMAGE.value, gen_type="video", ) await db.commit() return { "url": result.url, "filename": result.filename, "type": "image", "module": result.module, "resource_id": result.resource_id, "file_size_bytes": result.file_size_bytes, } @router.post( "/upload-video", summary="上传拆镜复刻视频素材", description="上传后先记录为 pending UploadResource;创建拆镜总任务集后绑定 ShotReplicateTaskSet。", ) async def upload_shot_replicate_video( file: UploadFile = File(...), duration_seconds: float | None = Query(None, description="前端识别的视频时长秒数,可选"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await upload_reference_file( db, file=file, current_user=current_user, module=UploadResourceModuleEnum.SHOT_REPLICATE.value, resource_type=UploadResourceTypeEnum.VIDEO.value, duration_seconds=duration_seconds, ) await db.commit() return { "url": result.url, "filename": result.filename, "type": "video", "module": result.module, "resource_id": result.resource_id, "file_size_bytes": result.file_size_bytes, "duration_seconds": result.duration_seconds, } @router.post( "/task-sets", response_model=ShotTaskSetDetailOut, summary="创建拆镜总任务集并异步分析原视频", description=( "创建拆镜总任务集,保存原视频地址和时长,随后异步投递原视频 AI 分析任务。" "分析完成后会写入原视频内容、分类、受众和 AI 建议拆镜时间段。" "状态枚举直接见本接口响应字段:status、analysis_status、split_status。" ), ) async def create_shot_task_set( req: ShotTaskSetCreate = Body(..., description="创建拆镜总任务集参数:原视频 URL、视频时长、标题和可选幂等键"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id")) try: task_set = await create_task_set(db, current_user=current_user, req=req) task_set_id = task_set.id await bind_upload_resources( db, user_id=current_user.id, module=UploadResourceModuleEnum.SHOT_REPLICATE.value, source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_TASK_SET.value, source_id=task_set_id, resource_ids=[req.video_resource_id], urls=[req.video_url], allow_common_migrate=True, ) await db.commit() except HTTPException: await db.rollback() raise except Exception as exc: await db.rollback() _log_api_exception_from_locals(exc, locals(), f"创建拆镜总任务集失败: {exc}") raise HTTPException(status_code=500, detail=f"创建拆镜总任务集失败: {exc}") try: from app.tasks.shot_replicate_tasks import analyze_original_video await register_shot_task_set_analysis_task(task_set_id) analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", countdown=0) except Exception as exc: _log_api_error( event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, current_user=current_user, project_id=task_set_id, message=f"拆镜分析任务投递失败: {exc}", detail={"task_set_id": task_set_id, "task": "analyze_original_video"}, exc=exc, ) raise HTTPException(status_code=503, detail=f"拆镜分析任务投递失败: {exc}") return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id) @router.get( "/task-sets", response_model=ShotTaskSetListOut, summary="查询拆镜总任务集列表", ) async def list_shot_task_sets( status: ShotTaskSetStatusEnum | None = Query(None, description="总任务状态筛选:pending_analysis=等待分析,analyzing=分析中,analysis_completed=分析完成,analysis_failed=分析失败,splitting=拆镜中,split_completed=拆镜完成,partial_failed=部分失败,failed=失败,deleted=已软删"), analysis_status: ShotAnalysisStatusEnum | None = Query(None, description="原视频分析状态筛选:pending=待分析,processing=分析中,completed=分析完成,failed=分析失败"), split_status: ShotSplitStatusEnum | None = Query(None, description="拆镜状态筛选:none=尚未拆镜,pending=待拆镜,processing=拆镜中,completed=拆镜完成,failed=拆镜失败,retry_waiting=等待恢复重试"), keyword: str | None = Query(None, description="标题/原视频内容/分类/受众关键词,模糊搜索;普通用户只在自己的数据内搜索"), user_id: str | None = Query(None, description="管理员专用:按用户ID筛选;普通用户不可使用"), user_name: str | None = Query(None, description="管理员专用:按用户名模糊筛选;普通用户不可使用"), created_start: datetime | None = Query(None, description="管理员专用:创建时间开始,ISO datetime;普通用户不可使用"), created_end: datetime | None = Query(None, description="管理员专用:创建时间结束,ISO datetime;普通用户不可使用"), page: int = Query(1, ge=1, description="分页页码,从1开始"), page_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): admin_only_params = { "user_id": user_id, "user_name": user_name, "created_start": created_start, "created_end": created_end, } if not _safe_user_is_admin(current_user) and any(value is not None and str(value).strip() != "" for value in admin_only_params.values()): raise HTTPException(status_code=403, detail="当前搜索条件仅管理员可用") return await list_task_sets( db, current_user=current_user, status=status.value if status else None, analysis_status=analysis_status.value if analysis_status else None, split_status=split_status.value if split_status else None, keyword=keyword, user_id=user_id, user_name=user_name, created_start=created_start, created_end=created_end, page=page, page_size=page_size, ) @router.get( "/task-sets/{task_set_id}", response_model=ShotTaskSetDetailOut, summary="获取拆镜总任务集详情", description="根据拆镜总任务集ID查询详情,包含原视频分析结果、AI 建议拆镜列表和当前总任务状态。", ) async def get_shot_task_set( task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id) @router.post( "/task-sets/{task_set_id}/reanalyze", response_model=ShotReanalyzeOut, summary="重新投递原视频 AI 分析任务", description="用于处理原视频分析失败或待处理的异常数据;重置分析状态后重新投递 analyze_original_video。", ) async def reanalyze_task_set( task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"), req: ShotReanalyzeRequest = Body(default_factory=ShotReanalyzeRequest, description="再次分析参数"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _ensure_celery_enabled(current_user=current_user, project_id=task_set_id) try: out = await prepare_reanalyze_task_set( db, current_user=current_user, task_set_id=task_set_id, force=req.force, reason=req.reason, ) await db.commit() except HTTPException as exc: await db.rollback() log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_REJECTED.value, project_id=task_set_id, user_id=_safe_user_id(current_user), message="原视频再次分析请求被拒绝", detail={"task_set_id": task_set_id, "request": req.model_dump(), "http_status": exc.status_code, "detail": exc.detail}, error=str(exc.detail), ) raise except Exception as exc: await db.rollback() _log_api_error( event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_FAILED.value, current_user=current_user, project_id=task_set_id, message=f"原视频再次分析状态重置失败: {exc}", detail={"task_set_id": task_set_id, "request": req.model_dump()}, exc=exc, ) raise HTTPException(status_code=500, detail=f"原视频再次分析状态重置失败: {exc}") try: from app.tasks.shot_replicate_tasks import analyze_original_video await register_shot_task_set_analysis_task(task_set_id) analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", countdown=0) log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_SUBMITTED.value, project_id=task_set_id, user_id=_safe_user_id(current_user), message="原视频再次分析任务已投递", detail={"task_set_id": task_set_id, "task": "analyze_original_video", "request": req.model_dump()}, ) except Exception as exc: _log_api_error( event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, current_user=current_user, project_id=task_set_id, message=f"原视频再次分析任务投递失败: {exc}", detail={"task_set_id": task_set_id, "task": "analyze_original_video"}, exc=exc, ) raise HTTPException(status_code=503, detail=f"原视频再次分析任务投递失败: {exc}") out.message = "原视频再次分析任务已提交" return out @router.post( "/task-sets/{task_set_id}/split-by-ai", response_model=ShotSplitByAIOut, summary="按 AI 建议方案异步拆镜", description=( "基于原视频 AI 分析生成的建议时间段创建拆镜片段,并异步投递 ffmpeg 切割任务。" "selected_indices 不传时默认按全部 AI 建议拆镜;replace_existing=true 时会软删旧 AI 建议片段后重新创建。" ), ) async def split_by_ai( task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"), req: ShotSplitByAIRequest = Body(default_factory=ShotSplitByAIRequest, description="AI 建议拆镜参数:可选择建议序号,也可选择是否覆盖旧 AI 片段"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id")) try: out = await create_segments_by_ai(db, current_user=current_user, task_set_id=task_set_id, req=req) segment_ids = [item.id for item in out.segments] await db.commit() except HTTPException: await db.rollback() raise except Exception as exc: await db.rollback() _log_api_exception_from_locals(exc, locals(), f"按 AI 建议拆镜失败: {exc}") raise HTTPException(status_code=500, detail=f"按 AI 建议拆镜失败: {exc}") from app.tasks.shot_replicate_tasks import split_one_segment for segment_id in segment_ids: await register_shot_split_task(segment_id, task_set_id=task_set_id) split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", countdown=0) return out @router.post( "/task-sets/{task_set_id}/split-custom", response_model=ShotSplitCustomOut, summary="按用户自定义开始/结束秒异步拆单条片段", description="按用户传入的 start_second/end_second 创建 custom 来源片段,并异步投递 ffmpeg 切割任务;切割完成后可进入复刻项目。", ) async def split_custom( task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"), req: ShotSplitCustomRequest = Body(..., description="自定义拆镜时间段参数,end_second 必须大于 start_second"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id")) try: out = await create_custom_segment(db, current_user=current_user, task_set_id=task_set_id, req=req) segment_id = out.segment.id await db.commit() except HTTPException: await db.rollback() raise except Exception as exc: await db.rollback() _log_api_exception_from_locals(exc, locals(), f"自定义拆镜失败: {exc}") raise HTTPException(status_code=500, detail=f"自定义拆镜失败: {exc}") from app.tasks.shot_replicate_tasks import split_one_segment await register_shot_split_task(segment_id, task_set_id=task_set_id) split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", countdown=0) return out @router.get( "/task-sets/{task_set_id}/segments", response_model=ShotSegmentListOut, summary="查询拆镜片段列表", description="分页查询指定拆镜总任务集下的片段列表,可按来源、切割状态、片段分析状态和复刻状态筛选。", ) async def list_task_set_segments( task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"), source_mode: ShotSegmentSourceModeEnum | None = Query(None, description="片段来源:ai_suggestion=AI 建议拆镜,custom=用户自定义拆镜"), split_status: ShotSplitStatusEnum | None = Query(None, description="切割状态:none=尚未拆镜,pending=待拆镜,processing=拆镜中,completed=拆镜完成,failed=拆镜失败,retry_waiting=等待恢复重试"), analysis_status: ShotSegmentAnalysisStatusEnum | None = Query(None, description="片段分析状态:not_required=无需单独分析,pending=等待分析,processing=分析中,completed=分析完成,failed=分析失败"), replicate_status: ShotSegmentReplicateStatusEnum | None = Query(None, description="片段复刻状态:not_started=未复刻,project_created=已创建复刻项目,processing=复刻处理中,completed=复刻完成,failed=复刻失败"), page: int = Query(1, ge=1, description="分页页码,从1开始"), page_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return await list_segments( db, current_user=current_user, task_set_id=task_set_id, source_mode=source_mode.value if source_mode else None, split_status=split_status.value if split_status else None, analysis_status=analysis_status.value if analysis_status else None, replicate_status=replicate_status.value if replicate_status else None, page=page, page_size=page_size, ) @router.get( "/segments/{segment_id}", response_model=ShotSegmentDetailOut, summary="获取拆镜片段详情", description="根据拆镜片段ID查询单个片段详情,包含切割结果、片段分析结果、复刻项目ID和状态。", ) async def get_segment( segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return await segment_detail(db, current_user=current_user, segment_id=segment_id) @router.post( "/segments/{segment_id}/reanalyze", response_model=ShotReanalyzeOut, summary="重新投递切片视频 AI 分析任务", description="用于处理自定义切片视频分析失败或待处理的异常数据;重置分析状态后重新投递 analyze_custom_segment_video。", ) async def reanalyze_segment( segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"), req: ShotReanalyzeRequest = Body(default_factory=ShotReanalyzeRequest, description="再次分析参数"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _ensure_celery_enabled(current_user=current_user, step_id=segment_id) try: out = await prepare_reanalyze_segment( db, current_user=current_user, segment_id=segment_id, force=req.force, reason=req.reason, ) task_set_id = out.task_set_id await db.commit() except HTTPException as exc: await db.rollback() log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_REJECTED.value, step_id=segment_id, user_id=_safe_user_id(current_user), message="切片视频再次分析请求被拒绝", detail={"segment_id": segment_id, "request": req.model_dump(), "http_status": exc.status_code, "detail": exc.detail}, error=str(exc.detail), ) raise except Exception as exc: await db.rollback() _log_api_error( event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_FAILED.value, current_user=current_user, step_id=segment_id, message=f"切片视频再次分析状态重置失败: {exc}", detail={"segment_id": segment_id, "request": req.model_dump()}, exc=exc, ) raise HTTPException(status_code=500, detail=f"切片视频再次分析状态重置失败: {exc}") try: from app.tasks.shot_replicate_tasks import analyze_custom_segment_video await register_shot_segment_analysis_task(segment_id, task_set_id=task_set_id) analyze_custom_segment_video.apply_async(args=[segment_id], queue="gen_chatapi_create", countdown=0) log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_SUBMITTED.value, project_id=task_set_id, step_id=segment_id, user_id=_safe_user_id(current_user), message="切片视频再次分析任务已投递", detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video", "request": req.model_dump()}, ) except Exception as exc: _log_api_error( event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, current_user=current_user, project_id=task_set_id, step_id=segment_id, message=f"切片视频再次分析任务投递失败: {exc}", detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"}, exc=exc, ) raise HTTPException(status_code=503, detail=f"切片视频再次分析任务投递失败: {exc}") out.message = "切片视频再次分析任务已提交" return out @router.post( "/segments/{segment_id}/retry-split", response_model=ShotSegmentSplitRetryOut, summary="重试拆镜片段视频切片", description="用于处理 ShotReplicateSegment 视频切片失败;重置 split_status 后复用现有 split_one_segment Celery 任务重新切割。", ) async def retry_split_segment( segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"), req: ShotSplitRetryRequest = Body(default_factory=ShotSplitRetryRequest, description="切片失败重试参数"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _ensure_celery_enabled(current_user=current_user, step_id=segment_id) try: out = await prepare_retry_split_segment( db, current_user=current_user, segment_id=segment_id, force=req.force, reason=req.reason, ) task_set_id = out.task_set_id await db.commit() except HTTPException: await db.rollback() raise except Exception as exc: await db.rollback() _log_api_error( event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_DISPATCH_FAILED.value, current_user=current_user, step_id=segment_id, message=f"切片失败重试状态重置失败: {exc}", detail={"segment_id": segment_id, "request": req.model_dump()}, exc=exc, ) raise HTTPException(status_code=500, detail=f"切片失败重试状态重置失败: {exc}") try: from app.tasks.shot_replicate_tasks import split_one_segment await register_shot_split_task(segment_id, task_set_id=task_set_id) split_one_segment.apply_async( args=[segment_id], queue="gen_result_download", countdown=0, priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER, ) log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_SUBMITTED.value, project_id=task_set_id, step_id=segment_id, user_id=_safe_user_id(current_user), message="拆镜片段切片重试任务已投递", detail={ "segment_id": segment_id, "task_set_id": task_set_id, "task": "split_one_segment", "queue": "gen_result_download", "request": req.model_dump(), }, ) except Exception as exc: _log_api_error( event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_DISPATCH_FAILED.value, current_user=current_user, project_id=task_set_id, step_id=segment_id, message=f"拆镜片段切片重试任务投递失败,等待恢复任务兜底: {exc}", detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "split_one_segment"}, exc=exc, ) out.message = "切片状态已重置,但 Celery 投递失败,将等待恢复任务兜底" return out @router.delete( "/segments/{segment_id}", response_model=ShotSegmentDeleteOut, summary="软删除拆镜片段", description=( "软删除单个拆镜片段;不删除 segment_video_path 指向的物理文件。" "如片段已创建拆镜复刻项目,会联动软删除该项目和已生成资源账本,但用户主动删除不退款。" "如片段或关联项目仍有处理中任务,会拒绝删除。" ), ) async def delete_shot_segment( segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): user_id = _safe_user_id(current_user) pending_ids: list[str] = [] try: out = await delete_segment(db, current_user=current_user, segment_id=segment_id) pending_ids = list(out.pending_delete_resource_ids or []) await db.commit() except HTTPException as exc: await safe_rollback_with_log( db, event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_ROLLBACK_FAILED.value, message="删除拆镜片段 HTTPException 回滚失败", user_id=user_id, module=MODULE, detail={"segment_id": segment_id}, original_exc=exc, ) raise except Exception as exc: # noqa: BLE001 await safe_rollback_with_log( db, event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_ROLLBACK_FAILED.value, message="删除拆镜片段主事务回滚失败", user_id=user_id, module=MODULE, detail={"segment_id": segment_id}, original_exc=exc, ) _log_api_exception_from_locals(exc, locals(), f"删除拆镜片段失败: {exc}") log_upload_resource_exception( event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_FAILED.value, message=f"删除拆镜片段失败: {exc}", user_id=user_id, module=MODULE, detail={"segment_id": segment_id}, exc=exc, ) raise HTTPException(status_code=500, detail=f"删除拆镜片段失败: {exc}") if pending_ids: try: await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids) await db.commit() except Exception as exc: # noqa: BLE001 await safe_rollback_with_log( db, event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_ROLLBACK_FAILED.value, message="删除拆镜片段 cleanup 事务回滚失败", user_id=user_id, module=MODULE, detail={"segment_id": segment_id, "pending_ids": pending_ids}, original_exc=exc, ) log_upload_resource_exception( event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_CLEANUP_FAILED.value, message=f"删除拆镜片段后清理真实文件失败: {exc}", user_id=user_id, resource_ids=pending_ids, module=MODULE, detail={"segment_id": segment_id}, exc=exc, ) return out @router.post( "/segments/{segment_id}/replication-projects", response_model=ShotReplicateActionOut, summary="将拆镜片段创建为拆镜复刻项目", description=( "以拆镜片段作为锁定素材视频创建 ModuleGenerationProject 复刻项目。" "创建后只同步生成第1步 material_input,后续第2-5步需要调用 /projects/{project_id}/steps/{step_id}/... 系列接口手动推进。" "素材视频来自片段 segment_video_url,不允许前端传入或后续修改。" ), ) async def create_replication_project_from_segment( segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"), req: ShotSegmentReplicationCreateRequest = Body(..., description="从拆镜片段创建复刻项目参数:生成项目名称、核心内容点、新产品图片和可选幂等键"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): log_module_event_file( module=MODULE, event_type=ModuleEventTypeEnum.V1_CREATE_BLOCKED.value, user_id=current_user.id, step_id=segment_id, message="拦截拆镜复刻 V1 创建请求", detail={"api_version": "v1", "flow_version": "v1", "segment_id": segment_id}, ) raise HTTPException( status_code=410, detail="V1 创建流程已停止,请使用 V2 API", ) @router.get( "/projects/{project_id}", response_model=ShotReplicateTaskDetailOut, summary="获取拆镜复刻项目详情", description="获取拆镜复刻 ModuleGenerationProject 项目详情,聚合返回素材、图片提词、图片生成、视频提词、视频生成和当前有效步骤列表。", ) async def get_project( project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return await _reload_project_detail(db, current_user, project_id) @router.put( "/projects/{project_id}/material", response_model=ShotReplicateActionOut, summary="修改拆镜复刻素材信息,素材视频不允许修改", description=( "修改拆镜复刻第1步素材输入,并重建第1步 material_input 新版本。" "素材视频 material_video_url 锁定为拆镜片段视频,不允许修改;可修改新产品图片、参考素材项目名、生成项目名和核心内容点。" "修改后会软删除第2、3、4、5步当前有效任务,并清空旧图片/视频结果。" ), ) async def update_material( project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), req: ShotReplicateMaterialUpdateRequest = Body(..., description="第1步素材输入修改参数;不接收 material_video_url,至少传一个允许修改字段"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): try: project_id_value, step_id_value = await update_shot_replicate_material_input(db, current_user=current_user, project_id=project_id, req=req) await db.commit() except HTTPException: await db.rollback() raise except Exception as exc: await db.rollback() _log_api_exception_from_locals(exc, locals(), f"修改素材输入失败: {exc}") raise HTTPException(status_code=500, detail=f"修改素材输入失败: {exc}") return ShotReplicateActionOut(message="素材输入已修改,素材视频保持锁定", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) @router.put( "/projects/{project_id}/steps/{step_id}/image-prompt", response_model=ShotReplicateActionOut, summary="直接修改第2步图片 AI 优化提词", description="直接修改第2步 image_prompt_optimize 的图片提示词,不调用 AI、不扣积分;保存后软删除第3、4、5步当前有效任务。", ) async def update_image_prompt( project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), step_id: str = Path(..., description="第2步图片 AI 提词子任务ID,即 module_generation_steps.id,step_code=image_prompt_optimize"), req: ShotReplicateImagePromptUpdateRequest = Body(..., description="图片 AI 优化提词修改参数"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): try: project, step = await update_shot_replicate_image_prompt(db, current_user=current_user, project_id=project_id, step_id=step_id, req=req) project_id_value, step_id_value = project.id, step.id await db.commit() except HTTPException: await db.rollback() raise except Exception as exc: await db.rollback() _log_api_exception_from_locals(exc, locals(), f"修改图片 AI 提词失败: {exc}") raise HTTPException(status_code=500, detail=f"修改图片 AI 提词失败: {exc}") return ShotReplicateActionOut(message="图片 AI 提词已修改,后续步骤已软删除", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) @router.put( "/projects/{project_id}/steps/{step_id}/video-prompt-schema", response_model=ShotReplicateActionOut, summary="修改第4步视频 AI 提词 JSON schema", description=( "修改第4步 video_prompt_optimize 的视频提词 JSON schema,不调用 AI、不扣积分。" "服务端会锁定视频时长、比例、分辨率、帧率、动态时间规划等关键结构;保存后软删除第5步视频生成任务。" ), ) async def update_video_prompt_schema( project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), step_id: str = Path(..., description="第4步视频 AI 提词子任务ID,即 module_generation_steps.id,step_code=video_prompt_optimize"), req: ShotReplicateVideoPromptSchemaUpdateRequest = Body(..., description="视频 AI 提词 JSON schema 修改参数"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): try: project, step = await update_shot_replicate_video_prompt_schema(db, current_user=current_user, project_id=project_id, step_id=step_id, req=req) project_id_value, step_id_value = project.id, step.id await db.commit() except HTTPException: await db.rollback() raise except Exception as exc: await db.rollback() _log_api_exception_from_locals(exc, locals(), f"修改视频 AI 提词失败: {exc}") raise HTTPException(status_code=500, detail=f"修改视频 AI 提词失败: {exc}") return ShotReplicateActionOut(message="视频 AI 提词 schema 已修改,第5步视频生成已软删除", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) @router.post( "/projects/{project_id}/steps/{step_id}/generate-image-prompt", response_model=ShotReplicateActionOut, summary="基于第1步素材输入生成图片 AI 提词", description=( "基于第1步 material_input 子任务手动生成第2步 image_prompt_optimize。" "如果已存在旧的第2、3、4、5步,会先软删除旧步骤,再创建新的第2步并投递 Celery 文本提词任务。" ), ) async def generate_image_prompt( project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), step_id: str = Path(..., description="第1步素材输入子任务ID,即 module_generation_steps.id,step_code=material_input"), req: ShotReplicateGenerateImagePromptRequest = Body(default_factory=ShotReplicateGenerateImagePromptRequest, description="图片提词生成参数,当前无需传参,额外字段会忽略"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id) try: project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id, req=req) project_id_value, step_id_value = project.id, step.id await db.commit() except HTTPException: await db.rollback() raise except Exception as exc: await db.rollback() _log_api_exception_from_locals(exc, locals(), f"提交图片 AI 提词失败: {exc}") raise HTTPException(status_code=500, detail=f"提交图片 AI 提词失败: {exc}") try: from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize await register_module_step_task( module=MODULE, project_id=project_id_value, step_id=step_id_value, step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, task_name=TASK_SHOT_IMAGE_PROMPT, ) start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0) except Exception as exc: await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片 AI 提词任务投递失败: {exc}") return ShotReplicateActionOut(message="图片 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) @router.post( "/projects/{project_id}/steps/{step_id}/generate-image", response_model=ShotReplicateActionOut, summary="基于第2步图片 AI 提词生成图片", description=( "基于第2步 image_prompt_optimize 子任务生成第3步 image_generate。" "请求体传入图片引擎和图片参数;ChatGenerationTask 幂等键由后端自动生成。" "如果已存在旧的第3、4、5步,会先软删除旧步骤,再创建新的第3步。" ), ) async def generate_image( project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), step_id: str = Path(..., description="第2步图片 AI 提词子任务ID,即 module_generation_steps.id,step_code=image_prompt_optimize"), req: ShotReplicateGenerateImageRequest = Body(..., description="图片生成引擎和参数;可选值来自图片引擎配置接口"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id) try: project, step, chat_task = await generate_image_from_prompt(db, current_user=current_user, project_id=project_id, prompt_step_id=step_id, req=req) project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id await db.commit() except HTTPException: await db.rollback() raise except Exception as exc: await db.rollback() _log_api_exception_from_locals(exc, locals(), f"提交图片生成失败: {exc}") raise HTTPException(status_code=500, detail=f"提交图片生成失败: {exc}") try: from app.tasks.generation_create_tasks import chatapi_create_generation_task chatapi_create_generation_task.apply_async( args=[chat_task_id_value], kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1}, queue="gen_chatapi_create", countdown=0, ) except Exception as exc: _log_api_error( event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片生成任务投递失败,等待生成恢复任务补投: {exc}", detail={"recoverable": True, "chat_task_id": chat_task_id_value}, exc=exc, ) return ShotReplicateActionOut(message="图片生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) @router.post( "/projects/{project_id}/steps/{step_id}/generate-video-prompt", response_model=ShotReplicateActionOut, summary="基于第3步图片结果生成视频 AI 提词 JSON schema", description=( "基于第3步 image_generate 子任务生成第4步 video_prompt_optimize。" "视频时长、比例、分辨率在本步骤确定并写入第4步 output_json;第5步视频生成只选择视频引擎。" "如果已存在旧的第4、5步,会先软删除旧步骤,再创建新的第4步并投递 Celery 文本提词任务。" ), ) async def generate_video_prompt( project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), step_id: str = Path(..., description="第3步图片生成子任务ID,即 module_generation_steps.id,step_code=image_generate"), req: ShotReplicateGenerateVideoPromptRequest = Body(..., description="视频提词生成参数:视频引擎、时长、比例、分辨率和目标平台;可选值来自视频引擎配置接口"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id) try: project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req) project_id_value, step_id_value = project.id, step.id await db.commit() except HTTPException: await db.rollback() raise except Exception as exc: await db.rollback() _log_api_exception_from_locals(exc, locals(), f"提交视频 AI 提词失败: {exc}") raise HTTPException(status_code=500, detail=f"提交视频 AI 提词失败: {exc}") try: from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize await register_module_step_task( module=MODULE, project_id=project_id_value, step_id=step_id_value, step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, task_name=TASK_SHOT_VIDEO_PROMPT, ) start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0) except Exception as exc: await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频 AI 提词任务投递失败: {exc}") return ShotReplicateActionOut(message="视频 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) @router.post( "/projects/{project_id}/steps/{step_id}/generate-video", response_model=ShotReplicateActionOut, summary="基于第4步视频 AI 提词生成最终视频", description=( "基于第4步 video_prompt_optimize 子任务生成第5步 video_generate。" "请求体只需要选择视频生成引擎 engine_id;duration、aspect_ratio、resolution 从第4步视频提词结果继承。" "如果已存在旧的第5步,会先软删除旧步骤,再创建新的第5步。" ), ) async def generate_video( project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), step_id: str = Path(..., description="第4步视频 AI 提词子任务ID,即 module_generation_steps.id,step_code=video_prompt_optimize"), req: ShotReplicateGenerateVideoRequest = Body(..., description="视频生成参数:只传 engine_id,其它视频参数继承第4步视频提词结果"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id) try: project, step, chat_task = await generate_video_from_prompt(db, current_user=current_user, project_id=project_id, prompt_step_id=step_id, req=req) project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id await db.commit() except HTTPException: await db.rollback() raise except Exception as exc: await db.rollback() _log_api_exception_from_locals(exc, locals(), f"提交视频生成失败: {exc}") raise HTTPException(status_code=500, detail=f"提交视频生成失败: {exc}") try: from app.tasks.generation_create_tasks import chatapi_create_generation_task chatapi_create_generation_task.apply_async( args=[chat_task_id_value], kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1}, queue="gen_chatapi_create", countdown=0, ) except Exception as exc: _log_api_error( event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频生成任务投递失败,等待生成恢复任务补投: {exc}", detail={"recoverable": True, "chat_task_id": chat_task_id_value}, exc=exc, ) return ShotReplicateActionOut(message="视频生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) @router.delete( "/task-sets/{task_set_id}", response_model=ShotTaskSetDeleteOut, summary="软删除拆镜任务集", description="软删除整个 ShotReplicateTaskSet,并联动软删除全部片段和片段内部复刻项目;UploadResource 真实文件在事务提交后清理。", ) async def delete_shot_task_set( task_set_id: str = Path(..., description="拆镜任务集ID,即 shot_replicate_task_sets.id"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): user_id = _safe_user_id(current_user) pending_ids: list[str] = [] try: out = await delete_task_set(db, current_user=current_user, task_set_id=task_set_id) pending_ids = list(out.pending_delete_resource_ids or []) await db.commit() except HTTPException as exc: await safe_rollback_with_log( db, event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_ROLLBACK_FAILED.value, message="删除拆镜任务集 HTTPException 回滚失败", user_id=user_id, module=MODULE, detail={"task_set_id": task_set_id}, original_exc=exc, ) raise except Exception as exc: # noqa: BLE001 await safe_rollback_with_log( db, event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_ROLLBACK_FAILED.value, message="删除拆镜任务集主事务回滚失败", user_id=user_id, module=MODULE, detail={"task_set_id": task_set_id}, original_exc=exc, ) _log_api_exception_from_locals(exc, locals(), f"删除拆镜任务集失败: {exc}") log_upload_resource_exception( event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_FAILED.value, message=f"删除拆镜任务集失败: {exc}", user_id=user_id, module=MODULE, detail={"task_set_id": task_set_id}, exc=exc, ) raise HTTPException(status_code=500, detail=f"删除拆镜任务集失败: {exc}") if pending_ids: try: await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids) await db.commit() except Exception as exc: # noqa: BLE001 await safe_rollback_with_log( db, event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_ROLLBACK_FAILED.value, message="删除拆镜任务集 cleanup 事务回滚失败", user_id=user_id, module=MODULE, detail={"task_set_id": task_set_id, "pending_ids": pending_ids}, original_exc=exc, ) log_upload_resource_exception( event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_CLEANUP_FAILED.value, message=f"删除拆镜任务集后清理真实文件失败: {exc}", user_id=user_id, resource_ids=pending_ids, module=MODULE, detail={"task_set_id": task_set_id}, exc=exc, ) return out