277 lines
12 KiB
Python
277 lines
12 KiB
Python
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,
|
||
PrivatePortraitAssetStatus,
|
||
PrivatePortraitAssetType,
|
||
PrivatePortraitLibraryType,
|
||
PrivatePortraitProjectStatus,
|
||
)
|
||
from app.schemas.common import NaiveDatetimeOptional
|
||
|
||
PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS = 2
|
||
PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS = 15
|
||
|
||
|
||
class PrivatePortraitEnumItem(BaseModel):
|
||
value: str
|
||
label: str
|
||
description: str | None = None
|
||
|
||
|
||
class PrivatePortraitEnumMetaOut(BaseModel):
|
||
library_types: list[PrivatePortraitEnumItem]
|
||
asset_types: list[PrivatePortraitEnumItem]
|
||
project_statuses: list[PrivatePortraitEnumItem]
|
||
asset_statuses: list[PrivatePortraitEnumItem]
|
||
|
||
|
||
class PrivatePortraitConfigOut(BaseModel):
|
||
enabled: bool = Field(..., description="是否启用私域人像素材库。asset_limit > 0 表示启用。")
|
||
asset_limit: int = Field(..., description="私域人像素材总量限制:真人/虚拟共用,图片/视频共用,0 表示关闭。")
|
||
used_asset_count: int = Field(..., description="当前占用额度的素材数量。统计 creating/Processing/Active 的 Image/Video。")
|
||
remaining_asset_count: int = Field(..., description="剩余可上传素材数量。")
|
||
supported_asset_types: list[str] = Field(default_factory=lambda: [PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAssetType.VIDEO.value], description="当前业务开放的素材类型。")
|
||
unsupported_asset_types: list[str] = Field(default_factory=lambda: [PrivatePortraitAssetType.AUDIO.value], description="火山支持但当前业务暂不开放的素材类型。")
|
||
# 兼容旧前端,后续确认无引用后可移除。
|
||
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="项目组名称。")
|
||
description: str | None = Field(None, max_length=2000, description="项目组描述。")
|
||
callback_redirect_url: str | None = Field(None, description="仅真人认证使用:认证完成后的手机端提示页地址。")
|
||
|
||
|
||
class PrivatePortraitVirtualProjectCreate(BaseModel):
|
||
name: str = Field(..., min_length=1, max_length=128, description="虚拟人像素材项目组名称。创建后会同步创建火山 Asset Group。")
|
||
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="项目状态。普通前端不建议手动变更,仅管理/排查使用。")
|
||
|
||
|
||
class PrivatePortraitProjectOut(BaseModel):
|
||
id: str
|
||
user_id: str | None = None
|
||
library_type: str = Field(default=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||
name: str
|
||
name_slug: str | None = None
|
||
remote_project_name: str | None = None
|
||
description: str | None = None
|
||
status: str
|
||
asset_group_count: int = 0
|
||
asset_count: int = 0
|
||
image_asset_count: int = 0
|
||
video_asset_count: int = 0
|
||
active_asset_count: int = 0
|
||
active_image_asset_count: int = 0
|
||
active_video_asset_count: int = 0
|
||
last_used_at: NaiveDatetimeOptional = None
|
||
created_at: NaiveDatetimeOptional = None
|
||
updated_at: NaiveDatetimeOptional = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class PrivatePortraitProjectListOut(BaseModel):
|
||
items: list[PrivatePortraitProjectOut]
|
||
total: int
|
||
page: int
|
||
page_size: int
|
||
|
||
|
||
class PrivatePortraitValidateSessionCreate(BaseModel):
|
||
callback_redirect_url: str | None = Field(None, description="认证完成后前端要跳转的页面。为空时使用后端默认回调页。")
|
||
|
||
|
||
class PrivatePortraitValidateSessionOut(BaseModel):
|
||
id: str
|
||
user_id: str | None = None
|
||
project_id: str
|
||
byted_token: str | None = None
|
||
h5_link: str | None = None
|
||
callback_url: str | None = None
|
||
result_code: str | None = None
|
||
algorithm_base_resp_code: str | None = None
|
||
verify_type: str | None = None
|
||
status: str
|
||
remote_group_id: str | None = None
|
||
remote_project_name: str | None = None
|
||
expired_at: NaiveDatetimeOptional = None
|
||
error_message: str | None = None
|
||
created_at: NaiveDatetimeOptional = None
|
||
updated_at: NaiveDatetimeOptional = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class PrivatePortraitProjectCreateWithValidateOut(BaseModel):
|
||
project: PrivatePortraitProjectOut
|
||
validate_session: PrivatePortraitValidateSessionOut
|
||
poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒。")
|
||
|
||
|
||
class PrivatePortraitAssetGroupOut(BaseModel):
|
||
id: str
|
||
user_id: str | None = None
|
||
project_id: str
|
||
library_type: str
|
||
remote_group_id: str
|
||
remote_group_name: str | None = None
|
||
remote_project_name: str
|
||
group_type: str
|
||
status: str
|
||
remote_delete_status: str
|
||
remote_deleted_at: NaiveDatetimeOptional = None
|
||
remote_delete_error: str | None = None
|
||
created_at: NaiveDatetimeOptional = None
|
||
updated_at: NaiveDatetimeOptional = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class PrivatePortraitAssetCreate(BaseModel):
|
||
url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换公网地址后调用火山 CreateAsset。")
|
||
asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value, description="素材类型。当前业务仅开放 Image / Video,Audio 暂不开放。")
|
||
name: str | None = Field(None, max_length=256, description="素材名称,仅用于检索和管理。")
|
||
video_duration: float | None = Field(None, ge=0, description="视频素材时长,单位秒。图片可为空。")
|
||
video_cover_url: str | None = Field(None, description="视频封面预览地址。图片可为空。")
|
||
file_size: int | None = Field(None, ge=0, description="文件大小,字节。")
|
||
mime_type: str | None = Field(None, max_length=128, description="素材 MIME 类型。")
|
||
|
||
@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":
|
||
if self.asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||
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:
|
||
raise ValueError(f"视频素材最长不能超过 {PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS} 秒")
|
||
return self
|
||
|
||
|
||
class PrivatePortraitAssetOut(BaseModel):
|
||
id: str
|
||
user_id: str | None = None
|
||
project_id: str
|
||
project_name: str | None = None
|
||
group_id: str
|
||
library_type: str
|
||
remote_group_id: str
|
||
remote_asset_id: str | None = None
|
||
remote_project_name: str | None = None
|
||
asset_type: str
|
||
name: str | None = None
|
||
source_url: str
|
||
preview_url: str | None = None
|
||
display_url: str | None = None
|
||
provider_url: str | None = None
|
||
remote_url: str | None = None
|
||
remote_url_expired_at: NaiveDatetimeOptional = None
|
||
video_duration: float | None = None
|
||
video_cover_url: str | None = None
|
||
file_size: int | None = None
|
||
mime_type: str | None = None
|
||
status: str
|
||
moderation: Any = None
|
||
last_poll_at: NaiveDatetimeOptional = None
|
||
next_poll_at: NaiveDatetimeOptional = None
|
||
poll_count: int = 0
|
||
remote_delete_status: str
|
||
remote_deleted_at: NaiveDatetimeOptional = None
|
||
remote_delete_error: str | None = None
|
||
error_message: str | None = None
|
||
created_at: NaiveDatetimeOptional = None
|
||
updated_at: NaiveDatetimeOptional = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class PrivatePortraitAssetListOut(BaseModel):
|
||
items: list[PrivatePortraitAssetOut]
|
||
total: int
|
||
page: int
|
||
page_size: int
|
||
|
||
|
||
class PrivatePortraitSelectableAssetOut(BaseModel):
|
||
id: str
|
||
project_id: str
|
||
project_name: str
|
||
library_type: str
|
||
name: str | None = None
|
||
asset_type: str
|
||
preview_url: str | None = None
|
||
display_url: str | None = None
|
||
provider_url: str | None = None
|
||
video_duration: float | None = None
|
||
video_cover_url: str | None = None
|
||
status: str = PrivatePortraitAssetStatus.ACTIVE.value
|
||
created_at: NaiveDatetimeOptional = None
|
||
|
||
|
||
class PrivatePortraitSelectableAssetListOut(BaseModel):
|
||
items: list[PrivatePortraitSelectableAssetOut]
|
||
total: int
|
||
page: int
|
||
page_size: int
|
||
|
||
|
||
class PrivatePortraitDeleteOut(BaseModel):
|
||
success: bool = True
|
||
remote_delete_status: str
|
||
|
||
|
||
class PrivatePortraitAdminStatsOut(BaseModel):
|
||
total_projects: int = 0
|
||
total_assets: int = 0
|
||
image_assets: int = 0
|
||
video_assets: int = 0
|
||
active_assets: int = 0
|
||
processing_assets: int = 0
|
||
failed_assets: int = 0
|
||
real_person_assets: int = 0
|
||
virtual_assets: int = 0
|
||
|
||
|
||
def build_private_portrait_enum_meta() -> PrivatePortraitEnumMetaOut:
|
||
return PrivatePortraitEnumMetaOut(
|
||
library_types=[
|
||
PrivatePortraitEnumItem(value=PrivatePortraitLibraryType.REAL_PERSON.value, label="真人认证素材库", description="需要用户扫码完成真人授权认证后才能上传素材。"),
|
||
PrivatePortraitEnumItem(value=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, label="私域虚拟人像素材库", description="通过火山 CreateAssetGroup/CreateAsset 入库的虚拟人像素材。"),
|
||
],
|
||
asset_types=[
|
||
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.IMAGE.value, label="图片", description="当前开放。"),
|
||
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.VIDEO.value, label="视频", description="当前开放,处理时间通常比图片更长。"),
|
||
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.AUDIO.value, label="音频", description="火山支持但当前业务暂不开放。"),
|
||
],
|
||
project_statuses=[PrivatePortraitEnumItem(value=item.value, label=item.value) for item in PrivatePortraitProjectStatus],
|
||
asset_statuses=[PrivatePortraitEnumItem(value=item.value, label=item.value) for item in PrivatePortraitAssetStatus],
|
||
)
|