拆镜复刻片段重试切片API | 真人/虚拟素材库上传API

This commit is contained in:
2026-07-09 12:07:28 +08:00
parent c05a52498e
commit 2b996e114e
18 changed files with 737 additions and 25 deletions
+94 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from urllib.parse import urlencode, unquote 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 fastapi.responses import RedirectResponse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
from app.models.user import User from app.models.user import User
from app.enums.upload_resource import UploadResourceTypeEnum
from app.schemas.private_portrait import ( from app.schemas.private_portrait import (
PrivatePortraitAssetCreate, PrivatePortraitAssetCreate,
PrivatePortraitAssetListOut, PrivatePortraitAssetListOut,
@@ -31,6 +32,7 @@ from app.schemas.private_portrait import (
PrivatePortraitProjectOut, PrivatePortraitProjectOut,
PrivatePortraitProjectUpdate, PrivatePortraitProjectUpdate,
PrivatePortraitSelectableAssetListOut, PrivatePortraitSelectableAssetListOut,
PrivatePortraitUploadOut,
PrivatePortraitValidateSessionCreate, PrivatePortraitValidateSessionCreate,
PrivatePortraitValidateSessionOut, PrivatePortraitValidateSessionOut,
build_private_portrait_enum_meta, build_private_portrait_enum_meta,
@@ -55,6 +57,8 @@ from app.services.private_portrait.project_service import (
refresh_project_counters, refresh_project_counters,
soft_delete_project, 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 ( from app.services.private_portrait.real_person.service import (
create_real_person_asset, create_real_person_asset,
create_real_person_project, create_real_person_project,
@@ -124,6 +128,62 @@ async def get_private_portrait_enum_meta():
return build_private_portrait_enum_meta() return build_private_portrait_enum_meta()
@router.post(
"/private-portrait/uploads/image",
response_model=PrivatePortraitUploadOut,
summary="上传真人图片素材",
description="上传真人图片素材并写入 UploadResourcemodule=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="上传真人视频素材并写入 UploadResourcemodule=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( @router.post(
"/private-portrait/projects", "/private-portrait/projects",
response_model=PrivatePortraitProjectCreateWithValidateOut, 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)): 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 = 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 project_id_snapshot = project.id
pending_upload_resource_ids = list(getattr(project, "_pending_upload_resource_ids", []) or [])
await db.commit() await db.commit()
try: try:
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote 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) _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: 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) _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) 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 = 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 asset_id_snapshot = asset.id
project_id_snapshot = asset.project_id project_id_snapshot = asset.project_id
pending_upload_resource_ids = list(getattr(asset, "_pending_upload_resource_ids", []) or [])
await db.commit() await db.commit()
try: try:
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote 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) _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: 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) _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) return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@@ -1,6 +1,6 @@
from __future__ import annotations 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 import select
from sqlalchemy.ext.asyncio import AsyncSession 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.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
from app.models.user import User from app.models.user import User
from app.enums.upload_resource import UploadResourceTypeEnum
from app.schemas.private_portrait import ( from app.schemas.private_portrait import (
PrivatePortraitAssetCreate, PrivatePortraitAssetCreate,
PrivatePortraitAssetListOut, PrivatePortraitAssetListOut,
@@ -26,6 +27,7 @@ from app.schemas.private_portrait import (
PrivatePortraitProjectOut, PrivatePortraitProjectOut,
PrivatePortraitProjectUpdate, PrivatePortraitProjectUpdate,
PrivatePortraitSelectableAssetListOut, PrivatePortraitSelectableAssetListOut,
PrivatePortraitUploadOut,
PrivatePortraitVirtualProjectCreate, PrivatePortraitVirtualProjectCreate,
build_private_portrait_enum_meta, build_private_portrait_enum_meta,
) )
@@ -46,6 +48,8 @@ from app.services.private_portrait.project_service import (
refresh_project_counters, refresh_project_counters,
soft_delete_project, 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 from app.services.private_portrait.virtual.service import create_virtual_asset, create_virtual_project, update_virtual_project
router = APIRouter(tags=["私域虚拟人像素材库"]) router = APIRouter(tags=["私域虚拟人像素材库"])
@@ -103,6 +107,62 @@ async def get_virtual_private_portrait_enum_meta():
return build_private_portrait_enum_meta() return build_private_portrait_enum_meta()
@router.post(
"/private-portrait/virtual/uploads/image",
response_model=PrivatePortraitUploadOut,
summary="上传虚拟图片素材",
description="上传虚拟图片素材并写入 UploadResourcemodule=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="上传虚拟视频素材并写入 UploadResourcemodule=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( @router.post(
"/private-portrait/virtual-projects", "/private-portrait/virtual-projects",
response_model=PrivatePortraitProjectOut, 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)): 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 = 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 project_id_snapshot = project.id
pending_upload_resource_ids = list(getattr(project, "_pending_upload_resource_ids", []) or [])
await db.commit() await db.commit()
try: try:
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote 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) _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: 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) _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) 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 = 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 asset_id_snapshot = asset.id
project_id_snapshot = asset.project_id project_id_snapshot = asset.project_id
pending_upload_resource_ids = list(getattr(asset, "_pending_upload_resource_ids", []) or [])
await db.commit() await db.commit()
try: try:
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote 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) _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: 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) _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) return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query,
from sqlalchemy import inspect as sa_inspect from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.dependencies import get_current_user, get_db from app.dependencies import get_current_user, get_db
from app.models.user import User from app.models.user import User
from app.enums.shot_replicate import ( from app.enums.shot_replicate import (
@@ -30,6 +31,8 @@ from app.schemas.shot_replicate import (
ShotReplicateImagePromptUpdateRequest, ShotReplicateImagePromptUpdateRequest,
ShotReanalyzeOut, ShotReanalyzeOut,
ShotReanalyzeRequest, ShotReanalyzeRequest,
ShotSegmentSplitRetryOut,
ShotSplitRetryRequest,
ShotReplicateMaterialUpdateRequest, ShotReplicateMaterialUpdateRequest,
ShotReplicateSpecOut, ShotReplicateSpecOut,
ShotReplicateTaskDetailOut, ShotReplicateTaskDetailOut,
@@ -71,6 +74,7 @@ from app.services.shot_replicate_taskset_service import (
list_task_sets, list_task_sets,
prepare_reanalyze_segment, prepare_reanalyze_segment,
prepare_reanalyze_task_set, prepare_reanalyze_task_set,
prepare_retry_split_segment,
segment_detail, segment_detail,
task_set_detail, task_set_detail,
) )
@@ -685,6 +689,83 @@ async def reanalyze_segment(
return out 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( @router.delete(
"/segments/{segment_id}", "/segments/{segment_id}",
response_model=ShotSegmentDeleteOut, response_model=ShotSegmentDeleteOut,
@@ -178,6 +178,15 @@ class PrivatePortraitEventType(str, Enum):
ASSET_CREATE_START = "ASSET_CREATE_START" ASSET_CREATE_START = "ASSET_CREATE_START"
ASSET_CREATE_SUCCESS = "ASSET_CREATE_SUCCESS" ASSET_CREATE_SUCCESS = "ASSET_CREATE_SUCCESS"
ASSET_CREATE_FAILED = "ASSET_CREATE_FAILED" 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_START = "ASSET_SYNC_START"
ASSET_SYNC_SUCCESS = "ASSET_SYNC_SUCCESS" ASSET_SYNC_SUCCESS = "ASSET_SYNC_SUCCESS"
@@ -133,6 +133,10 @@ class ShotReplicateLogEventEnum(StrEnum):
SPLIT_BY_AI_SUBMITTED = "SHOT_SPLIT_BY_AI_SUBMITTED" SPLIT_BY_AI_SUBMITTED = "SHOT_SPLIT_BY_AI_SUBMITTED"
SPLIT_CUSTOM_SUBMITTED = "SHOT_SPLIT_CUSTOM_SUBMITTED" SPLIT_CUSTOM_SUBMITTED = "SHOT_SPLIT_CUSTOM_SUBMITTED"
SEGMENT_DELETED = "SHOT_SEGMENT_DELETED" 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): class ShotReplicateRemoteActionEnum(StrEnum):
@@ -11,6 +11,8 @@ class UploadResourceModuleEnum(StrEnum):
HOME_MATERIAL = "home_material" HOME_MATERIAL = "home_material"
HOT_OPENING_REPLICATE = "hot_opening_replicate" HOT_OPENING_REPLICATE = "hot_opening_replicate"
SHOT_REPLICATE = "shot_replicate" SHOT_REPLICATE = "shot_replicate"
PRIVATE_PORTRAIT_REAL = "private_portrait_real"
PRIVATE_PORTRAIT_VIRTUAL = "private_portrait_virtual"
class UploadResourceTypeEnum(StrEnum): class UploadResourceTypeEnum(StrEnum):
@@ -83,6 +85,7 @@ class UploadResourceSourceModelEnum(StrEnum):
HOME_MATERIAL_ASSET = "HomeMaterialAsset" HOME_MATERIAL_ASSET = "HomeMaterialAsset"
SYSTEM_CONFIG = "SystemConfig" SYSTEM_CONFIG = "SystemConfig"
OPEN_TYPE = "OpenType" OPEN_TYPE = "OpenType"
PRIVATE_PORTRAIT_ASSET = "PrivatePortraitAsset"
class UploadResourceEventEnum(StrEnum): class UploadResourceEventEnum(StrEnum):
@@ -112,6 +115,7 @@ class UploadResourceEventEnum(StrEnum):
BIND_SUCCESS = "bind_success" BIND_SUCCESS = "bind_success"
BIND_CONFLICT = "bind_conflict" BIND_CONFLICT = "bind_conflict"
BIND_SKIPPED = "bind_skipped" BIND_SKIPPED = "bind_skipped"
BIND_FAILED = "bind_failed"
BACKFILL_START = "backfill_start" BACKFILL_START = "backfill_start"
BACKFILL_FILE_MATCHED = "backfill_file_matched" BACKFILL_FILE_MATCHED = "backfill_file_matched"
@@ -166,6 +170,8 @@ UPLOAD_RESOURCE_MODULE_LABELS: dict[str, str] = {
UploadResourceModuleEnum.HOME_MATERIAL.value: "首页素材", UploadResourceModuleEnum.HOME_MATERIAL.value: "首页素材",
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value: "爆款开头复刻", UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value: "爆款开头复刻",
UploadResourceModuleEnum.SHOT_REPLICATE.value: "拆镜复刻", UploadResourceModuleEnum.SHOT_REPLICATE.value: "拆镜复刻",
UploadResourceModuleEnum.PRIVATE_PORTRAIT_REAL.value: "私域真人素材",
UploadResourceModuleEnum.PRIVATE_PORTRAIT_VIRTUAL.value: "私域虚拟素材",
} }
UPLOAD_RESOURCE_TYPE_LABELS: dict[str, str] = { UPLOAD_RESOURCE_TYPE_LABELS: dict[str, str] = {
+18 -3
View File
@@ -241,12 +241,27 @@ class PrivatePortraitAssetGroupOut(BaseModel):
model_config = {"from_attributes": True} 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): class PrivatePortraitAssetCreate(BaseModel):
url: str = Field( url: str = Field(
..., ...,
min_length=1, min_length=1,
description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换为公网地址后调用火山 CreateAsset。", description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换为公网地址后调用火山 CreateAsset。",
) )
upload_resource_id: str | None = Field(
None,
max_length=32,
description="可选但新客户端必须传:真人/虚拟专用上传接口返回的 UploadResource.id。后端 CreateAsset 成功后会绑定到 PrivatePortraitAsset。",
)
asset_type: str = Field( asset_type: str = Field(
default=PrivatePortraitAssetType.IMAGE.value, default=PrivatePortraitAssetType.IMAGE.value,
description="素材类型枚举:Image=图片,Video=视频,Audio=音频。当前业务仅开放 Image / VideoAudio 会被拒绝。", description="素材类型枚举:Image=图片,Video=视频,Audio=音频。当前业务仅开放 Image / VideoAudio 会被拒绝。",
@@ -273,10 +288,10 @@ class PrivatePortraitAssetCreate(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def validate_video_duration(self) -> "PrivatePortraitAssetCreate": 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 duration = self.video_duration
if duration is None:
raise ValueError("Video 素材必须提供 video_duration")
if duration < PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS: if duration < PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS:
raise ValueError(f"视频素材最短不能少于 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS}") raise ValueError(f"视频素材最短不能少于 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS}")
if duration > PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS: if duration > PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS:
@@ -740,6 +740,27 @@ class ShotReanalyzeOut(BaseModel):
analysis_status: str = Field(..., description="重置后的分析状态") analysis_status: str = Field(..., description="重置后的分析状态")
celery_task_name: str = Field(..., description="已投递或待投递的 Celery 任务名") 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): class ShotSplitByAIOut(BaseModel):
task_set_id: str = Field(..., description="拆镜总任务集ID") 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") status: str = Field(..., description="总任务状态:pending_analysis/analyzing/analysis_completed/analysis_failed/splitting/split_completed/partial_failed/failed/deleted")
+3 -1
View File
@@ -65,7 +65,7 @@ class UploadResourceHistoryItemOut(BaseModel):
history_source: Literal["upload_resource"] = Field("upload_resource", description="素材云历史来源固定为 upload_resource") history_source: Literal["upload_resource"] = Field("upload_resource", description="素材云历史来源固定为 upload_resource")
history_source_label: str = Field("历史上传素材", description="素材云历史来源中文名称") 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="上传资源所属模块中文名称") module_label: str = Field(..., description="上传资源所属模块中文名称")
resource_type: Literal["image", "video", "audio"] = Field(..., description="资源类型:image=图片,video=视频,audio=音频") resource_type: Literal["image", "video", "audio"] = Field(..., description="资源类型:image=图片,video=视频,audio=音频")
resource_type_label: str = Field(..., description="资源类型中文名称") resource_type_label: str = Field(..., description="资源类型中文名称")
@@ -182,4 +182,6 @@ UPLOAD_RESOURCE_HISTORY_ALLOWED_MODULES = {
UploadResourceModuleEnum.COMMON.value, UploadResourceModuleEnum.COMMON.value,
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value, UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
UploadResourceModuleEnum.SHOT_REPLICATE.value, UploadResourceModuleEnum.SHOT_REPLICATE.value,
UploadResourceModuleEnum.PRIVATE_PORTRAIT_REAL.value,
UploadResourceModuleEnum.PRIVATE_PORTRAIT_VIRTUAL.value,
} }
@@ -6,7 +6,7 @@ from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import func, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -31,11 +31,22 @@ from app.enums.private_portrait import (
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
PrivatePortraitValidateSessionStatus, 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.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.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
from app.services.operation_log_service import log_operation_error, log_operation_event 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.ark_client import ArkPrivateAssetClient
from app.services.private_portrait.project_service import get_user_project, refresh_project_counters 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 ( from app.services.private_portrait.quota_service import (
count_user_counting_assets, count_user_counting_assets,
ensure_private_portrait_asset_quota_available, 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}") 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: def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut:
return PrivatePortraitValidateSessionOut( return PrivatePortraitValidateSessionOut(
id=session.id, id=session.id,
@@ -417,11 +500,14 @@ async def create_asset(
library_type: str | None = None, library_type: str | None = None,
) -> PrivatePortraitAsset: ) -> PrivatePortraitAsset:
_assert_enabled_asset_type(payload.asset_type) _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) project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
if project.status != PrivatePortraitProjectStatus.ACTIVE.value: if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="项目未激活,不能上传素材") 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) 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) 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) public_url = _public_url(payload.url)
@@ -445,7 +531,7 @@ async def create_asset(
) )
db.add(asset) db.add(asset)
await db.flush() 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: 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_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") 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.status = PrivatePortraitAssetStatus.PROCESSING.value
asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type)) asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
asset.raw_response_json = _json(remote_resp) 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 refresh_project_counters(db, [project.id])
await db.flush() await db.flush()
await db.refresh(asset) await db.refresh(asset)
@@ -465,7 +573,7 @@ async def create_asset(
asset.status = PrivatePortraitAssetStatus.FAILED.value asset.status = PrivatePortraitAssetStatus.FAILED.value
asset.error_message = _exception_message(exc) asset.error_message = _exception_message(exc)
await db.flush() 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 raise
@@ -598,10 +706,19 @@ async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str, li
asset.deleted_at = now asset.deleted_at = now
asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.PENDING.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 refresh_project_counters(db, [asset.project_id])
await db.flush() await db.flush()
await db.refresh(asset) 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 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") 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 return
now = datetime.now(timezone.utc) 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: try:
await ArkPrivateAssetClient(for_celery=True).delete_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id) 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 asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
@@ -19,9 +19,12 @@ from app.enums.private_portrait import (
PrivatePortraitProjectStatus, PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
) )
from app.enums.upload_resource import UploadResourceSourceModelEnum
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject
from app.schemas.private_portrait import PrivatePortraitProjectCreate, PrivatePortraitProjectOut, PrivatePortraitProjectUpdate from app.schemas.private_portrait import PrivatePortraitProjectCreate, PrivatePortraitProjectOut, PrivatePortraitProjectUpdate
from app.services.operation_log_service import log_operation_event 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 from app.utils.id_gen import generate_id
DOMAIN = "private_portrait" DOMAIN = "private_portrait"
@@ -264,8 +267,29 @@ async def soft_delete_project(
) -> PrivatePortraitProject: ) -> PrivatePortraitProject:
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type) project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
now = datetime.now(timezone.utc) 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.deleted_at = now
project.status = PrivatePortraitProjectStatus.DELETED.value 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( await db.execute(
update(PrivatePortraitAsset) update(PrivatePortraitAsset)
.where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None)) .where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None))
@@ -285,6 +309,6 @@ async def soft_delete_project(
user_id=user_id, user_id=user_id,
project_id=project.id, project_id=project.id,
message="本地软删私域人像素材项目", 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 return project
@@ -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
@@ -2,6 +2,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
@@ -29,6 +30,7 @@ from app.schemas.shot_replicate import (
ShotSegmentDeleteOut, ShotSegmentDeleteOut,
ShotSegmentDetailOut, ShotSegmentDetailOut,
ShotSegmentListOut, ShotSegmentListOut,
ShotSegmentSplitRetryOut,
ShotReanalyzeOut, ShotReanalyzeOut,
ShotSegmentOut, ShotSegmentOut,
ShotSplitByAIOut, 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( async def create_custom_segment(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
from sqlalchemy import select from sqlalchemy import select
@@ -15,7 +15,7 @@ from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceTy
COMMON_IMAGE_RE = re.compile(r"^images/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_img_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$") COMMON_IMAGE_RE = re.compile(r"^images/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_img_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
COMMON_VIDEO_RE = re.compile(r"^videos/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$") COMMON_VIDEO_RE = re.compile(r"^videos/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
COMMON_AUDIO_RE = re.compile(r"^audios/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/audio_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$") COMMON_AUDIO_RE = re.compile(r"^audios/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/audio_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
MODULE_RE = re.compile(r"^(?P<module>hot_opening_replicate|shot_replicate)/(?P<kind>images|videos)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>video_img|video_ref)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$") MODULE_RE = re.compile(r"^(?P<module>hot_opening_replicate|shot_replicate|private_portrait_real|private_portrait_virtual)/(?P<kind>images|videos)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>video_img|video_ref)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
SHOT_SEGMENT_RE = re.compile(r"^shot_segments/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<segment_id>[^/]+)\.mp4$") SHOT_SEGMENT_RE = re.compile(r"^shot_segments/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<segment_id>[^/]+)\.mp4$")
LEGACY_GEN_RE = re.compile(r"^(?P<kind>images|videos)/gen_(?P<user_id>[^_]+)_(?P<rand>[0-9a-zA-Z]+)\.(?P<ext>[^/]+)$") LEGACY_GEN_RE = re.compile(r"^(?P<kind>images|videos)/gen_(?P<user_id>[^_]+)_(?P<rand>[0-9a-zA-Z]+)\.(?P<ext>[^/]+)$")
ADMIN_UPLOAD_RE = re.compile(r"^admin_uploads/(?P<scene>system_logo|system_pdf|open_type_thumb|common)/(?P<kind>images|videos|audios|files)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>admin_img|admin_video|admin_audio|admin_pdf|admin_file)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$") ADMIN_UPLOAD_RE = re.compile(r"^admin_uploads/(?P<scene>system_logo|system_pdf|open_type_thumb|common)/(?P<kind>images|videos|audios|files)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>admin_img|admin_video|admin_audio|admin_pdf|admin_file)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
+41 -2
View File
@@ -165,6 +165,43 @@ export async function uploadVideo(file: File, durationSeconds?: number): Promise
return await res.json(); 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<UploadResourceResult> {
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<UploadResourceResult> {
return uploadPrivatePortraitFile('/private-portrait/uploads/image', file, undefined, '真人图片素材上传失败');
}
export async function uploadPrivatePortraitVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
return uploadPrivatePortraitFile('/private-portrait/uploads/video', file, durationSeconds, '真人视频素材上传失败');
}
export async function uploadPrivatePortraitVirtualImage(file: File): Promise<UploadResourceResult> {
return uploadPrivatePortraitFile('/private-portrait/virtual/uploads/image', file, undefined, '虚拟图片素材上传失败');
}
export async function uploadPrivatePortraitVirtualVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
return uploadPrivatePortraitFile('/private-portrait/virtual/uploads/video', file, durationSeconds, '虚拟视频素材上传失败');
}
export async function uploadHotOpeningImage(file: File): Promise<UploadResourceResult> { export async function uploadHotOpeningImage(file: File): Promise<UploadResourceResult> {
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append('file', file);
@@ -957,7 +994,7 @@ export async function getPrivatePortraitValidateSession(sessionId: string): Prom
return api.get<PrivatePortraitValidateSession>(`/private-portrait/validate-sessions/${sessionId}`); return api.get<PrivatePortraitValidateSession>(`/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<PrivatePortraitAsset> { 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<PrivatePortraitAsset> {
return api.post<PrivatePortraitAsset>(`/private-portrait/projects/${projectId}/assets`, { return api.post<PrivatePortraitAsset>(`/private-portrait/projects/${projectId}/assets`, {
url: payload.url, url: payload.url,
asset_type: payload.assetType || 'Image', asset_type: payload.assetType || 'Image',
@@ -966,6 +1003,7 @@ export async function createPrivatePortraitAsset(projectId: string, payload: { u
video_cover_url: payload.videoCoverUrl || null, video_cover_url: payload.videoCoverUrl || null,
file_size: payload.fileSize ?? null, file_size: payload.fileSize ?? null,
mime_type: payload.mimeType || 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}`); 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<PrivatePortraitAsset> { 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<PrivatePortraitAsset> {
return api.post<PrivatePortraitAsset>(`/private-portrait/virtual-projects/${projectId}/assets`, { return api.post<PrivatePortraitAsset>(`/private-portrait/virtual-projects/${projectId}/assets`, {
url: payload.url, url: payload.url,
asset_type: payload.assetType || 'Image', asset_type: payload.assetType || 'Image',
@@ -1036,6 +1074,7 @@ export async function createPrivatePortraitVirtualAsset(projectId: string, paylo
video_cover_url: payload.videoCoverUrl || null, video_cover_url: payload.videoCoverUrl || null,
file_size: payload.fileSize ?? null, file_size: payload.fileSize ?? null,
mime_type: payload.mimeType || null, mime_type: payload.mimeType || null,
upload_resource_id: payload.uploadResourceId || null,
}); });
} }
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { Button, Input, Modal, Space, Upload, message } from 'antd'; import { Button, Input, Modal, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons'; import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface'; 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 MIN_PRIVATE_VIDEO_DURATION = 2;
const MAX_PRIVATE_VIDEO_DURATION = 15; const MAX_PRIVATE_VIDEO_DURATION = 15;
@@ -89,14 +89,15 @@ const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose,
return; 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, { await createPrivatePortraitAsset(projectId, {
url: uploaded.url, url: uploaded.url,
assetType, assetType,
name: name.trim() || file.name, name: name.trim() || file.name,
videoDuration, videoDuration: uploaded.duration_seconds ?? videoDuration,
fileSize: file.size, fileSize: uploaded.file_size_bytes ?? file.size,
mimeType: file.type || null, mimeType: file.type || null,
uploadResourceId: uploaded.resource_id || null,
}); });
message.success(assetType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中'); message.success(assetType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
reset(); reset();
@@ -35,8 +35,8 @@ import {
deletePrivatePortraitVirtualProject, deletePrivatePortraitVirtualProject,
getPrivatePortraitVirtualAssets, getPrivatePortraitVirtualAssets,
getPrivatePortraitVirtualProjects, getPrivatePortraitVirtualProjects,
uploadImage, uploadPrivatePortraitVirtualImage,
uploadVideo, uploadPrivatePortraitVirtualVideo,
} from '../../../api'; } from '../../../api';
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types'; import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
@@ -276,14 +276,15 @@ const VirtualMaterialPanel: React.FC = () => {
if (currentType === 'Video' && !validatePrivateVideoDuration(duration, message.error)) { if (currentType === 'Video' && !validatePrivateVideoDuration(duration, message.error)) {
return; 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, { await createPrivatePortraitVirtualAsset(selectedProjectId, {
url: uploaded.url, url: uploaded.url,
assetType: currentType, assetType: currentType,
name: assetName.trim() || file.name, name: assetName.trim() || file.name,
videoDuration: duration, videoDuration: uploaded.duration_seconds ?? duration,
fileSize: file.size, fileSize: uploaded.file_size_bytes ?? file.size,
mimeType: file.type || null, mimeType: file.type || null,
uploadResourceId: uploaded.resource_id || null,
}); });
message.success(currentType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中'); message.success(currentType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
setUploadOpen(false); setUploadOpen(false);