Files
video-gen/video-gen-api/app/services/virtual_portrait_v3/asset_service.py
T
root 0c511f3451 1、增加调用 AI 视频生成能力和虚拟素材库管理的对外api
2、增加后台apikkey管理
3、增加apikey单独的模型定价
4、增加apikey调用情况
5、完善所有数据的注释增加
2026-08-06 13:13:28 +08:00

675 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timedelta, timezone
from typing import Awaitable, Callable
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import (
PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.virtual_portrait_v3 import VpV3Asset, VpV3Project
from app.schemas.virtual_portrait_v3.asset import (
VpV3AssetCreate,
VpV3AssetListOut,
VpV3AssetOut,
VpV3SelectableAssetListOut,
VpV3SelectableAssetOut,
)
from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.private_portrait.ark_client import (
ArkPrivateAssetClient,
ArkPrivateAssetClientError,
)
from app.services.virtual_portrait_v3.project_service import (
refresh_project_counters,
)
from app.services.virtual_portrait_v3.quota_service import (
_bytes_to_mb,
_refresh_quota_used,
check_asset_quota,
get_quota,
remote_project_name,
)
from app.services.virtual_portrait_v3.upload_service import (
delete_local_file_by_url,
download_url_to_local,
)
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
DOMAIN = "virtual_portrait_v3"
URL_RE_REMOTE_URL_EXPR = re.compile(r"^https?://", re.IGNORECASE)
URL_LOCAL_UPLOAD_EXPR = re.compile(r"^/uploads/|^https?://[^/]+/uploads/", re.IGNORECASE)
# 北京时间(UTC+8)统一基准:V3 业务所有时间写入 / 时间比较唯一参考
_BJ_TZ = timezone(timedelta(hours=8))
def _bj_now() -> datetime:
"""返回当前北京时间(UTC+8naive datetime(去掉 tzinfo,与 DB naive 存储一致)。"""
return datetime.now(_BJ_TZ).replace(tzinfo=None)
def _json(data) -> str | None:
if data is None:
return None
return json.dumps(data, ensure_ascii=False, default=str)
def asset_to_out(a: VpV3Asset) -> VpV3AssetOut:
return VpV3AssetOut(
asset_id=a.id,
project_id=a.project_id,
name=a.name,
asset_type=a.asset_type,
status=a.status,
source_url=a.source_url,
preview_url=a.preview_url,
remote_url=a.remote_url,
remote_url_expired_at=a.remote_url_expired_at,
video_duration=a.video_duration,
video_cover_url=a.video_cover_url,
file_size_bytes=a.file_size_bytes,
mime_type=a.mime_type,
moderation_json=a.moderation_json,
error_message=a.error_message,
remote_delete_status=a.remote_delete_status or PrivatePortraitRemoteDeleteStatus.NONE.value,
created_at=a.created_at,
updated_at=a.updated_at,
)
def asset_to_selectable(a: VpV3Asset) -> VpV3SelectableAssetOut:
return VpV3SelectableAssetOut(
asset_id=a.id,
project_id=a.project_id,
name=a.name,
asset_type=a.asset_type,
status=a.status,
source_url=a.source_url,
preview_url=a.preview_url or a.remote_url or a.source_url,
video_duration=a.video_duration,
video_cover_url=a.video_cover_url,
file_size_bytes=a.file_size_bytes,
created_at=a.created_at,
)
def _validate_source_url(url: str, asset_type: str) -> None:
"""创建素材时的 source_url 现在只允许 http(s) 的外部 URL。
旧的 /uploads/* 本地 URL 已不再推荐(直接让系统自己下载保存)。"""
if not url or not url.strip():
raise HTTPException(status_code=400, detail="source_url 不能为空")
stripped = url.strip()
if not stripped.lower().startswith("http://") and not stripped.lower().startswith("https://"):
raise HTTPException(
status_code=400,
detail="source_url 必须是公网可访问的 http(s) URL;本服务会自动下载并保存到本地",
)
if len(stripped) > 2000:
raise HTTPException(status_code=400, detail="source_url 过长(最多 2000 字符)")
# ---------------------------------------------------------------------------
# Asset CRUD
# ---------------------------------------------------------------------------
async def create_asset(
db: AsyncSession,
*,
api_key_id: str,
project: VpV3Project,
payload: VpV3AssetCreate,
) -> VpV3Asset:
"""在项目下创建素材:
**新流程(一步到位)**
1. project 状态校验
2. source_url 格式校验
3. 将 source_url 下载保存到本地 vp_v3 上传目录(占用磁盘,校验 MIME/大小/网络)
- 失败:抛 HTTPException400/413/415/502/500),不留临时文件
4. 配额校验(素材数 + 存储 MB,用下载后的实际 file_size_bytes
- 失败:**立刻删除本地已下载的文件**,避免占用磁盘;再抛 403
5. Video 时长校验(payload.video_duration 优先,否则用 ffprobe 探测到的值;>60s 报错)
- 失败:删本地文件 → 抛 400
6. 写 VpV3AssetCreating 状态,带 next_poll_at
- 失败:删本地文件 → 抛 500
7. 调 Ark CreateAsset(url=本地公网 URL),异步审核
- 异常:status 置为 FAILED,保留本地文件(因为已占配额和素材数,走删除接口会清理)
8. 刷新项目计数 + 配额 used,返回素材
"""
# 1. project 状态校验
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail=f"项目状态 {project.status} 不可创建素材,仅 active 项目可操作")
# 2. source_url 校验(只允许公网 http(s)
_validate_source_url(payload.source_url, payload.asset_type)
downloaded: "DownloadedAsset | None" = None
try:
# 3. URL → 本地下载保存(此处负责 URL 合法性/网络/MIME/大小的校验及抛错)
downloaded = await download_url_to_local(
api_key_id=api_key_id,
asset_type=payload.asset_type,
source_url=payload.source_url,
requested_filename=payload.name,
)
file_size_bytes = downloaded.file_size_bytes
# 4. 配额校验(素材数 + 存储),这里已经拿到真实 file_size_bytes
try:
await check_asset_quota(
db,
api_key_id=api_key_id,
asset_count_delta=1,
file_size_bytes=file_size_bytes,
)
except HTTPException:
# 配额不足 → 立刻清理刚下载好的本地文件,再抛
_safe_delete_local_file(downloaded.url)
raise
# 5. Video 时长:优先用 payload.video_duration,否则用探测值
effective_video_duration: float | None = None
if payload.asset_type == PrivatePortraitAssetType.VIDEO.value:
if payload.video_duration is not None and payload.video_duration > 0:
effective_video_duration = float(payload.video_duration)
elif downloaded.duration_seconds is not None and downloaded.duration_seconds > 0:
effective_video_duration = float(downloaded.duration_seconds)
else:
_safe_delete_local_file(downloaded.url)
raise HTTPException(
status_code=400,
detail="Video 素材无法获取时长:请显式传 video_duration(秒),或确保 URL 指向合法的视频文件",
)
if effective_video_duration > 60:
_safe_delete_local_file(downloaded.url)
raise HTTPException(status_code=400, detail="视频素材时长不能超过 60 秒")
# 素材展示名:payload.name → downloaded.suggested_name → filename 去扩展名
final_name: str | None = (payload.name or "").strip()[:128] or None
if not final_name and downloaded.suggested_name:
final_name = (downloaded.suggested_name or "").strip()[:128] or None
asset = VpV3Asset(
id=generate_id(),
api_key_id=api_key_id,
project_id=project.id,
remote_project_name=project.remote_project_name,
remote_group_id=project.remote_group_id,
remote_asset_id=None,
asset_type=payload.asset_type,
name=final_name,
source_url=payload.source_url, # 本地存储后的 URL
preview_url=downloaded.url, # 初始 preview = 本地 URL
remote_url=None,
remote_url_expired_at=None,
upload_resource_id=None, # 不再使用(旧接口兼容保留字段)
video_duration=effective_video_duration,
video_cover_url=payload.video_cover_url,
file_size_bytes=file_size_bytes,
mime_type=downloaded.mime_type,
status=PrivatePortraitAssetStatus.CREATING.value,
poll_count=0,
next_poll_at=_bj_now() + timedelta(seconds=2),
)
db.add(asset)
await db.flush()
await db.refresh(asset)
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
# 任何 DB 写入前的异常 → 能清理就清理本地文件
if downloaded:
_safe_delete_local_file(downloaded.url)
logger.exception("vp_v3 创建素材(下载/写库阶段)异常:%s", exc)
raise HTTPException(status_code=500, detail=f"创建素材失败:{exc}") from exc
# 6. 调 Ark CreateAsset(到这里 DB 已经 flush 成功了)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_CREATE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.API.value,
api_key_id=api_key_id,
project_id=project.id,
asset_id=asset.id,
detail={
"remote_project_name": asset.remote_project_name,
"remote_group_id": asset.remote_group_id,
"source_url": asset.source_url,
"asset_type": asset.asset_type,
"original_source_url": payload.source_url.strip()[:500],
},
)
try:
resp = await ArkPrivateAssetClient().create_asset(
project_name=asset.remote_project_name,
group_id=asset.remote_group_id,
url=asset.source_url,
asset_type=asset.asset_type,
name=asset.name,
)
remote_asset_id = resp.get("Id") or resp.get("AssetId") or resp.get("assetId") or resp.get("id")
if not remote_asset_id:
raise RuntimeError("CreateAsset 未返回素材 Id")
asset.remote_asset_id = str(remote_asset_id)
asset.raw_response_json = _json(resp)
asset.remote_url = resp.get("URL") or resp.get("url") or resp.get("Url") or asset.remote_url
if asset.remote_url:
asset.preview_url = asset.remote_url
asset.next_poll_at = _bj_now() + timedelta(seconds=3)
asset.status = PrivatePortraitAssetStatus.CREATING.value
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_CREATE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
api_key_id=api_key_id,
project_id=project.id,
asset_id=asset.id,
detail={"remote_asset_id": remote_asset_id},
)
await refresh_project_counters(db, [project.id])
_ = await get_quota(db, api_key_id=api_key_id, refresh=True)
return asset
except Exception as exc: # noqa: BLE001
# 火山调用失败 → 保留本地文件(DB 已写好,走删除接口清理),状态 FAILED,带错误
asset.status = PrivatePortraitAssetStatus.FAILED.value
asset.error_message = str(exc)
asset.raw_response_json = _json({"error": str(exc)})
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_CREATE_REMOTE_FAILED.value,
source=PrivatePortraitEventSource.API.value,
api_key_id=api_key_id,
project_id=project.id,
asset_id=asset.id,
exc=exc,
)
raise HTTPException(status_code=502, detail=f"提交火山素材创建失败:{exc}") from exc
def _safe_delete_local_file(local_url: str | None) -> None:
if not local_url:
return
try:
delete_local_file_by_url(local_url)
except Exception: # noqa: BLE001
logger.warning("vp_v3 清理本地文件失败(不抛):%s", local_url)
async def list_assets(
db: AsyncSession,
*,
api_key_id: str,
project_id: str | None = None,
status: str | None = None,
keyword: str | None = None,
asset_type: str | None = None,
page: int,
page_size: int,
) -> tuple[list[VpV3Asset], int]:
"""分页查询素材列表。"""
conds = [VpV3Asset.api_key_id == api_key_id, VpV3Asset.deleted_at.is_(None)]
if project_id:
conds.append(VpV3Asset.remote_group_id == project_id)
if status:
conds.append(VpV3Asset.status == status)
if keyword:
conds.append((VpV3Asset.name.is_not(None)) & (VpV3Asset.name.ilike(f"%{keyword}%")))
if asset_type:
conds.append(VpV3Asset.asset_type == asset_type)
count_result = await db.execute(select(func.count(VpV3Asset.id)).where(*conds))
total = int(count_result.scalar() or 0)
q = (
select(VpV3Asset)
.where(*conds)
.order_by(VpV3Asset.created_at.desc())
.limit(page_size)
.offset((page - 1) * page_size)
)
items = list((await db.execute(q)).scalars().all())
return items, total
async def list_selectable_assets(
db: AsyncSession,
*,
api_key_id: str,
project_id: str | None = None,
keyword: str | None = None,
asset_type: str | None = None,
page: int,
page_size: int,
) -> tuple[list[VpV3Asset], int]:
"""AI 创作选择器素材列表:只返回 status=Active 的。"""
items, total = await list_assets(
db,
api_key_id=api_key_id,
project_id=project_id,
status=PrivatePortraitAssetStatus.ACTIVE.value,
keyword=keyword,
asset_type=asset_type,
page=page,
page_size=page_size,
)
return items, total
async def get_asset(db: AsyncSession, *, api_key_id: str, asset_id: str) -> VpV3Asset:
"""素材详情(权限校验)。"""
row = (await db.execute(
select(VpV3Asset).where(
VpV3Asset.remote_asset_id == asset_id,
VpV3Asset.api_key_id == api_key_id,
VpV3Asset.deleted_at.is_(None),
).limit(1)
)).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="虚拟素材不存在")
return row
async def sync_asset_status(
db: AsyncSession,
*,
api_key_id: str,
asset_id: str,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> VpV3Asset:
"""主动同步素材状态(调 Ark GetAsset)。
注意:如果素材没有 remote_asset_id(远端还未 CreateAsset 返回),直接跳过并返回当前本地快照。
"""
asset = await get_asset(db, api_key_id=api_key_id, asset_id=asset_id)
if not asset.remote_asset_id:
return asset
try:
resp = await ArkPrivateAssetClient().get_asset(
project_name=asset.remote_project_name, asset_id=asset.remote_asset_id,
)
if execution_guard is not None:
await execution_guard()
_apply_get_asset_response(asset, resp)
except Exception as exc: # noqa: BLE001
# 异常分支也必须推进 poll 计数 + 重算下次轮询时间,避免无限调度且数据库无变化
asset.last_poll_at = _bj_now()
asset.poll_count = int(asset.poll_count or 0) + 1
asset.error_message = f"同步状态失败:{exc}"
logger.warning("vp_v3 同步素材状态失败:asset_id=%s err=%s", asset_id, exc)
# 异常情况仍然保持 CREATING,按指数退避重算 next_poll_at
delays = [3, 6, 12, 30, 60]
idx = min(asset.poll_count, len(delays) - 1)
asset.next_poll_at = _bj_now() + timedelta(seconds=delays[idx])
finally:
await db.flush()
await refresh_project_counters(db, [asset.project_id])
return asset
def _apply_get_asset_response(a: VpV3Asset, resp: dict) -> None:
"""把 Ark GetAsset 响应应用到本地记录(状态、URL、审核信息)。"""
# 先推进公共轮询字段(无论状态映射结果如何,只要调了一次 GetAsset 都必须记录)
a.last_poll_at = _bj_now()
a.poll_count = int(a.poll_count or 0) + 1
a.moderation_json = _json(resp)
a.raw_response_json = _json(resp)
# Status 映射:火山 Status 字段 → 本地枚举
status_raw = str(resp.get("Status") or resp.get("status") or "").lower()
if status_raw in {"active", "success", "done", "available"}:
a.status = PrivatePortraitAssetStatus.ACTIVE.value
elif status_raw in {"creating", "pending", "processing", "auditing"}:
a.status = PrivatePortraitAssetStatus.CREATING.value
elif status_raw in {"failed", "error", "rejected", "invalid"}:
a.status = PrivatePortraitAssetStatus.FAILED.value
msg = resp.get("Message") or resp.get("message") or resp.get("Error") or resp.get("error")
if msg:
a.error_message = str(msg)
else:
# 未知状态保持原
pass
# URL 续期
url = resp.get("URL") or resp.get("url") or resp.get("Url")
if url:
a.remote_url = url
a.preview_url = url
a.remote_url_expired_at = None # 无法解析过期时间就不填
# 视频时长
if not a.video_duration:
dur = resp.get("Duration") or resp.get("duration")
if dur is not None:
try:
a.video_duration = float(dur)
except Exception: # noqa: BLE001
pass
# 大小
if not a.file_size_bytes:
size = resp.get("FileSize") or resp.get("fileSize") or resp.get("size")
if size is not None:
try:
a.file_size_bytes = int(size)
except Exception: # noqa: BLE001
pass
# 状态判断下次轮询时间
if a.status == PrivatePortraitAssetStatus.CREATING.value:
# 指数退避:3s → 6s → 12s → 30s → 60s,最多 60s
delays = [3, 6, 12, 30, 60]
idx = min(a.poll_count, len(delays) - 1)
a.next_poll_at = _bj_now() + timedelta(seconds=delays[idx])
elif a.status == PrivatePortraitAssetStatus.FAILED.value:
a.next_poll_at = None # 失败不再轮询
elif a.status == PrivatePortraitAssetStatus.ACTIVE.value:
a.next_poll_at = None # 成功不再轮询
async def soft_delete_asset(db: AsyncSession, *, api_key_id: str, asset_id: str) -> VpV3Asset:
"""软删素材(本地先标记为删除中,同步删除本地落盘文件,重新计算项目计数和配额 used,然后 commit 后再投递异步远端删除任务)。"""
asset = await get_asset(db, api_key_id=api_key_id, asset_id=asset_id)
pid = asset.project_id
now = _bj_now()
asset.deleted_at = now
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.PENDING.value
asset.status = PrivatePortraitAssetStatus.DELETING.value
# 本地落盘文件:立刻删(成功失败都不影响状态,避免占磁盘;失败仅 log)
if asset.source_url:
_safe_delete_local_file(asset.source_url)
await db.flush()
await refresh_project_counters(db, [pid])
q = await get_quota(db, api_key_id=api_key_id, refresh=True)
return asset
# V3 专属的远端删除服务
V3_DOMAIN = "virtual_portrait_v3"
async def _load_v3_asset_delete_snapshot(db: AsyncSession, *, asset_id: str) -> dict | None:
"""加载 V3 素材删除快照。"""
asset = (
await db.execute(
select(VpV3Asset).where(VpV3Asset.remote_asset_id == asset_id).limit(1)
)
).scalar_one_or_none()
if not asset:
return None
return {
"owner_id": str(asset.id),
"owner_type": "asset",
"api_key_id": str(asset.api_key_id),
"project_id": str(asset.project_id),
"remote_id": str(asset.remote_asset_id) if asset.remote_asset_id else None,
"remote_project_name": str(asset.remote_project_name or ""),
"asset_type": str(asset.asset_type or ""),
"remote_delete_status": str(asset.remote_delete_status or ""),
}
async def _apply_v3_asset_delete_result(
db: AsyncSession,
*,
asset_id: str,
remote_id: str | None,
succeeded: bool,
skipped: bool = False,
error: BaseException | None = None,
) -> None:
"""应用 V3 素材远端删除结果到数据库。"""
asset = (
await db.execute(
select(VpV3Asset).where(VpV3Asset.id == asset_id).with_for_update().limit(1)
)
).scalar_one_or_none()
if not asset:
return
if asset.remote_delete_status in {
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
}:
return
if remote_id and str(asset.remote_asset_id or "") != remote_id:
raise RuntimeError("V3 素材远程 Asset 已变化,旧删除结果已丢弃")
now = _bj_now()
if skipped:
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
asset.remote_delete_error = None
elif succeeded:
asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
asset.remote_deleted_at = now
asset.remote_delete_error = None
else:
asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
asset.remote_delete_error = str(error or "远程删除失败")
await db.flush()
async def delete_v3_asset_remote(
db: AsyncSession,
*,
asset_id: str,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> None:
"""V3 素材远端删除(异步 Celery 任务调用)。"""
snapshot = await _load_v3_asset_delete_snapshot(db, asset_id=asset_id)
if snapshot is None:
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
asset_id=asset_id,
message="远程删除跳过:本地素材不存在",
)
await db.rollback()
return
if snapshot["remote_delete_status"] in {
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
}:
await db.rollback()
return
remote_id = snapshot["remote_id"]
if not remote_id:
await _apply_v3_asset_delete_result(
db,
asset_id=asset_id,
remote_id=None,
succeeded=False,
skipped=True,
)
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=snapshot["project_id"],
asset_id=asset_id,
message="远程删除跳过:素材没有 remote_asset_id",
)
return
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=snapshot["project_id"],
asset_id=asset_id,
detail={
"remote_asset_id": remote_id,
"remote_project_name": snapshot["remote_project_name"],
"asset_type": snapshot["asset_type"],
},
)
await db.rollback()
remote_error: BaseException | None = None
succeeded = False
try:
await ArkPrivateAssetClient(for_celery=True).delete_asset(
project_name=snapshot["remote_project_name"],
asset_id=remote_id,
)
succeeded = True
except Exception as exc:
remote_error = exc
# 404 视为幂等成功
if "not found" in str(exc).lower() or "404" in str(exc):
succeeded = True
if execution_guard is not None:
await execution_guard()
await _apply_v3_asset_delete_result(
db,
asset_id=asset_id,
remote_id=remote_id,
succeeded=succeeded,
error=remote_error,
)
if succeeded:
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=snapshot["project_id"],
asset_id=asset_id,
message="远程资源不存在,按幂等删除成功处理" if remote_error is not None else None,
detail={
"remote_asset_id": remote_id,
"remote_project_name": snapshot["remote_project_name"],
},
)
else:
assert remote_error is not None
log_operation_error(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_FAILED.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=snapshot["project_id"],
asset_id=asset_id,
exc=remote_error,
)