from __future__ import annotations from typing import Any from pydantic import BaseModel, Field, field_validator, model_validator from app.enums.private_portrait import ( PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES, PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, PrivatePortraitAssetStatus, PrivatePortraitAssetType, PrivatePortraitLibraryType, PrivatePortraitProjectStatus, PrivatePortraitRemoteDeleteStatus, PrivatePortraitValidateSessionStatus, ) from app.schemas.common import NaiveDatetimeOptional PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS = 2 PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS = 15 _LIBRARY_TYPE_DESCRIPTIONS: dict[str, tuple[str, str]] = { PrivatePortraitLibraryType.REAL_PERSON.value: ( "真人认证素材库", "真人素材库。创建项目时会生成火山真人认证 H5 链接,用户扫码并认证成功后,项目状态变为 active,才允许上传真人素材。", ), PrivatePortraitLibraryType.AIGC_VIRTUAL.value: ( "私域虚拟人像素材库", "虚拟素材库。创建项目时直接同步火山 CreateAssetGroup,GroupType=AIGC,不需要真人扫码认证。", ), } _PROJECT_STATUS_DESCRIPTIONS: dict[str, tuple[str, str]] = { PrivatePortraitProjectStatus.VALIDATING.value: ("认证中", "真人项目已创建,等待用户扫码完成真人认证。"), PrivatePortraitProjectStatus.ACTIVE.value: ("可用", "项目可用,允许上传素材,项目内 Active 素材可用于 AI 创作。"), PrivatePortraitProjectStatus.VALIDATE_FAILED.value: ("认证失败", "真人认证失败或回调结果不通过。"), PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value: ("远端分组创建中", "正在调用火山创建 Asset Group。"), PrivatePortraitProjectStatus.CREATE_GROUP_FAILED.value: ("远端分组创建失败", "火山 Asset Group 创建失败,需要重试或人工排查。"), PrivatePortraitProjectStatus.DELETED.value: ("已删除", "本地已软删,不再对普通前端展示。"), } _VALIDATE_SESSION_STATUS_DESCRIPTIONS: dict[str, tuple[str, str]] = { PrivatePortraitValidateSessionStatus.CREATED.value: ("已创建", "认证会话已创建,前端可展示 h5_link 二维码。"), PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value: ("回调成功", "火山回调 resultCode=10000,认证通过,等待或已绑定远端分组。"), PrivatePortraitValidateSessionStatus.CALLBACK_FAILED.value: ("回调失败", "火山回调 resultCode 非成功值,认证未通过。"), PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value: ("分组可用", "认证成功并已获得可用 remote_group_id,项目可上传素材。"), PrivatePortraitValidateSessionStatus.EXPIRED.value: ("已过期", "认证会话超过有效期,需要重新创建认证会话。"), PrivatePortraitValidateSessionStatus.FAILED.value: ("失败", "认证会话创建、查询或回调处理失败。"), } _ASSET_TYPE_DESCRIPTIONS: dict[str, tuple[str, str]] = { PrivatePortraitAssetType.IMAGE.value: ("图片", "当前开放。用于真人/虚拟图片参考素材。"), PrivatePortraitAssetType.VIDEO.value: ( "视频", f"当前开放。必须传 video_duration,支持 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS}~{PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS} 秒。", ), PrivatePortraitAssetType.AUDIO.value: ("音频", "火山支持但当前业务暂不开放,接口会拒绝 Audio。"), } _ASSET_STATUS_DESCRIPTIONS: dict[str, tuple[str, str]] = { PrivatePortraitAssetStatus.CREATING.value: ("创建中", "本地记录已创建,正在调用火山 CreateAsset。"), PrivatePortraitAssetStatus.PROCESSING.value: ("处理中", "火山侧正在处理素材,暂不可用于 AI 创作。"), PrivatePortraitAssetStatus.ACTIVE.value: ("可用", "素材已处理完成,可在 AI 创作中作为参考素材选择。"), PrivatePortraitAssetStatus.FAILED.value: ("失败", "火山侧处理失败或本地同步失败。"), PrivatePortraitAssetStatus.LOCAL_DELETED.value: ("本地已删除", "本地已软删,等待远端删除或无需远端删除。"), PrivatePortraitAssetStatus.REMOTE_DELETED.value: ("远端已删除", "火山侧素材删除成功。"), PrivatePortraitAssetStatus.DELETE_FAILED.value: ("删除失败", "火山侧删除失败,后续可通过补偿任务重试。"), } _REMOTE_DELETE_STATUS_DESCRIPTIONS: dict[str, tuple[str, str]] = { PrivatePortraitRemoteDeleteStatus.NONE.value: ("无需删除", "未触发远端删除,通常表示资源仍正常。"), PrivatePortraitRemoteDeleteStatus.PENDING.value: ("等待删除", "本地已软删并提交成功,远端删除任务等待执行或执行中。"), PrivatePortraitRemoteDeleteStatus.SUCCESS.value: ("删除成功", "火山侧远端资源已删除。"), PrivatePortraitRemoteDeleteStatus.FAILED.value: ("删除失败", "火山侧远端删除失败,已记录错误,后续可补偿重试。"), PrivatePortraitRemoteDeleteStatus.SKIPPED.value: ("跳过删除", "资源没有远端 ID 或已不需要调用远端删除。"), } def _enum_items(enum_values: list[str], mapping: dict[str, tuple[str, str]]) -> list["PrivatePortraitEnumItem"]: return [ PrivatePortraitEnumItem( value=value, label=mapping.get(value, (value, ""))[0], description=mapping.get(value, (value, None))[1], ) for value in enum_values ] class PrivatePortraitEnumItem(BaseModel): value: str = Field(..., description="枚举值,接口入参和出参均使用该值。") label: str = Field(..., description="枚举中文名称,用于前端展示。") description: str | None = Field(None, description="枚举说明,用于 OpenAPI 文档、前端提示或排查。") class PrivatePortraitEnumMetaOut(BaseModel): library_types: list[PrivatePortraitEnumItem] = Field(..., description="素材库类型枚举:real_person=真人认证素材库,aigc_virtual=私域虚拟人像素材库。") asset_types: list[PrivatePortraitEnumItem] = Field(..., description="素材类型枚举:Image=图片,Video=视频,Audio=音频;当前业务仅开放 Image/Video。") project_statuses: list[PrivatePortraitEnumItem] = Field(..., description="项目状态枚举,用于项目列表、项目详情、筛选和状态展示。") asset_statuses: list[PrivatePortraitEnumItem] = Field(..., description="素材状态枚举。只有 Active 素材可被 selectable-assets 返回并用于 AI 创作。") validate_session_statuses: list[PrivatePortraitEnumItem] = Field(..., description="真人认证会话状态枚举,用于 PC 端轮询扫码认证结果。") remote_delete_statuses: list[PrivatePortraitEnumItem] = Field(..., description="远端删除状态枚举。本地删除先 commit,远端删除异步执行。") class PrivatePortraitConfigOut(BaseModel): enabled: bool = Field(..., description="是否启用私域人像素材库。asset_limit > 0 表示启用;asset_limit = 0 表示关闭。") asset_limit: int = Field(..., description="私域素材总量限制。真人/虚拟共用,图片/视频共用;0 表示关闭私域素材模块。") used_asset_count: int = Field(..., description="当前已占用额度的素材数量。统计真人+虚拟、Image+Video,状态包含 creating/Processing/Active。") remaining_asset_count: int = Field(..., description="剩余可上传素材数量。计算方式:max(asset_limit - used_asset_count, 0)。") supported_asset_types: list[str] = Field( default_factory=lambda: [PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAssetType.VIDEO.value], description="当前业务开放的素材类型枚举值。固定为 Image / Video。", ) unsupported_asset_types: list[str] = Field( default_factory=lambda: [PrivatePortraitAssetType.AUDIO.value], description="火山支持但当前业务暂不开放的素材类型。当前为 Audio。", ) image_limit: int | None = Field(None, description="兼容旧前端字段:旧图片额度字段。新前端请改用 asset_limit。") used_image_count: int | None = Field(None, description="兼容旧前端字段:旧图片已用数量。新前端请改用 used_asset_count。") remaining_image_count: int | None = Field(None, description="兼容旧前端字段:旧图片剩余额度。新前端请改用 remaining_asset_count。") class PrivatePortraitAdminConfigUpdate(BaseModel): private_portrait_asset_limit: int = Field( ..., ge=0, le=9999, description="用户私域素材总量限制。0 表示关闭;>0 表示启用并限制真人/虚拟、图片/视频素材总量。", ) class PrivatePortraitProjectCreate(BaseModel): name: str = Field(..., min_length=1, max_length=128, description="真人认证素材项目组名称。前端展示用,不直接作为火山 ProjectName。") description: str | None = Field(None, max_length=2000, description="真人认证素材项目组描述,前端展示和管理备注用。") callback_redirect_url: str | None = Field( None, description="真人认证完成后的手机端跳转地址。为空时使用后端默认回调页;PC 端仍应通过 validate_session 轮询最终状态。", ) class PrivatePortraitVirtualProjectCreate(BaseModel): name: str = Field( ..., min_length=1, max_length=128, description="虚拟人像素材项目组名称。创建后会同步火山 CreateAssetGroup,GroupType=AIGC。", ) description: str | None = Field(None, max_length=2000, description="虚拟人像素材项目组描述,会同步到火山 Asset Group。") class PrivatePortraitProjectUpdate(BaseModel): name: str | None = Field(None, min_length=1, max_length=128, description="项目组名称。仅修改本地展示名称和必要远端分组名称。") description: str | None = Field(None, max_length=2000, description="项目组描述。用于前端展示和管理备注。") status: str | None = Field( None, description="项目状态。可选值:validating、active、validate_failed、creating_remote_group、create_group_failed、deleted。普通前端不建议手动变更。", ) class PrivatePortraitProjectOut(BaseModel): id: str = Field(..., description="本地私域项目 ID。后续查询、更新、删除、上传素材均使用该 ID。") user_id: str | None = Field(None, description="项目所属用户 ID。普通用户接口一般返回当前用户 ID;管理接口用于定位用户。") library_type: str = Field(default=PrivatePortraitLibraryType.REAL_PERSON.value, description="素材库类型:real_person=真人认证素材库,aigc_virtual=私域虚拟素材库。") name: str = Field(..., description="项目组名称,前端展示用。") name_slug: str | None = Field(None, description="项目名称归一化值,兼容旧逻辑或排查使用。") remote_project_name: str | None = Field(None, description=f"火山侧 ProjectName 快照。当前固定为 {PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME}。") description: str | None = Field(None, description="项目组描述。") status: str = Field(..., description="项目状态。active 表示可上传素材;真人项目 validating 表示等待扫码认证。") asset_group_count: int = Field(0, description="项目下本地素材组数量。真人一般认证成功后为 1,虚拟创建项目后为 1。") asset_count: int = Field(0, description="项目下素材总数,包含图片和视频。") image_asset_count: int = Field(0, description="项目下图片素材数量。") video_asset_count: int = Field(0, description="项目下视频素材数量。") active_asset_count: int = Field(0, description="项目下 Active 可用素材总数。") active_image_asset_count: int = Field(0, description="项目下 Active 可用图片素材数量。") active_video_asset_count: int = Field(0, description="项目下 Active 可用视频素材数量。") last_used_at: NaiveDatetimeOptional = Field(None, description="最近被 AI 创作引用的时间。为空表示尚未使用。") created_at: NaiveDatetimeOptional = Field(None, description="本地项目创建时间。") updated_at: NaiveDatetimeOptional = Field(None, description="本地项目最后更新时间。") model_config = {"from_attributes": True} class PrivatePortraitProjectListOut(BaseModel): items: list[PrivatePortraitProjectOut] = Field(..., description="项目列表。真人接口只返回 real_person,虚拟接口只返回 aigc_virtual。") total: int = Field(..., description="符合筛选条件的项目总数。") page: int = Field(..., description="当前页码,从 1 开始。") page_size: int = Field(..., description="每页数量。") class PrivatePortraitValidateSessionCreate(BaseModel): callback_redirect_url: str | None = Field( None, description="真人认证完成后手机端要跳转的页面。为空时使用后端默认回调页;PC 端应继续轮询 validate-session。", ) class PrivatePortraitValidateSessionOut(BaseModel): id: str = Field(..., description="本地认证会话 ID。PC 端轮询认证状态时使用。") user_id: str | None = Field(None, description="认证会话所属用户 ID。") project_id: str = Field(..., description="认证会话绑定的本地真人项目 ID。") byted_token: str | None = Field(None, description="火山认证 token,排查用。前端一般不直接展示。") h5_link: str | None = Field(None, description="火山真人认证 H5 链接。前端应转二维码供手机扫码。") callback_url: str | None = Field(None, description="后端传给火山的回调地址。认证完成后火山会请求该地址。") result_code: str | None = Field(None, description="火山回调 resultCode。10000 表示成功,其他值表示失败或未通过。") algorithm_base_resp_code: str | None = Field(None, description="火山算法返回码,排查认证失败原因用。") verify_type: str | None = Field(None, description="火山认证类型。当前真人认证使用 real_time。") status: str = Field(..., description="认证会话状态:created、callback_success、callback_failed、group_active、expired、failed。") remote_group_id: str | None = Field(None, description="认证成功后火山返回的 Asset Group ID。后续上传真人素材会绑定该分组。") remote_project_name: str | None = Field(None, description=f"火山侧 ProjectName 快照。当前固定为 {PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME}。") expired_at: NaiveDatetimeOptional = Field(None, description="认证会话过期时间。过期后需要重新创建认证会话。") error_message: str | None = Field(None, description="认证失败、回调失败或查询失败的错误信息。") created_at: NaiveDatetimeOptional = Field(None, description="认证会话创建时间。") updated_at: NaiveDatetimeOptional = Field(None, description="认证会话最后更新时间。") model_config = {"from_attributes": True} class PrivatePortraitProjectCreateWithValidateOut(BaseModel): project: PrivatePortraitProjectOut = Field(..., description="创建成功的真人项目数据。初始状态通常为 validating。") validate_session: PrivatePortraitValidateSessionOut = Field(..., description="随项目创建一起生成的真人认证会话。前端用 h5_link 展示二维码。") poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒。") class PrivatePortraitAssetGroupOut(BaseModel): id: str = Field(..., description="本地素材组 ID。") user_id: str | None = Field(None, description="素材组所属用户 ID。") project_id: str = Field(..., description="素材组所属本地项目 ID。") library_type: str = Field(..., description="素材库类型:real_person=真人认证素材库,aigc_virtual=私域虚拟素材库。") remote_group_id: str = Field(..., description="火山 Asset Group ID。") remote_group_name: str | None = Field(None, description="火山 Asset Group 名称。") remote_project_name: str = Field(..., description=f"火山侧 ProjectName。当前固定为 {PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME}。") group_type: str = Field(..., description="火山分组类型。真人为 LivenessFace,虚拟为 AIGC。") status: str = Field(..., description="素材组本地状态:creating、active、local_deleted、remote_deleted、delete_failed、failed。") remote_delete_status: str = Field(..., description="远端删除状态:none、pending、success、failed、skipped。") remote_deleted_at: NaiveDatetimeOptional = Field(None, description="远端删除成功时间。") remote_delete_error: str | None = Field(None, description="远端删除失败错误信息。") created_at: NaiveDatetimeOptional = Field(None, description="素材组创建时间。") updated_at: NaiveDatetimeOptional = Field(None, description="素材组最后更新时间。") 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 会被拒绝。", ) name: str | None = Field(None, max_length=256, description="素材名称。用于前端展示、检索和管理备注;不影响远端素材处理。") video_duration: float | None = Field( None, ge=0, description=f"视频素材时长,单位秒。asset_type=Video 时必填,范围 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS}~{PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS} 秒;图片可为空。", ) video_cover_url: str | None = Field(None, description="视频封面预览地址。asset_type=Video 时建议传入;图片可为空。") file_size: int | None = Field(None, ge=0, description="文件大小,单位字节。用于前端展示和排查。") mime_type: str | None = Field(None, max_length=128, description="素材 MIME 类型,例如 image/png、video/mp4。用于前端展示和排查。") @field_validator("asset_type") @classmethod def validate_asset_type(cls, v: str) -> str: value = v or PrivatePortraitAssetType.IMAGE.value if value not in {item.value for item in PrivatePortraitAssetType}: raise ValueError("asset_type 仅支持 Image/Video,Audio 暂未开放") if value not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES: raise ValueError("Audio 暂未开放,当前仅支持 Image/Video") return value @model_validator(mode="after") def validate_video_duration(self) -> "PrivatePortraitAssetCreate": # 允许新客户端只传 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 < PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS: raise ValueError(f"视频素材最短不能少于 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS} 秒") if duration > PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS: raise ValueError(f"视频素材最长不能超过 {PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS} 秒") return self class PrivatePortraitAssetOut(BaseModel): id: str = Field(..., description="本地素材 ID。删除、同步、详情查询均使用该 ID。") user_id: str | None = Field(None, description="素材所属用户 ID。") project_id: str = Field(..., description="素材所属项目 ID。") project_name: str | None = Field(None, description="素材所属项目名称。列表接口会尽量回填,详情接口会单独查询回填。") group_id: str = Field(..., description="本地素材组 ID。") library_type: str = Field(..., description="素材库类型:real_person=真人认证素材库,aigc_virtual=私域虚拟素材库。") remote_group_id: str = Field(..., description="火山 Asset Group ID。") remote_asset_id: str | None = Field(None, description="火山 Asset ID。创建成功后回填。") remote_project_name: str | None = Field(None, description=f"火山侧 ProjectName 快照。当前固定为 {PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME}。") asset_type: str = Field(..., description="素材类型:Image=图片,Video=视频。Audio 当前不会返回给普通业务。") name: str | None = Field(None, description="素材名称。") source_url: str = Field(..., description="本地原始素材 URL。用于回显和排查,生成时通常使用 display_url/provider_url。") preview_url: str | None = Field(None, description="前端预览 URL。图片/视频都优先使用该字段展示缩略或预览。") display_url: str | None = Field(None, description="前端可访问展示 URL。用于素材列表、素材云或 AI 创作选择器展示。") provider_url: str | None = Field(None, description="提供给生成服务使用的 URL。可能是本地签名 URL 或远端可访问 URL。") remote_url: str | None = Field(None, description="火山侧返回的素材 URL。可能有过期时间,仅排查或兜底使用。") remote_url_expired_at: NaiveDatetimeOptional = Field(None, description="remote_url 过期时间。过期后应重新同步素材状态或使用 display_url/provider_url。") video_duration: float | None = Field(None, description="视频素材时长,单位秒。图片为空。") video_cover_url: str | None = Field(None, description="视频封面 URL。图片为空。") file_size: int | None = Field(None, description="素材文件大小,单位字节。") mime_type: str | None = Field(None, description="素材 MIME 类型。") status: str = Field(..., description="素材状态:creating、Processing、Active、Failed、local_deleted、remote_deleted、delete_failed。只有 Active 可用于 AI 创作。") moderation: Any = Field(None, description="火山审核或处理返回的原始补充信息。结构可能随火山返回变化,前端一般不依赖该字段。") last_poll_at: NaiveDatetimeOptional = Field(None, description="最近一次同步/轮询火山素材状态的时间。") next_poll_at: NaiveDatetimeOptional = Field(None, description="下一次计划同步/轮询火山素材状态的时间。") poll_count: int = Field(0, description="素材状态轮询次数。用于排查处理超时。") remote_delete_status: str = Field(..., description="远端删除状态:none、pending、success、failed、skipped。") remote_deleted_at: NaiveDatetimeOptional = Field(None, description="远端素材删除成功时间。") remote_delete_error: str | None = Field(None, description="远端素材删除失败错误信息。") error_message: str | None = Field(None, description="创建、同步、处理或删除过程中的错误信息。") created_at: NaiveDatetimeOptional = Field(None, description="素材创建时间。") updated_at: NaiveDatetimeOptional = Field(None, description="素材最后更新时间。") model_config = {"from_attributes": True} class PrivatePortraitAssetListOut(BaseModel): items: list[PrivatePortraitAssetOut] = Field(..., description="素材列表。包含图片/视频,是否可用以 status=Active 为准。") total: int = Field(..., description="符合筛选条件的素材总数。") page: int = Field(..., description="当前页码,从 1 开始。") page_size: int = Field(..., description="每页数量。") class PrivatePortraitSelectableAssetOut(BaseModel): id: str = Field(..., description="本地素材 ID。AI 创作选择后可作为 private_asset_id / private_portrait_asset 引用。") project_id: str = Field(..., description="素材所属项目 ID。") project_name: str = Field(..., description="素材所属项目名称。") library_type: str = Field(..., description="素材库类型:real_person=真人认证素材库,aigc_virtual=私域虚拟素材库。") name: str | None = Field(None, description="素材名称。") asset_type: str = Field(..., description="素材类型:Image=图片,Video=视频。") preview_url: str | None = Field(None, description="前端选择器预览 URL。") display_url: str | None = Field(None, description="前端展示 URL。") provider_url: str | None = Field(None, description="传给 AI 创作生成接口的可访问 URL。") video_duration: float | None = Field(None, description="视频素材时长,单位秒。图片为空。AI 创作会用它做视频总时长校验。") video_cover_url: str | None = Field(None, description="视频封面 URL。图片为空。") status: str = Field(default=PrivatePortraitAssetStatus.ACTIVE.value, description="固定返回 Active。该接口只返回可用于 AI 创作的素材。") created_at: NaiveDatetimeOptional = Field(None, description="素材创建时间。") class PrivatePortraitSelectableAssetListOut(BaseModel): items: list[PrivatePortraitSelectableAssetOut] = Field(..., description="可用于 AI 创作选择器的素材列表。只包含 Active 的 Image/Video。") total: int = Field(..., description="符合筛选条件的可选素材总数。") page: int = Field(..., description="当前页码,从 1 开始。") page_size: int = Field(..., description="每页数量。") class PrivatePortraitDeleteOut(BaseModel): success: bool = Field(True, description="本地删除是否成功。接口返回时表示本地软删已提交成功。") remote_delete_status: str = Field(..., description="远端删除状态。通常返回 pending,表示远端删除任务已投递或等待补偿。") class PrivatePortraitAdminStatsOut(BaseModel): total_projects: int = Field(0, description="项目总数,包含真人和虚拟项目。") total_assets: int = Field(0, description="素材总数,包含真人/虚拟、图片/视频。") image_assets: int = Field(0, description="图片素材数量。") video_assets: int = Field(0, description="视频素材数量。") active_assets: int = Field(0, description="Active 可用素材数量。") processing_assets: int = Field(0, description="Processing 处理中素材数量。") failed_assets: int = Field(0, description="Failed 失败素材数量。") real_person_assets: int = Field(0, description="真人素材数量,library_type=real_person。") virtual_assets: int = Field(0, description="虚拟素材数量,library_type=aigc_virtual。") def build_private_portrait_enum_meta() -> PrivatePortraitEnumMetaOut: return PrivatePortraitEnumMetaOut( library_types=_enum_items( [PrivatePortraitLibraryType.REAL_PERSON.value, PrivatePortraitLibraryType.AIGC_VIRTUAL.value], _LIBRARY_TYPE_DESCRIPTIONS, ), asset_types=_enum_items( [PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAssetType.VIDEO.value, PrivatePortraitAssetType.AUDIO.value], _ASSET_TYPE_DESCRIPTIONS, ), project_statuses=_enum_items([item.value for item in PrivatePortraitProjectStatus], _PROJECT_STATUS_DESCRIPTIONS), asset_statuses=_enum_items([item.value for item in PrivatePortraitAssetStatus], _ASSET_STATUS_DESCRIPTIONS), validate_session_statuses=_enum_items([item.value for item in PrivatePortraitValidateSessionStatus], _VALIDATE_SESSION_STATUS_DESCRIPTIONS), remote_delete_statuses=_enum_items([item.value for item in PrivatePortraitRemoteDeleteStatus], _REMOTE_DELETE_STATUS_DESCRIPTIONS), )