diff --git a/video-gen-api/app/api/v1/private_portrait.py b/video-gen-api/app/api/v1/private_portrait.py index 36594d54..81cd2af3 100644 --- a/video-gen-api/app/api/v1/private_portrait.py +++ b/video-gen-api/app/api/v1/private_portrait.py @@ -2,7 +2,7 @@ from __future__ import annotations from urllib.parse import urlencode, unquote -from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile from fastapi.responses import RedirectResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -18,6 +18,7 @@ from app.enums.private_portrait import ( ) from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject from app.models.user import User +from app.enums.upload_resource import UploadResourceTypeEnum from app.schemas.private_portrait import ( PrivatePortraitAssetCreate, PrivatePortraitAssetListOut, @@ -31,6 +32,7 @@ from app.schemas.private_portrait import ( PrivatePortraitProjectOut, PrivatePortraitProjectUpdate, PrivatePortraitSelectableAssetListOut, + PrivatePortraitUploadOut, PrivatePortraitValidateSessionCreate, PrivatePortraitValidateSessionOut, build_private_portrait_enum_meta, @@ -55,6 +57,8 @@ from app.services.private_portrait.project_service import ( refresh_project_counters, soft_delete_project, ) +from app.services.private_portrait.upload_service import upload_private_portrait_asset_file +from app.services.upload_resource import cleanup_upload_resource_files_after_commit from app.services.private_portrait.real_person.service import ( create_real_person_asset, create_real_person_project, @@ -124,6 +128,62 @@ async def get_private_portrait_enum_meta(): return build_private_portrait_enum_meta() +@router.post( + "/private-portrait/uploads/image", + response_model=PrivatePortraitUploadOut, + summary="上传真人图片素材", + description="上传真人图片素材并写入 UploadResource,module=private_portrait_real。创建素材时需回传 resource_id 到 upload_resource_id。", +) +async def upload_private_portrait_image(file: UploadFile = File(...), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + try: + out = await upload_private_portrait_asset_file( + db, + file=file, + current_user=current_user, + library_type=PrivatePortraitLibraryType.REAL_PERSON.value, + resource_type=UploadResourceTypeEnum.IMAGE.value, + ) + await db.commit() + return out + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise HTTPException(status_code=500, detail=f"上传真人图片素材失败: {exc}") + + +@router.post( + "/private-portrait/uploads/video", + response_model=PrivatePortraitUploadOut, + summary="上传真人视频素材", + description="上传真人视频素材并写入 UploadResource,module=private_portrait_real。创建素材时需回传 resource_id 到 upload_resource_id。", +) +async def upload_private_portrait_video( + file: UploadFile = File(...), + duration_seconds: float | None = Query(None, description="客户端解析的视频秒数,服务端会写入 UploadResource 并在创建素材时回填"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + out = await upload_private_portrait_asset_file( + db, + file=file, + current_user=current_user, + library_type=PrivatePortraitLibraryType.REAL_PERSON.value, + resource_type=UploadResourceTypeEnum.VIDEO.value, + duration_seconds=duration_seconds, + ) + await db.commit() + return out + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise HTTPException(status_code=500, detail=f"上传真人视频素材失败: {exc}") + + @router.post( "/private-portrait/projects", response_model=PrivatePortraitProjectCreateWithValidateOut, @@ -196,6 +256,7 @@ async def update_private_portrait_project(project_id: str, payload: PrivatePortr async def delete_private_portrait_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value) project_id_snapshot = project.id + pending_upload_resource_ids = list(getattr(project, "_pending_upload_resource_ids", []) or []) await db.commit() try: from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote @@ -204,6 +265,21 @@ async def delete_private_portrait_project(project_id: str, current_user: User = _log_task_dispatch_success(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot) except Exception as exc: _log_task_dispatch_failed(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot, exc=exc) + if pending_upload_resource_ids: + try: + await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_upload_resource_ids) + await db.commit() + except Exception as exc: + await db.rollback() + log_operation_error( + domain=DOMAIN, + event_type=PrivatePortraitEventType.ASSET_UPLOAD_RELEASE_FAILED.value, + source=PrivatePortraitEventSource.API.value, + user_id=current_user.id, + project_id=project_id_snapshot, + exc=exc, + detail={"resource_ids": pending_upload_resource_ids}, + ) return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value) @@ -337,6 +413,7 @@ async def delete_private_portrait_asset(asset_id: str, current_user: User = Depe asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value) asset_id_snapshot = asset.id project_id_snapshot = asset.project_id + pending_upload_resource_ids = list(getattr(asset, "_pending_upload_resource_ids", []) or []) await db.commit() try: from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote @@ -345,6 +422,22 @@ async def delete_private_portrait_asset(asset_id: str, current_user: User = Depe _log_task_dispatch_success(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot) except Exception as exc: _log_task_dispatch_failed(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot, exc=exc) + if pending_upload_resource_ids: + try: + await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_upload_resource_ids) + await db.commit() + except Exception as exc: + await db.rollback() + log_operation_error( + domain=DOMAIN, + event_type=PrivatePortraitEventType.ASSET_UPLOAD_RELEASE_FAILED.value, + source=PrivatePortraitEventSource.API.value, + user_id=current_user.id, + project_id=project_id_snapshot, + asset_id=asset_id_snapshot, + exc=exc, + detail={"resource_ids": pending_upload_resource_ids}, + ) return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value) diff --git a/video-gen-api/app/api/v1/private_portrait_virtual.py b/video-gen-api/app/api/v1/private_portrait_virtual.py index 0173158f..4b10142a 100644 --- a/video-gen-api/app/api/v1/private_portrait_virtual.py +++ b/video-gen-api/app/api/v1/private_portrait_virtual.py @@ -1,6 +1,6 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,6 +15,7 @@ from app.enums.private_portrait import ( ) from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject from app.models.user import User +from app.enums.upload_resource import UploadResourceTypeEnum from app.schemas.private_portrait import ( PrivatePortraitAssetCreate, PrivatePortraitAssetListOut, @@ -26,6 +27,7 @@ from app.schemas.private_portrait import ( PrivatePortraitProjectOut, PrivatePortraitProjectUpdate, PrivatePortraitSelectableAssetListOut, + PrivatePortraitUploadOut, PrivatePortraitVirtualProjectCreate, build_private_portrait_enum_meta, ) @@ -46,6 +48,8 @@ from app.services.private_portrait.project_service import ( refresh_project_counters, soft_delete_project, ) +from app.services.private_portrait.upload_service import upload_private_portrait_asset_file +from app.services.upload_resource import cleanup_upload_resource_files_after_commit from app.services.private_portrait.virtual.service import create_virtual_asset, create_virtual_project, update_virtual_project router = APIRouter(tags=["私域虚拟人像素材库"]) @@ -103,6 +107,62 @@ async def get_virtual_private_portrait_enum_meta(): return build_private_portrait_enum_meta() +@router.post( + "/private-portrait/virtual/uploads/image", + response_model=PrivatePortraitUploadOut, + summary="上传虚拟图片素材", + description="上传虚拟图片素材并写入 UploadResource,module=private_portrait_virtual。创建素材时需回传 resource_id 到 upload_resource_id。", +) +async def upload_private_portrait_virtual_image(file: UploadFile = File(...), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + try: + out = await upload_private_portrait_asset_file( + db, + file=file, + current_user=current_user, + library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, + resource_type=UploadResourceTypeEnum.IMAGE.value, + ) + await db.commit() + return out + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise HTTPException(status_code=500, detail=f"上传虚拟图片素材失败: {exc}") + + +@router.post( + "/private-portrait/virtual/uploads/video", + response_model=PrivatePortraitUploadOut, + summary="上传虚拟视频素材", + description="上传虚拟视频素材并写入 UploadResource,module=private_portrait_virtual。创建素材时需回传 resource_id 到 upload_resource_id。", +) +async def upload_private_portrait_virtual_video( + file: UploadFile = File(...), + duration_seconds: float | None = Query(None, description="客户端解析的视频秒数,服务端会写入 UploadResource 并在创建素材时回填"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + out = await upload_private_portrait_asset_file( + db, + file=file, + current_user=current_user, + library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, + resource_type=UploadResourceTypeEnum.VIDEO.value, + duration_seconds=duration_seconds, + ) + await db.commit() + return out + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise HTTPException(status_code=500, detail=f"上传虚拟视频素材失败: {exc}") + + @router.post( "/private-portrait/virtual-projects", response_model=PrivatePortraitProjectOut, @@ -170,6 +230,7 @@ async def update_private_portrait_virtual_project(project_id: str, payload: Priv async def delete_private_portrait_virtual_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value) project_id_snapshot = project.id + pending_upload_resource_ids = list(getattr(project, "_pending_upload_resource_ids", []) or []) await db.commit() try: from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote @@ -178,6 +239,21 @@ async def delete_private_portrait_virtual_project(project_id: str, current_user: _log_task_dispatch_success(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot) except Exception as exc: _log_task_dispatch_failed(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot, exc=exc) + if pending_upload_resource_ids: + try: + await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_upload_resource_ids) + await db.commit() + except Exception as exc: + await db.rollback() + log_operation_error( + domain=DOMAIN, + event_type=PrivatePortraitEventType.ASSET_UPLOAD_RELEASE_FAILED.value, + source=PrivatePortraitEventSource.API.value, + user_id=current_user.id, + project_id=project_id_snapshot, + exc=exc, + detail={"resource_ids": pending_upload_resource_ids}, + ) return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value) @@ -266,6 +342,7 @@ async def delete_private_portrait_virtual_asset(asset_id: str, current_user: Use asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value) asset_id_snapshot = asset.id project_id_snapshot = asset.project_id + pending_upload_resource_ids = list(getattr(asset, "_pending_upload_resource_ids", []) or []) await db.commit() try: from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote @@ -274,6 +351,22 @@ async def delete_private_portrait_virtual_asset(asset_id: str, current_user: Use _log_task_dispatch_success(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot) except Exception as exc: _log_task_dispatch_failed(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot, exc=exc) + if pending_upload_resource_ids: + try: + await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_upload_resource_ids) + await db.commit() + except Exception as exc: + await db.rollback() + log_operation_error( + domain=DOMAIN, + event_type=PrivatePortraitEventType.ASSET_UPLOAD_RELEASE_FAILED.value, + source=PrivatePortraitEventSource.API.value, + user_id=current_user.id, + project_id=project_id_snapshot, + asset_id=asset_id_snapshot, + exc=exc, + detail={"resource_ids": pending_upload_resource_ids}, + ) return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value) diff --git a/video-gen-api/app/api/v1/shot_replicate.py b/video-gen-api/app/api/v1/shot_replicate.py index a53478f9..838c0987 100644 --- a/video-gen-api/app/api/v1/shot_replicate.py +++ b/video-gen-api/app/api/v1/shot_replicate.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, 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.shot_replicate import ( @@ -30,6 +31,8 @@ from app.schemas.shot_replicate import ( ShotReplicateImagePromptUpdateRequest, ShotReanalyzeOut, ShotReanalyzeRequest, + ShotSegmentSplitRetryOut, + ShotSplitRetryRequest, ShotReplicateMaterialUpdateRequest, ShotReplicateSpecOut, ShotReplicateTaskDetailOut, @@ -71,6 +74,7 @@ from app.services.shot_replicate_taskset_service import ( list_task_sets, prepare_reanalyze_segment, prepare_reanalyze_task_set, + prepare_retry_split_segment, segment_detail, task_set_detail, ) @@ -685,6 +689,83 @@ async def reanalyze_segment( 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, diff --git a/video-gen-api/app/enums/private_portrait.py b/video-gen-api/app/enums/private_portrait.py index 87239082..d83ae210 100644 --- a/video-gen-api/app/enums/private_portrait.py +++ b/video-gen-api/app/enums/private_portrait.py @@ -178,6 +178,15 @@ class PrivatePortraitEventType(str, Enum): ASSET_CREATE_START = "ASSET_CREATE_START" ASSET_CREATE_SUCCESS = "ASSET_CREATE_SUCCESS" ASSET_CREATE_FAILED = "ASSET_CREATE_FAILED" + ASSET_UPLOAD_START = "ASSET_UPLOAD_START" + ASSET_UPLOAD_SUCCESS = "ASSET_UPLOAD_SUCCESS" + ASSET_UPLOAD_FAILED = "ASSET_UPLOAD_FAILED" + ASSET_UPLOAD_BIND_START = "ASSET_UPLOAD_BIND_START" + ASSET_UPLOAD_BIND_SUCCESS = "ASSET_UPLOAD_BIND_SUCCESS" + ASSET_UPLOAD_BIND_FAILED = "ASSET_UPLOAD_BIND_FAILED" + ASSET_UPLOAD_RELEASE_START = "ASSET_UPLOAD_RELEASE_START" + ASSET_UPLOAD_RELEASE_SUCCESS = "ASSET_UPLOAD_RELEASE_SUCCESS" + ASSET_UPLOAD_RELEASE_FAILED = "ASSET_UPLOAD_RELEASE_FAILED" ASSET_SYNC_START = "ASSET_SYNC_START" ASSET_SYNC_SUCCESS = "ASSET_SYNC_SUCCESS" diff --git a/video-gen-api/app/enums/shot_replicate.py b/video-gen-api/app/enums/shot_replicate.py index 868f25cc..70316714 100644 --- a/video-gen-api/app/enums/shot_replicate.py +++ b/video-gen-api/app/enums/shot_replicate.py @@ -133,6 +133,10 @@ class ShotReplicateLogEventEnum(StrEnum): SPLIT_BY_AI_SUBMITTED = "SHOT_SPLIT_BY_AI_SUBMITTED" SPLIT_CUSTOM_SUBMITTED = "SHOT_SPLIT_CUSTOM_SUBMITTED" SEGMENT_DELETED = "SHOT_SEGMENT_DELETED" + SEGMENT_SPLIT_RETRY_RECEIVED = "SHOT_SEGMENT_SPLIT_RETRY_RECEIVED" + SEGMENT_SPLIT_RETRY_SUBMITTED = "SHOT_SEGMENT_SPLIT_RETRY_SUBMITTED" + SEGMENT_SPLIT_RETRY_REJECTED = "SHOT_SEGMENT_SPLIT_RETRY_REJECTED" + SEGMENT_SPLIT_RETRY_DISPATCH_FAILED = "SHOT_SEGMENT_SPLIT_RETRY_DISPATCH_FAILED" class ShotReplicateRemoteActionEnum(StrEnum): diff --git a/video-gen-api/app/enums/upload_resource.py b/video-gen-api/app/enums/upload_resource.py index e337dbbe..ed6bbec0 100644 --- a/video-gen-api/app/enums/upload_resource.py +++ b/video-gen-api/app/enums/upload_resource.py @@ -11,6 +11,8 @@ class UploadResourceModuleEnum(StrEnum): HOME_MATERIAL = "home_material" HOT_OPENING_REPLICATE = "hot_opening_replicate" SHOT_REPLICATE = "shot_replicate" + PRIVATE_PORTRAIT_REAL = "private_portrait_real" + PRIVATE_PORTRAIT_VIRTUAL = "private_portrait_virtual" class UploadResourceTypeEnum(StrEnum): @@ -83,6 +85,7 @@ class UploadResourceSourceModelEnum(StrEnum): HOME_MATERIAL_ASSET = "HomeMaterialAsset" SYSTEM_CONFIG = "SystemConfig" OPEN_TYPE = "OpenType" + PRIVATE_PORTRAIT_ASSET = "PrivatePortraitAsset" class UploadResourceEventEnum(StrEnum): @@ -112,6 +115,7 @@ class UploadResourceEventEnum(StrEnum): BIND_SUCCESS = "bind_success" BIND_CONFLICT = "bind_conflict" BIND_SKIPPED = "bind_skipped" + BIND_FAILED = "bind_failed" BACKFILL_START = "backfill_start" BACKFILL_FILE_MATCHED = "backfill_file_matched" @@ -166,6 +170,8 @@ UPLOAD_RESOURCE_MODULE_LABELS: dict[str, str] = { UploadResourceModuleEnum.HOME_MATERIAL.value: "首页素材", UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value: "爆款开头复刻", UploadResourceModuleEnum.SHOT_REPLICATE.value: "拆镜复刻", + UploadResourceModuleEnum.PRIVATE_PORTRAIT_REAL.value: "私域真人素材", + UploadResourceModuleEnum.PRIVATE_PORTRAIT_VIRTUAL.value: "私域虚拟素材", } UPLOAD_RESOURCE_TYPE_LABELS: dict[str, str] = { diff --git a/video-gen-api/app/schemas/private_portrait.py b/video-gen-api/app/schemas/private_portrait.py index d05e6c16..4fb936bf 100644 --- a/video-gen-api/app/schemas/private_portrait.py +++ b/video-gen-api/app/schemas/private_portrait.py @@ -241,12 +241,27 @@ class PrivatePortraitAssetGroupOut(BaseModel): model_config = {"from_attributes": True} +class PrivatePortraitUploadOut(BaseModel): + url: str = Field(..., description="上传后的本地资源 URL,可直接用于创建私域素材") + filename: str = Field(..., description="原始文件名或安全文件名") + type: str = Field(..., description="资源类型:image/video") + module: str = Field(..., description="上传模块:private_portrait_real/private_portrait_virtual") + resource_id: str = Field(..., description="UploadResource.id。创建素材时必须作为 upload_resource_id 传回后端绑定素材") + file_size_bytes: int = Field(0, description="文件大小,单位字节") + duration_seconds: float | None = Field(None, description="视频素材秒数,图片为空") + + class PrivatePortraitAssetCreate(BaseModel): url: str = Field( ..., min_length=1, description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换为公网地址后调用火山 CreateAsset。", ) + upload_resource_id: str | None = Field( + None, + max_length=32, + description="可选但新客户端必须传:真人/虚拟专用上传接口返回的 UploadResource.id。后端 CreateAsset 成功后会绑定到 PrivatePortraitAsset。", + ) asset_type: str = Field( default=PrivatePortraitAssetType.IMAGE.value, description="素材类型枚举:Image=图片,Video=视频,Audio=音频。当前业务仅开放 Image / Video,Audio 会被拒绝。", @@ -273,10 +288,10 @@ class PrivatePortraitAssetCreate(BaseModel): @model_validator(mode="after") def validate_video_duration(self) -> "PrivatePortraitAssetCreate": - if self.asset_type == PrivatePortraitAssetType.VIDEO.value: + # 允许新客户端只传 upload_resource_id,服务层会优先从 UploadResource.duration_seconds 回填秒数。 + # 如果前端已传 video_duration,则这里先做范围校验,避免无效视频进入远端入库。 + if self.asset_type == PrivatePortraitAssetType.VIDEO.value and self.video_duration is not None: duration = self.video_duration - if duration is None: - raise ValueError("Video 素材必须提供 video_duration") if duration < PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS: raise ValueError(f"视频素材最短不能少于 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS} 秒") if duration > PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS: diff --git a/video-gen-api/app/schemas/shot_replicate.py b/video-gen-api/app/schemas/shot_replicate.py index 26ac430e..ed370315 100644 --- a/video-gen-api/app/schemas/shot_replicate.py +++ b/video-gen-api/app/schemas/shot_replicate.py @@ -740,6 +740,27 @@ class ShotReanalyzeOut(BaseModel): analysis_status: str = Field(..., description="重置后的分析状态") celery_task_name: str = Field(..., description="已投递或待投递的 Celery 任务名") +class ShotSplitRetryRequest(BaseModel): + force: bool = Field(False, description="是否强制重置;当前只用于兼容入参,已完成切片仍会拒绝重切,避免旧文件覆盖和资源释放复杂化") + reason: str | None = Field(None, max_length=200, description="切片失败重试原因,会写入模块日志") + + @field_validator("reason", mode="before") + @classmethod + def _strip_reason(cls, value: str | None) -> str | None: + if value is None: + return None + value = str(value).strip() + return value or None + + +class ShotSegmentSplitRetryOut(BaseModel): + message: str = Field(..., description="操作结果提示") + task_set_id: str = Field(..., description="拆镜总任务集ID") + segment_id: str = Field(..., description="拆镜片段ID") + split_status: str = Field(..., description="重置后的切片状态") + celery_task_name: str = Field(..., description="已投递或待投递的 Celery 任务名") + + class ShotSplitByAIOut(BaseModel): task_set_id: str = Field(..., description="拆镜总任务集ID") status: str = Field(..., description="总任务状态:pending_analysis/analyzing/analysis_completed/analysis_failed/splitting/split_completed/partial_failed/failed/deleted") diff --git a/video-gen-api/app/schemas/upload_resource.py b/video-gen-api/app/schemas/upload_resource.py index e06fe1c5..35cfb4e7 100644 --- a/video-gen-api/app/schemas/upload_resource.py +++ b/video-gen-api/app/schemas/upload_resource.py @@ -65,7 +65,7 @@ class UploadResourceHistoryItemOut(BaseModel): history_source: Literal["upload_resource"] = Field("upload_resource", description="素材云历史来源固定为 upload_resource") history_source_label: str = Field("历史上传素材", description="素材云历史来源中文名称") - module: str = Field(..., description="上传资源所属模块:common=普通上传,hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻") + module: str = Field(..., description="上传资源所属模块:common=普通上传,hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻,private_portrait_real=私域真人素材,private_portrait_virtual=私域虚拟素材") module_label: str = Field(..., description="上传资源所属模块中文名称") resource_type: Literal["image", "video", "audio"] = Field(..., description="资源类型:image=图片,video=视频,audio=音频") resource_type_label: str = Field(..., description="资源类型中文名称") @@ -182,4 +182,6 @@ UPLOAD_RESOURCE_HISTORY_ALLOWED_MODULES = { UploadResourceModuleEnum.COMMON.value, UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value, UploadResourceModuleEnum.SHOT_REPLICATE.value, + UploadResourceModuleEnum.PRIVATE_PORTRAIT_REAL.value, + UploadResourceModuleEnum.PRIVATE_PORTRAIT_VIRTUAL.value, } diff --git a/video-gen-api/app/services/private_portrait/asset_service.py b/video-gen-api/app/services/private_portrait/asset_service.py index 2027bcb8..b7b72ab9 100644 --- a/video-gen-api/app/services/private_portrait/asset_service.py +++ b/video-gen-api/app/services/private_portrait/asset_service.py @@ -6,7 +6,7 @@ from typing import Any from urllib.parse import urlencode from fastapi import HTTPException -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -31,11 +31,22 @@ from app.enums.private_portrait import ( PrivatePortraitRemoteDeleteStatus, PrivatePortraitValidateSessionStatus, ) +from app.enums.upload_resource import ( + UploadResourceBindStatusEnum, + UploadResourceDeletePolicyEnum, + UploadResourceModuleEnum, + UploadResourceSourceModelEnum, + UploadResourceTypeEnum, +) from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession +from app.models.upload_resource import UploadResource from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut from app.services.operation_log_service import log_operation_error, log_operation_event from app.services.private_portrait.ark_client import ArkPrivateAssetClient from app.services.private_portrait.project_service import get_user_project, refresh_project_counters +from app.services.private_portrait.upload_service import private_portrait_upload_module +from app.services.upload_resource import bind_upload_resources, release_upload_resources_by_source +from app.services.upload_resource.path_resolver import upload_url_to_storage_path from app.services.private_portrait.quota_service import ( count_user_counting_assets, ensure_private_portrait_asset_quota_available, @@ -133,6 +144,78 @@ def _assert_private_asset_video_duration(payload: PrivatePortraitAssetCreate) -> raise HTTPException(status_code=400, detail=f"视频素材最长不能超过 {PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS} 秒") +def _resource_type_for_asset_type(asset_type: str) -> str: + if asset_type == PrivatePortraitAssetType.VIDEO.value: + return UploadResourceTypeEnum.VIDEO.value + return UploadResourceTypeEnum.IMAGE.value + + +def _safe_set_payload_attr(payload: PrivatePortraitAssetCreate, name: str, value: Any) -> None: + if value is None: + return + try: + setattr(payload, name, value) + except Exception: + pass + + +async def _resolve_upload_resource_for_asset( + db: AsyncSession, + *, + user_id: str, + payload: PrivatePortraitAssetCreate, + module: str, +) -> UploadResource | None: + """定位并校验待绑定的 UploadResource。 + + 新客户端必须传 upload_resource_id;旧客户端没传时按 url 反查 storage_path 兼容。 + 不通过 relationship 懒加载,全部按 ID/路径批查,避免 commit 后 ORM 失效风险。 + """ + resource_id = str(payload.upload_resource_id or "").strip() or None + storage_path = upload_url_to_storage_path(payload.url) + if not resource_id and not storage_path: + return None + + filters = [ + UploadResource.user_id == user_id, + UploadResource.deleted_at.is_(None), + ] + if resource_id and storage_path: + filters.append(or_(UploadResource.id == resource_id, UploadResource.storage_path == storage_path)) + elif resource_id: + filters.append(UploadResource.id == resource_id) + else: + filters.append(UploadResource.storage_path == storage_path) + + result = await db.execute(select(UploadResource).where(*filters).with_for_update().limit(1)) + resource = result.scalar_one_or_none() + if not resource: + if resource_id: + raise HTTPException(status_code=404, detail="上传资源不存在或不属于当前用户") + return None + + if resource.bind_status != UploadResourceBindStatusEnum.PENDING.value or resource.source_id or resource.source_model: + raise HTTPException(status_code=409, detail="上传资源已绑定其他素材,不能重复使用") + if resource.delete_policy != UploadResourceDeletePolicyEnum.USER_DELETABLE.value: + raise HTTPException(status_code=409, detail="上传资源当前不允许绑定私域素材") + + expected_type = _resource_type_for_asset_type(payload.asset_type) + if resource.resource_type != expected_type: + raise HTTPException(status_code=400, detail="上传资源类型与素材类型不一致") + + if resource.module not in {module, UploadResourceModuleEnum.COMMON.value}: + raise HTTPException(status_code=409, detail="上传资源所属模块不匹配,请重新上传素材") + + if payload.asset_type == PrivatePortraitAssetType.VIDEO.value and payload.video_duration is None and resource.duration_seconds is not None: + _safe_set_payload_attr(payload, "video_duration", float(resource.duration_seconds)) + if payload.file_size is None and resource.file_size_bytes is not None: + _safe_set_payload_attr(payload, "file_size", int(resource.file_size_bytes or 0)) + if payload.mime_type is None and resource.mime_type: + _safe_set_payload_attr(payload, "mime_type", resource.mime_type) + + return resource + + def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut: return PrivatePortraitValidateSessionOut( id=session.id, @@ -417,11 +500,14 @@ async def create_asset( library_type: str | None = None, ) -> PrivatePortraitAsset: _assert_enabled_asset_type(payload.asset_type) - _assert_private_asset_video_duration(payload) project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type) if project.status != PrivatePortraitProjectStatus.ACTIVE.value: raise HTTPException(status_code=400, detail="项目未激活,不能上传素材") + module = private_portrait_upload_module(project.library_type) + upload_resource = await _resolve_upload_resource_for_asset(db, user_id=user_id, payload=payload, module=module) + _assert_private_asset_video_duration(payload) + limit, current_count = await ensure_private_portrait_asset_quota_available(db, user_id=user_id, project_id=project_id, library_type=project.library_type, asset_type=payload.asset_type) group = await get_project_active_group(db, user_id=user_id, project_id=project.id, library_type=project.library_type) public_url = _public_url(payload.url) @@ -445,7 +531,7 @@ async def create_asset( ) db.add(asset) await db.flush() - log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"asset_limit": limit, "used_asset_count": current_count, "library_type": project.library_type, "asset_type": payload.asset_type, "remote_project_name": project.remote_project_name}) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"asset_limit": limit, "used_asset_count": current_count, "library_type": project.library_type, "asset_type": payload.asset_type, "remote_project_name": project.remote_project_name, "upload_resource_id": upload_resource.id if upload_resource else payload.upload_resource_id}) try: remote_resp = await ArkPrivateAssetClient().create_asset(project_name=project.remote_project_name, group_id=group.remote_group_id, url=public_url, asset_type=payload.asset_type, name=payload.name) remote_asset_id = remote_resp.get("Id") or remote_resp.get("AssetId") or remote_resp.get("assetId") @@ -456,6 +542,28 @@ async def create_asset( asset.status = PrivatePortraitAssetStatus.PROCESSING.value asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type)) asset.raw_response_json = _json(remote_resp) + bind_stats = await bind_upload_resources( + db, + user_id=user_id, + module=module, + source_model=UploadResourceSourceModelEnum.PRIVATE_PORTRAIT_ASSET.value, + source_id=asset.id, + resource_ids=[payload.upload_resource_id, upload_resource.id if upload_resource else None], + urls=[payload.url], + allow_common_migrate=True, + ) + if bind_stats.get("bound"): + log_operation_event( + domain=DOMAIN, + event_type=PrivatePortraitEventType.ASSET_UPLOAD_BIND_SUCCESS.value, + event_status=PrivatePortraitEventStatus.SUCCESS.value, + source=PrivatePortraitEventSource.API.value, + user_id=user_id, + project_id=project.id, + group_id=group.id, + asset_id=asset.id, + detail={"module": module, "upload_resource_id": payload.upload_resource_id, "bind_stats": bind_stats}, + ) await refresh_project_counters(db, [project.id]) await db.flush() await db.refresh(asset) @@ -465,7 +573,7 @@ async def create_asset( asset.status = PrivatePortraitAssetStatus.FAILED.value asset.error_message = _exception_message(exc) await db.flush() - log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, exc=exc) + log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, exc=exc, detail={"upload_resource_id": payload.upload_resource_id, "module": module}) raise @@ -598,10 +706,19 @@ async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str, li asset.deleted_at = now asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.PENDING.value + module = private_portrait_upload_module(asset.library_type) + upload_release = await release_upload_resources_by_source( + db, + source_model=UploadResourceSourceModelEnum.PRIVATE_PORTRAIT_ASSET.value, + source_ids=[asset.id], + module=module, + ) + setattr(asset, "_pending_upload_resource_ids", list(upload_release.get("released_resource_ids") or [])) + setattr(asset, "_upload_resource_release", upload_release) await refresh_project_counters(db, [asset.project_id]) await db.flush() await db.refresh(asset) - log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type}) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type, "upload_resource_release": {k: v for k, v in getattr(asset, "_upload_resource_release", {}).items() if k != "released_resource_ids"}, "pending_upload_resource_count": len(getattr(asset, "_pending_upload_resource_ids", []))}) return asset @@ -617,7 +734,7 @@ async def delete_asset_remote(db: AsyncSession, *, asset_id: str) -> None: log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, message="远程删除跳过:素材没有 remote_asset_id") return now = datetime.now(timezone.utc) - log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type}) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type, "upload_resource_release": {k: v for k, v in getattr(asset, "_upload_resource_release", {}).items() if k != "released_resource_ids"}, "pending_upload_resource_count": len(getattr(asset, "_pending_upload_resource_ids", []))}) try: await ArkPrivateAssetClient(for_celery=True).delete_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id) asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value diff --git a/video-gen-api/app/services/private_portrait/project_service.py b/video-gen-api/app/services/private_portrait/project_service.py index f40086fe..9aecccae 100644 --- a/video-gen-api/app/services/private_portrait/project_service.py +++ b/video-gen-api/app/services/private_portrait/project_service.py @@ -19,9 +19,12 @@ from app.enums.private_portrait import ( PrivatePortraitProjectStatus, PrivatePortraitRemoteDeleteStatus, ) +from app.enums.upload_resource import UploadResourceSourceModelEnum from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject from app.schemas.private_portrait import PrivatePortraitProjectCreate, PrivatePortraitProjectOut, PrivatePortraitProjectUpdate from app.services.operation_log_service import log_operation_event +from app.services.private_portrait.upload_service import private_portrait_upload_module +from app.services.upload_resource import release_upload_resources_by_source from app.utils.id_gen import generate_id DOMAIN = "private_portrait" @@ -264,8 +267,29 @@ async def soft_delete_project( ) -> PrivatePortraitProject: project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type) now = datetime.now(timezone.utc) + asset_id_rows = await db.execute( + select(PrivatePortraitAsset.id) + .where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None)) + ) + asset_ids = [row[0] for row in asset_id_rows.all()] + module = private_portrait_upload_module(project.library_type) + project.deleted_at = now project.status = PrivatePortraitProjectStatus.DELETED.value + upload_release = await release_upload_resources_by_source( + db, + source_model=UploadResourceSourceModelEnum.PRIVATE_PORTRAIT_ASSET.value, + source_ids=asset_ids, + module=module, + ) if asset_ids else { + "matched": 0, + "released": 0, + "already_deleted": 0, + "released_resource_ids": [], + } + setattr(project, "_pending_upload_resource_ids", list(upload_release.get("released_resource_ids") or [])) + setattr(project, "_upload_resource_release", upload_release) + await db.execute( update(PrivatePortraitAsset) .where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None)) @@ -285,6 +309,6 @@ async def soft_delete_project( user_id=user_id, project_id=project.id, message="本地软删私域人像素材项目", - detail={"library_type": project.library_type, "remote_project_name": project.remote_project_name}, + detail={"library_type": project.library_type, "remote_project_name": project.remote_project_name, "asset_count": len(asset_ids), "upload_resource_release": {k: v for k, v in upload_release.items() if k != "released_resource_ids"}, "pending_upload_resource_count": len(getattr(project, "_pending_upload_resource_ids", []))}, ) return project diff --git a/video-gen-api/app/services/private_portrait/upload_service.py b/video-gen-api/app/services/private_portrait/upload_service.py new file mode 100644 index 00000000..5d8fddd0 --- /dev/null +++ b/video-gen-api/app/services/private_portrait/upload_service.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from fastapi import HTTPException, UploadFile +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.private_portrait import ( + PrivatePortraitEventSource, + PrivatePortraitEventStatus, + PrivatePortraitEventType, + PrivatePortraitLibraryType, +) +from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceTypeEnum +from app.models.user import User +from app.schemas.private_portrait import PrivatePortraitUploadOut +from app.services.operation_log_service import log_operation_error, log_operation_event +from app.services.upload_resource import upload_reference_file + +DOMAIN = "private_portrait" +PRIVATE_PORTRAIT_IMAGE_MAX_BYTES = 10 * 1024 * 1024 +PRIVATE_PORTRAIT_VIDEO_MAX_BYTES = 100 * 1024 * 1024 + + +def private_portrait_upload_module(library_type: str) -> str: + if library_type == PrivatePortraitLibraryType.REAL_PERSON.value: + return UploadResourceModuleEnum.PRIVATE_PORTRAIT_REAL.value + if library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value: + return UploadResourceModuleEnum.PRIVATE_PORTRAIT_VIRTUAL.value + raise HTTPException(status_code=400, detail="素材库类型不支持") + + +async def upload_private_portrait_asset_file( + db: AsyncSession, + *, + file: UploadFile, + current_user: User, + library_type: str, + resource_type: str, + duration_seconds: float | None = None, +) -> PrivatePortraitUploadOut: + if resource_type not in {UploadResourceTypeEnum.IMAGE.value, UploadResourceTypeEnum.VIDEO.value}: + raise HTTPException(status_code=400, detail="私域素材上传仅支持图片或视频") + + module = private_portrait_upload_module(library_type) + max_bytes = PRIVATE_PORTRAIT_VIDEO_MAX_BYTES if resource_type == UploadResourceTypeEnum.VIDEO.value else PRIVATE_PORTRAIT_IMAGE_MAX_BYTES + + log_operation_event( + domain=DOMAIN, + event_type=PrivatePortraitEventType.ASSET_UPLOAD_START.value, + event_status=PrivatePortraitEventStatus.PENDING.value, + source=PrivatePortraitEventSource.API.value, + user_id=current_user.id, + detail={ + "module": module, + "library_type": library_type, + "resource_type": resource_type, + "filename": file.filename, + "content_type": file.content_type, + "duration_seconds": duration_seconds, + }, + ) + try: + result = await upload_reference_file( + db, + file=file, + current_user=current_user, + module=module, + resource_type=resource_type, + gen_type="private_portrait", + duration_seconds=duration_seconds, + max_bytes=max_bytes, + ) + out = PrivatePortraitUploadOut( + url=result.url, + filename=result.filename, + type=result.resource_type, + module=result.module, + resource_id=result.resource_id, + file_size_bytes=result.file_size_bytes, + duration_seconds=result.duration_seconds, + ) + log_operation_event( + domain=DOMAIN, + event_type=PrivatePortraitEventType.ASSET_UPLOAD_SUCCESS.value, + event_status=PrivatePortraitEventStatus.SUCCESS.value, + source=PrivatePortraitEventSource.API.value, + user_id=current_user.id, + detail={ + "module": module, + "library_type": library_type, + "resource_type": resource_type, + "resource_id": result.resource_id, + "url": result.url, + "file_size_bytes": result.file_size_bytes, + "duration_seconds": result.duration_seconds, + }, + ) + return out + except Exception as exc: # noqa: BLE001 + log_operation_error( + domain=DOMAIN, + event_type=PrivatePortraitEventType.ASSET_UPLOAD_FAILED.value, + source=PrivatePortraitEventSource.API.value, + user_id=current_user.id, + exc=exc, + detail={ + "module": module, + "library_type": library_type, + "resource_type": resource_type, + "filename": file.filename, + }, + ) + raise diff --git a/video-gen-api/app/services/shot_replicate_taskset_service.py b/video-gen-api/app/services/shot_replicate_taskset_service.py index 1ef4b4d2..1ee033ee 100644 --- a/video-gen-api/app/services/shot_replicate_taskset_service.py +++ b/video-gen-api/app/services/shot_replicate_taskset_service.py @@ -2,6 +2,7 @@ from __future__ import annotations import uuid from datetime import datetime, timezone +from pathlib import Path from typing import Any from fastapi import HTTPException @@ -29,6 +30,7 @@ from app.schemas.shot_replicate import ( ShotSegmentDeleteOut, ShotSegmentDetailOut, ShotSegmentListOut, + ShotSegmentSplitRetryOut, ShotReanalyzeOut, ShotSegmentOut, ShotSplitByAIOut, @@ -500,6 +502,97 @@ async def create_segments_by_ai( ) +async def prepare_retry_split_segment( + db: AsyncSession, + *, + current_user: User, + segment_id: str, + force: bool = False, + reason: str | None = None, +) -> ShotSegmentSplitRetryOut: + """重置失败切片片段,commit 成功后由 API 投递现有 split_one_segment 任务。""" + segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True) + task_set = await get_task_set_for_user(db, task_set_id=segment.task_set_id, user=current_user, for_update=True) + from_split_status = segment.split_status + allowed = {ShotSplitStatusEnum.FAILED.value, ShotSplitStatusEnum.RETRY_WAITING.value} + + reject_reason: str | None = None + if task_set.deleted_at is not None or task_set.status == ShotTaskSetStatusEnum.DELETED.value: + reject_reason = "拆镜总任务集已删除,不能重试切片" + elif segment.deleted_at is not None: + reject_reason = "拆镜片段已删除,不能重试切片" + elif from_split_status == ShotSplitStatusEnum.PROCESSING.value: + reject_reason = "拆镜片段正在切片处理中,不能重复投递" + elif from_split_status == ShotSplitStatusEnum.COMPLETED.value: + reject_reason = "拆镜片段已切片完成,不支持重切,避免旧切片资源覆盖" + elif from_split_status not in allowed and not force: + reject_reason = "仅允许失败或等待重试的切片片段重新投递" + elif not task_set.video_path: + reject_reason = "原视频本地路径为空,不能重试切片" + elif not Path(str(task_set.video_path)).exists(): + reject_reason = "原视频本地文件不存在,不能重试切片" + + if reject_reason: + log_module_event_file( + module=MODULE, + event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_REJECTED.value, + project_id=task_set.id, + step_id=segment.id, + status="rejected", + message=reject_reason, + detail={ + "segment_id": segment.id, + "task_set_id": task_set.id, + "from_split_status": from_split_status, + "force": force, + "reason": reason, + }, + ) + raise HTTPException(status_code=400, detail=reject_reason) + + now = _now() + segment.split_status = ShotSplitStatusEnum.PENDING.value + segment.split_enqueued_at = now + segment.split_started_at = None + segment.split_lease_until = None + segment.split_next_retry_at = None + segment.split_retry_count = 0 + segment.split_last_error = None + segment.split_celery_task_id = f"shot-split:{uuid.uuid4().hex}" + task_set.split_error_message = None + + await refresh_task_set_split_summary(db, task_set.id) + await db.flush() + + log_module_event_file( + module=MODULE, + event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_RECEIVED.value, + project_id=task_set.id, + step_id=segment.id, + status="pending", + message="拆镜片段切片失败重试已重置,等待投递 Celery", + detail={ + "segment_id": segment.id, + "task_set_id": task_set.id, + "from_split_status": from_split_status, + "to_split_status": segment.split_status, + "force": force, + "reason": reason, + "source_path": task_set.video_path, + "celery_task_name": "shot_replicate.split_one_segment", + "queue": "gen_result_download", + }, + ) + + return ShotSegmentSplitRetryOut( + message="切片重试已提交,正在重新切割视频片段", + task_set_id=task_set.id, + segment_id=segment.id, + split_status=segment.split_status, + celery_task_name="shot_replicate.split_one_segment", + ) + + async def create_custom_segment( db: AsyncSession, *, diff --git a/video-gen-api/app/services/upload_resource/bind_service.py b/video-gen-api/app/services/upload_resource/bind_service.py index 884e3a51..074afc26 100644 --- a/video-gen-api/app/services/upload_resource/bind_service.py +++ b/video-gen-api/app/services/upload_resource/bind_service.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime, timezone +from pathlib import Path from typing import Any, Iterable from sqlalchemy import select diff --git a/video-gen-api/app/services/upload_resource/path_resolver.py b/video-gen-api/app/services/upload_resource/path_resolver.py index 22aeda7c..8d713d9e 100644 --- a/video-gen-api/app/services/upload_resource/path_resolver.py +++ b/video-gen-api/app/services/upload_resource/path_resolver.py @@ -15,7 +15,7 @@ from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceTy COMMON_IMAGE_RE = re.compile(r"^images/(?P\d{4})/(?P\d{2})/(?P\d{2})/video_img_(?P[^_]+)_(?P\d{8})_(?P\d{6})_(?P[0-9a-fA-F]{8})\.(?P[^/]+)$") COMMON_VIDEO_RE = re.compile(r"^videos/(?P\d{4})/(?P\d{2})/(?P\d{2})/video_ref_(?P[^_]+)_(?P\d{8})_(?P\d{6})_(?P[0-9a-fA-F]{8})\.(?P[^/]+)$") COMMON_AUDIO_RE = re.compile(r"^audios/(?P\d{4})/(?P\d{2})/(?P\d{2})/audio_ref_(?P[^_]+)_(?P\d{8})_(?P\d{6})_(?P[0-9a-fA-F]{8})\.(?P[^/]+)$") -MODULE_RE = re.compile(r"^(?Phot_opening_replicate|shot_replicate)/(?Pimages|videos)/(?P\d{4})/(?P\d{2})/(?P\d{2})/(?Pvideo_img|video_ref)_(?P[^_]+)_(?P\d{8})_(?P\d{6})_(?P[0-9a-fA-F]{8})\.(?P[^/]+)$") +MODULE_RE = re.compile(r"^(?Phot_opening_replicate|shot_replicate|private_portrait_real|private_portrait_virtual)/(?Pimages|videos)/(?P\d{4})/(?P\d{2})/(?P\d{2})/(?Pvideo_img|video_ref)_(?P[^_]+)_(?P\d{8})_(?P\d{6})_(?P[0-9a-fA-F]{8})\.(?P[^/]+)$") SHOT_SEGMENT_RE = re.compile(r"^shot_segments/(?P\d{4})/(?P\d{2})/(?P\d{2})/(?P[^/]+)\.mp4$") LEGACY_GEN_RE = re.compile(r"^(?Pimages|videos)/gen_(?P[^_]+)_(?P[0-9a-zA-Z]+)\.(?P[^/]+)$") ADMIN_UPLOAD_RE = re.compile(r"^admin_uploads/(?Psystem_logo|system_pdf|open_type_thumb|common)/(?Pimages|videos|audios|files)/(?P\d{4})/(?P\d{2})/(?P\d{2})/(?Padmin_img|admin_video|admin_audio|admin_pdf|admin_file)_(?P[^_]+)_(?P\d{8})_(?P\d{6})_(?P[0-9a-fA-F]{8})\.(?P[^/]+)$") diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts index fd3410e6..8617c4f8 100644 --- a/video-gen-app/src/api/index.ts +++ b/video-gen-app/src/api/index.ts @@ -165,6 +165,43 @@ export async function uploadVideo(file: File, durationSeconds?: number): Promise return await res.json(); } +function privatePortraitUploadEndpoint(path: string, durationSeconds?: number): string { + const base = `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api${path}`; + const query = typeof durationSeconds === 'number' && durationSeconds > 0 + ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` + : ''; + return `${base}${query}`; +} + +async function uploadPrivatePortraitFile(path: string, file: File, durationSeconds?: number, errorMessage = '素材上传失败'): Promise { + const form = new FormData(); + form.append('file', file); + const token = localStorage.getItem('auth_token'); + const res = await fetch(privatePortraitUploadEndpoint(path, durationSeconds), { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: form, + }); + if (!res.ok) throw new Error(errorMessage); + return await res.json(); +} + +export async function uploadPrivatePortraitImage(file: File): Promise { + return uploadPrivatePortraitFile('/private-portrait/uploads/image', file, undefined, '真人图片素材上传失败'); +} + +export async function uploadPrivatePortraitVideo(file: File, durationSeconds?: number): Promise { + return uploadPrivatePortraitFile('/private-portrait/uploads/video', file, durationSeconds, '真人视频素材上传失败'); +} + +export async function uploadPrivatePortraitVirtualImage(file: File): Promise { + return uploadPrivatePortraitFile('/private-portrait/virtual/uploads/image', file, undefined, '虚拟图片素材上传失败'); +} + +export async function uploadPrivatePortraitVirtualVideo(file: File, durationSeconds?: number): Promise { + return uploadPrivatePortraitFile('/private-portrait/virtual/uploads/video', file, durationSeconds, '虚拟视频素材上传失败'); +} + export async function uploadHotOpeningImage(file: File): Promise { const form = new FormData(); form.append('file', file); @@ -957,7 +994,7 @@ export async function getPrivatePortraitValidateSession(sessionId: string): Prom return api.get(`/private-portrait/validate-sessions/${sessionId}`); } -export async function createPrivatePortraitAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null }): Promise { +export async function createPrivatePortraitAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null; uploadResourceId?: string | null }): Promise { return api.post(`/private-portrait/projects/${projectId}/assets`, { url: payload.url, asset_type: payload.assetType || 'Image', @@ -966,6 +1003,7 @@ export async function createPrivatePortraitAsset(projectId: string, payload: { u video_cover_url: payload.videoCoverUrl || null, file_size: payload.fileSize ?? null, mime_type: payload.mimeType || null, + upload_resource_id: payload.uploadResourceId || null, }); } @@ -1027,7 +1065,7 @@ export async function deletePrivatePortraitVirtualProject(projectId: string): Pr await api.delete(`/private-portrait/virtual-projects/${projectId}`); } -export async function createPrivatePortraitVirtualAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null }): Promise { +export async function createPrivatePortraitVirtualAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null; uploadResourceId?: string | null }): Promise { return api.post(`/private-portrait/virtual-projects/${projectId}/assets`, { url: payload.url, asset_type: payload.assetType || 'Image', @@ -1036,6 +1074,7 @@ export async function createPrivatePortraitVirtualAsset(projectId: string, paylo video_cover_url: payload.videoCoverUrl || null, file_size: payload.fileSize ?? null, mime_type: payload.mimeType || null, + upload_resource_id: payload.uploadResourceId || null, }); } diff --git a/video-gen-app/src/components/privatePortrait/library/AssetUpload.tsx b/video-gen-app/src/components/privatePortrait/library/AssetUpload.tsx index 131e9363..7a5e05da 100644 --- a/video-gen-app/src/components/privatePortrait/library/AssetUpload.tsx +++ b/video-gen-app/src/components/privatePortrait/library/AssetUpload.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { Button, Input, Modal, Space, Upload, message } from 'antd'; import { UploadOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload/interface'; -import { createPrivatePortraitAsset, uploadImage, uploadVideo } from '../../../api'; +import { createPrivatePortraitAsset, uploadPrivatePortraitImage, uploadPrivatePortraitVideo } from '../../../api'; const MIN_PRIVATE_VIDEO_DURATION = 2; const MAX_PRIVATE_VIDEO_DURATION = 15; @@ -89,14 +89,15 @@ const PrivatePortraitAssetUpload: React.FC = ({ projectId, open, onClose, return; } - const uploaded = assetType === 'Video' ? await uploadVideo(file) : await uploadImage(file); + const uploaded = assetType === 'Video' ? await uploadPrivatePortraitVideo(file, videoDuration || undefined) : await uploadPrivatePortraitImage(file); await createPrivatePortraitAsset(projectId, { url: uploaded.url, assetType, name: name.trim() || file.name, - videoDuration, - fileSize: file.size, + videoDuration: uploaded.duration_seconds ?? videoDuration, + fileSize: uploaded.file_size_bytes ?? file.size, mimeType: file.type || null, + uploadResourceId: uploaded.resource_id || null, }); message.success(assetType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中'); reset(); diff --git a/video-gen-app/src/components/privatePortrait/library/VirtualMaterialPanel.tsx b/video-gen-app/src/components/privatePortrait/library/VirtualMaterialPanel.tsx index a61d037d..78531d5a 100644 --- a/video-gen-app/src/components/privatePortrait/library/VirtualMaterialPanel.tsx +++ b/video-gen-app/src/components/privatePortrait/library/VirtualMaterialPanel.tsx @@ -35,8 +35,8 @@ import { deletePrivatePortraitVirtualProject, getPrivatePortraitVirtualAssets, getPrivatePortraitVirtualProjects, - uploadImage, - uploadVideo, + uploadPrivatePortraitVirtualImage, + uploadPrivatePortraitVirtualVideo, } from '../../../api'; import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types'; @@ -276,14 +276,15 @@ const VirtualMaterialPanel: React.FC = () => { if (currentType === 'Video' && !validatePrivateVideoDuration(duration, message.error)) { return; } - const uploaded = currentType === 'Video' ? await uploadVideo(file) : await uploadImage(file); + const uploaded = currentType === 'Video' ? await uploadPrivatePortraitVirtualVideo(file, duration || undefined) : await uploadPrivatePortraitVirtualImage(file); await createPrivatePortraitVirtualAsset(selectedProjectId, { url: uploaded.url, assetType: currentType, name: assetName.trim() || file.name, - videoDuration: duration, - fileSize: file.size, + videoDuration: uploaded.duration_seconds ?? duration, + fileSize: uploaded.file_size_bytes ?? file.size, mimeType: file.type || null, + uploadResourceId: uploaded.resource_id || null, }); message.success(currentType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中'); setUploadOpen(false);