feat(ai): 自由创作视频生成全量移植(/free-create)——Seedance 文/图生视频+按时长计费+人物素材库
后端: - free_video.py: 提交/轮询/收藏/软删全链路,复用 AITask(新增 is_deleted/is_favorited, migration 0022) - video_pricing.py: 按时长 token 计费(×1.10 buffer+clamp); video_errors.py 错误归一; media_probe.py 时长探测 - catalog/volcano: Seedance free-video 模型接入+seed(migration 0023) - 素材库: FreeAssetGroup/FreeAsset(火山 Assets API 引用登记, migration 0009)+ 上传/轮询/删除接口 - settings: FREE_VIDEO_MAX_CONCURRENT 团队并发闸(默认3); CELERY_TASK_ALWAYS_EAGER 本地联调开关(生产恒关) - 测试: test_free_video.py 新增; billing/products/projects tests 配套调整 前端: - /free-create 页面+components/free-create/ 全套(输入栏/@mention 素材引用/生成卡/视频详情弹窗/素材库弹窗) - api.ts/types.ts 扩展 free-video 与 free-assets 接口; 路由/侧边栏入口接入 bug/: 测试清单 (11)(12) 与截图归档 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,7 +59,11 @@ def _get_service():
|
||||
Credentials(ak, sk, SERVICE, REGION),
|
||||
10, 30,
|
||||
)
|
||||
actions = ["CreateAssetGroup", "CreateAsset", "ListAssetGroups", "ListAssets", "GetAsset", "DeleteAsset"]
|
||||
actions = [
|
||||
"CreateAssetGroup", "CreateAsset", "ListAssetGroups", "ListAssets", "GetAsset", "DeleteAsset",
|
||||
# 自由创作素材库(组/素材全生命周期)
|
||||
"GetAssetGroup", "UpdateAssetGroup", "UpdateAsset", "DeleteAssetGroup",
|
||||
]
|
||||
api_info = {a: ApiInfo("POST", "/", {"Action": a, "Version": API_VERSION}, {}, {}) for a in actions}
|
||||
return Service(service_info, api_info)
|
||||
|
||||
@@ -117,3 +121,49 @@ def list_asset_groups(page: int = 1, page_size: int = 20, name: str | None = Non
|
||||
{"Filter": filter_dict, "PageNumber": page, "PageSize": page_size, "ProjectName": _project()},
|
||||
)
|
||||
return result.get("Items", []), result.get("TotalCount", 0)
|
||||
|
||||
|
||||
def list_assets(group_ids: list | None = None, status: str | None = None,
|
||||
name: str | None = None, page: int = 1, page_size: int = 20) -> tuple:
|
||||
"""列组内素材。返回 (items, total_count)。"""
|
||||
filter_dict: dict = {"GroupType": "AIGC"}
|
||||
if group_ids:
|
||||
filter_dict["GroupIds"] = group_ids
|
||||
if status:
|
||||
filter_dict["Statuses"] = [status]
|
||||
if name:
|
||||
filter_dict["Name"] = name
|
||||
result = _do_request(
|
||||
"ListAssets",
|
||||
{"Filter": filter_dict, "PageNumber": page, "PageSize": page_size, "ProjectName": _project()},
|
||||
)
|
||||
return result.get("Items", []), result.get("TotalCount", 0)
|
||||
|
||||
|
||||
def get_asset_group(group_id: str) -> dict:
|
||||
return _do_request("GetAssetGroup", {"Id": group_id, "ProjectName": _project()})
|
||||
|
||||
|
||||
def update_asset_group(group_id: str, name: str | None = None, description: str | None = None) -> None:
|
||||
body: dict = {"Id": group_id, "ProjectName": _project()}
|
||||
if name is not None:
|
||||
body["Name"] = name
|
||||
if description is not None:
|
||||
body["Description"] = description
|
||||
_do_request("UpdateAssetGroup", body)
|
||||
|
||||
|
||||
def update_asset(asset_id: str, name: str | None = None) -> None:
|
||||
body: dict = {"Id": asset_id, "ProjectName": _project()}
|
||||
if name is not None:
|
||||
body["Name"] = name
|
||||
_do_request("UpdateAsset", body)
|
||||
|
||||
|
||||
def delete_asset(asset_id: str) -> None:
|
||||
_do_request("DeleteAsset", {"Id": asset_id, "ProjectName": _project()})
|
||||
|
||||
|
||||
def delete_asset_group(group_id: str) -> None:
|
||||
"""删组(远程级联删组内素材)。"""
|
||||
_do_request("DeleteAssetGroup", {"Id": group_id, "ProjectName": _project()})
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""自由创作·人物素材库端点(FreeAssetGroup / FreeAsset ←→ 火山 Assets API)。
|
||||
|
||||
移植自 jimeng-clone 素材库:建组=火山 CreateAssetGroup+本地记录;传素材=TOS 上传→CreateAsset(URL)
|
||||
→FreeAsset(processing);状态轮询=GetAsset 刷新 active/failed;删除=远程 NotFound 幂等继续清本地。
|
||||
生成时以 asset://{remote_asset_id} 引用(见 apps/ai/free_video.build_content_items)。
|
||||
"""
|
||||
import logging
|
||||
import tempfile
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.common.api import get_current_team
|
||||
|
||||
from . import assets_client
|
||||
from .assets_client import AssetsAPIError
|
||||
from .models import FreeAsset, FreeAssetGroup
|
||||
from .storage import TosStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||
_VIDEO_TYPES = {"video/mp4": ".mp4", "video/quicktime": ".mov"}
|
||||
_AUDIO_TYPES = {"audio/mpeg": ".mp3", "audio/wav": ".wav", "audio/x-wav": ".wav", "audio/wave": ".wav"}
|
||||
_IMAGE_MAX = 30 * 1024 * 1024
|
||||
_VIDEO_MAX = 50 * 1024 * 1024
|
||||
_AUDIO_MAX = 15 * 1024 * 1024
|
||||
|
||||
|
||||
def _serialize_asset(fa: FreeAsset) -> dict:
|
||||
return {
|
||||
"id": str(fa.id),
|
||||
"name": fa.name,
|
||||
"url": fa.url,
|
||||
"type": fa.asset_type.lower(),
|
||||
"thumb_url": fa.thumbnail_url or (fa.url if fa.asset_type == FreeAsset.Type.IMAGE else ""),
|
||||
"duration": fa.duration,
|
||||
"status": fa.status,
|
||||
"error_message": fa.error_message,
|
||||
"created_at": fa.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_group(group: FreeAssetGroup, asset_count: int | None = None) -> dict:
|
||||
return {
|
||||
"id": str(group.id),
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"thumbnail_url": group.thumbnail_url,
|
||||
"asset_count": asset_count if asset_count is not None else group.assets.count(),
|
||||
"created_at": group.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _assets_api_unavailable() -> Response:
|
||||
return Response({"detail": "素材库服务未配置,请联系管理员"}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
|
||||
|
||||
class FreeAssetGroupListView(APIView):
|
||||
"""GET 列素材组 / POST 建组。"""
|
||||
|
||||
def get(self, request):
|
||||
team = get_current_team(request.user)
|
||||
groups = FreeAssetGroup.objects.filter(team=team, is_deleted=False).order_by("-created_at")
|
||||
counts = {str(g.id): g.assets.count() for g in groups}
|
||||
return Response({"results": [_serialize_group(g, counts[str(g.id)]) for g in groups]})
|
||||
|
||||
def post(self, request):
|
||||
if not assets_client.is_enabled():
|
||||
return _assets_api_unavailable()
|
||||
team = get_current_team(request.user)
|
||||
name = str(request.data.get("name") or "").strip()
|
||||
description = str(request.data.get("description") or "").strip()
|
||||
if not name:
|
||||
return Response({"detail": "素材组名称不能为空"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
remote_group_id = assets_client.create_asset_group(name, description)
|
||||
except AssetsAPIError as exc:
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
group = FreeAssetGroup.objects.create(
|
||||
team=team, created_by=request.user, name=name, description=description, remote_group_id=remote_group_id
|
||||
)
|
||||
return Response({"group": _serialize_group(group, 0)}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class FreeAssetGroupDetailView(APIView):
|
||||
"""GET 组详情+素材列表 / PATCH 改名 / DELETE 删组(远程级联,NotFound 幂等清本地)。"""
|
||||
|
||||
def _get_group(self, request, group_id) -> FreeAssetGroup | None:
|
||||
team = get_current_team(request.user)
|
||||
return FreeAssetGroup.objects.filter(id=group_id, team=team, is_deleted=False).first()
|
||||
|
||||
def get(self, request, group_id):
|
||||
group = self._get_group(request, group_id)
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
assets = list(group.assets.order_by("-created_at"))
|
||||
return Response({"group": _serialize_group(group, len(assets)), "assets": [_serialize_asset(a) for a in assets]})
|
||||
|
||||
def patch(self, request, group_id):
|
||||
group = self._get_group(request, group_id)
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
name = request.data.get("name")
|
||||
description = request.data.get("description")
|
||||
update_fields = ["updated_at"]
|
||||
if name is not None and str(name).strip():
|
||||
group.name = str(name).strip()
|
||||
update_fields.append("name")
|
||||
if description is not None:
|
||||
group.description = str(description)
|
||||
update_fields.append("description")
|
||||
if len(update_fields) == 1:
|
||||
return Response({"group": _serialize_group(group)})
|
||||
try:
|
||||
assets_client.update_asset_group(group.remote_group_id, name=group.name, description=group.description)
|
||||
except AssetsAPIError as exc:
|
||||
if exc.code != "NotFound":
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
group.save(update_fields=update_fields)
|
||||
return Response({"group": _serialize_group(group)})
|
||||
|
||||
def delete(self, request, group_id):
|
||||
group = self._get_group(request, group_id)
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
assets_client.delete_asset_group(group.remote_group_id)
|
||||
except AssetsAPIError as exc:
|
||||
# 远程已不存在 → 幂等继续清本地;其它错误如实反馈
|
||||
if exc.code != "NotFound":
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
group.is_deleted = True
|
||||
group.save(update_fields=["is_deleted", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class FreeAssetUploadView(APIView):
|
||||
"""POST /free-groups/<id>/assets/ —— 上传素材进组:TOS → 火山 CreateAsset → FreeAsset(processing)。"""
|
||||
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request, group_id):
|
||||
if not assets_client.is_enabled():
|
||||
return _assets_api_unavailable()
|
||||
team = get_current_team(request.user)
|
||||
group = FreeAssetGroup.objects.filter(id=group_id, team=team, is_deleted=False).first()
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "缺少文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
content_type = (upload.content_type or "").lower()
|
||||
size = upload.size or 0
|
||||
if content_type in _IMAGE_TYPES:
|
||||
kind, suffix, asset_type = "image", _IMAGE_TYPES[content_type], FreeAsset.Type.IMAGE
|
||||
if size > _IMAGE_MAX:
|
||||
return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif content_type in _VIDEO_TYPES:
|
||||
kind, suffix, asset_type = "video", _VIDEO_TYPES[content_type], FreeAsset.Type.VIDEO
|
||||
if size > _VIDEO_MAX:
|
||||
return Response({"detail": "视频大小不能超过 50MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif content_type in _AUDIO_TYPES:
|
||||
kind, suffix, asset_type = "audio", _AUDIO_TYPES[content_type], FreeAsset.Type.AUDIO
|
||||
if size > _AUDIO_MAX:
|
||||
return Response({"detail": "音频大小不能超过 15MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
{"detail": "不支持的文件格式(图片 JPG/PNG/WebP,视频 MP4/MOV,音频 MP3/WAV)"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
raw = upload.read()
|
||||
duration = None
|
||||
thumb_url = ""
|
||||
storage = TosStorage()
|
||||
file_id = uuid.uuid4()
|
||||
|
||||
if kind != "image":
|
||||
from apps.ai.media_probe import extract_video_poster, probe_duration
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="airshelf-fc-lib-") as tmp:
|
||||
tmp_path = Path(tmp) / f"in{suffix}"
|
||||
tmp_path.write_bytes(raw)
|
||||
duration = probe_duration(str(tmp_path))
|
||||
if duration is None:
|
||||
return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not (2 <= duration <= 15):
|
||||
label = "视频" if kind == "video" else "音频"
|
||||
return Response({"detail": f"{label}时长需在 2-15 秒之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if kind == "video":
|
||||
poster = extract_video_poster(str(tmp_path))
|
||||
if poster:
|
||||
poster_key = f"teams/{team.id}/free-create/library/{file_id}-poster.jpg"
|
||||
storage.upload_fileobj(fileobj=BytesIO(poster), object_key=poster_key, content_type="image/jpeg")
|
||||
thumb_url = storage.public_url(object_key=poster_key)
|
||||
|
||||
object_key = f"teams/{team.id}/free-create/library/{file_id}{suffix}"
|
||||
stored = storage.upload_fileobj(fileobj=BytesIO(raw), object_key=object_key, content_type=content_type)
|
||||
url = storage.public_url(object_key=stored.object_key)
|
||||
|
||||
name = str(request.data.get("name") or "").strip() or (upload.name or f"素材{suffix}")
|
||||
name = name[:128]
|
||||
try:
|
||||
remote_asset_id = assets_client.create_asset(
|
||||
group.remote_group_id, url, name=name, asset_type=asset_type
|
||||
)
|
||||
except AssetsAPIError as exc:
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
fa = FreeAsset.objects.create(
|
||||
group=group,
|
||||
remote_asset_id=remote_asset_id,
|
||||
name=name,
|
||||
url=url,
|
||||
asset_type=asset_type,
|
||||
thumbnail_url=thumb_url or (url if kind == "image" else ""),
|
||||
duration=duration,
|
||||
status=FreeAsset.Status.PROCESSING,
|
||||
)
|
||||
if not group.thumbnail_url and fa.thumbnail_url:
|
||||
group.thumbnail_url = fa.thumbnail_url
|
||||
group.save(update_fields=["thumbnail_url", "updated_at"])
|
||||
return Response({"asset": _serialize_asset(fa)}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class FreeAssetDetailView(APIView):
|
||||
"""PATCH 改名 / DELETE 删素材(远程 NotFound 幂等清本地)。"""
|
||||
|
||||
def _get_asset(self, request, asset_id) -> FreeAsset | None:
|
||||
team = get_current_team(request.user)
|
||||
return FreeAsset.objects.filter(id=asset_id, group__team=team, group__is_deleted=False).select_related("group").first()
|
||||
|
||||
def patch(self, request, asset_id):
|
||||
fa = self._get_asset(request, asset_id)
|
||||
if fa is None:
|
||||
return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
name = str(request.data.get("name") or "").strip()
|
||||
if not name:
|
||||
return Response({"detail": "素材名称不能为空"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
assets_client.update_asset(fa.remote_asset_id, name=name)
|
||||
except AssetsAPIError as exc:
|
||||
if exc.code != "NotFound":
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
fa.name = name[:128]
|
||||
fa.save(update_fields=["name", "updated_at"])
|
||||
return Response({"asset": _serialize_asset(fa)})
|
||||
|
||||
def delete(self, request, asset_id):
|
||||
fa = self._get_asset(request, asset_id)
|
||||
if fa is None:
|
||||
return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
assets_client.delete_asset(fa.remote_asset_id)
|
||||
except AssetsAPIError as exc:
|
||||
if exc.code != "NotFound":
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
fa.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class FreeAssetPollView(APIView):
|
||||
"""POST /free-assets/<id>/poll/ —— 查火山刷新审核状态(processing → active/failed)。"""
|
||||
|
||||
def post(self, request, asset_id):
|
||||
team = get_current_team(request.user)
|
||||
fa = FreeAsset.objects.filter(id=asset_id, group__team=team, group__is_deleted=False).first()
|
||||
if fa is None:
|
||||
return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if fa.status == FreeAsset.Status.PROCESSING:
|
||||
try:
|
||||
result = assets_client.get_asset(fa.remote_asset_id)
|
||||
remote_status = str(result.get("Status") or "")
|
||||
if remote_status == "Active":
|
||||
fa.status = FreeAsset.Status.ACTIVE
|
||||
fa.url = result.get("Url") or fa.url
|
||||
fa.save(update_fields=["status", "url", "updated_at"])
|
||||
elif remote_status == "Failed":
|
||||
fa.status = FreeAsset.Status.FAILED
|
||||
fa.error_message = str(result.get("ErrorMessage") or "素材审核未通过")
|
||||
fa.save(update_fields=["status", "error_message", "updated_at"])
|
||||
except AssetsAPIError as exc:
|
||||
if exc.code == "NotFound":
|
||||
fa.status = FreeAsset.Status.FAILED
|
||||
fa.error_message = "素材在远程已不存在"
|
||||
fa.save(update_fields=["status", "error_message", "updated_at"])
|
||||
else:
|
||||
logger.warning("free asset %s poll failed: %s", asset_id, exc)
|
||||
return Response({"asset": _serialize_asset(fa)})
|
||||
@@ -0,0 +1,135 @@
|
||||
# Generated by Django 5.1.15 on 2026-07-02 07:23
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0007_team_monthly_credit_limit"),
|
||||
("assets", "0008_backfill_workbench_in_library"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FreeAssetGroup",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=128)),
|
||||
("description", models.TextField(blank=True)),
|
||||
("remote_group_id", models.CharField(max_length=128)),
|
||||
("thumbnail_url", models.URLField(blank=True)),
|
||||
("is_deleted", models.BooleanField(default=False)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="created_%(class)s_set",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"team",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="%(class)s_set",
|
||||
to="accounts.team",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="FreeAsset",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("remote_asset_id", models.CharField(max_length=128)),
|
||||
("name", models.CharField(blank=True, max_length=128)),
|
||||
("url", models.URLField(blank=True, max_length=1024)),
|
||||
(
|
||||
"asset_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("Image", "Image"),
|
||||
("Video", "Video"),
|
||||
("Audio", "Audio"),
|
||||
],
|
||||
default="Image",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("thumbnail_url", models.URLField(blank=True, max_length=1024)),
|
||||
("duration", models.FloatField(blank=True, null=True)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("processing", "Processing"),
|
||||
("active", "Active"),
|
||||
("failed", "Failed"),
|
||||
],
|
||||
default="processing",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("error_message", models.TextField(blank=True)),
|
||||
(
|
||||
"group",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="assets",
|
||||
to="assets.freeassetgroup",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="freeassetgroup",
|
||||
index=models.Index(
|
||||
fields=["team", "-created_at"], name="assets_free_team_id_c0cbb2_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="freeasset",
|
||||
index=models.Index(
|
||||
fields=["group", "-created_at"], name="assets_free_group_i_b2a9ec_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="freeasset",
|
||||
index=models.Index(
|
||||
fields=["remote_asset_id"], name="assets_free_remote__49909d_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -178,3 +178,61 @@ class AssetUsage(TimeStampedModel):
|
||||
usage_type = models.CharField(max_length=64)
|
||||
context = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
class FreeAssetGroup(TeamOwnedModel):
|
||||
"""自由创作·人物素材组(一角色一组)。
|
||||
|
||||
本质是「火山 Assets API 引用登记表」:组内素材登记到火山素材库拿 remote_asset_id,
|
||||
生成时以 asset://{remote_asset_id} 引用(免重复上传 + 火山侧预审)。
|
||||
与 AssetReviewGroup(一团队一审核组)职责不同,与资产库 Asset(TOS 文件资产)也不同,独立建表。
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=128)
|
||||
description = models.TextField(blank=True)
|
||||
remote_group_id = models.CharField(max_length=128)
|
||||
thumbnail_url = models.URLField(blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["team", "-created_at"]),
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"free-group:{self.name}"
|
||||
|
||||
|
||||
class FreeAsset(TimeStampedModel):
|
||||
"""自由创作·素材组内单素材(火山 Assets API 登记项)。"""
|
||||
|
||||
class Type(models.TextChoices):
|
||||
IMAGE = "Image", "Image"
|
||||
VIDEO = "Video", "Video"
|
||||
AUDIO = "Audio", "Audio"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PROCESSING = "processing", "Processing" # 火山侧处理/审核中
|
||||
ACTIVE = "active", "Active" # 可用,可 asset:// 引用
|
||||
FAILED = "failed", "Failed" # 审核/处理失败
|
||||
|
||||
group = models.ForeignKey(FreeAssetGroup, on_delete=models.CASCADE, related_name="assets")
|
||||
remote_asset_id = models.CharField(max_length=128)
|
||||
name = models.CharField(max_length=128, blank=True)
|
||||
url = models.URLField(max_length=1024, blank=True) # TOS 公读直链(火山从这里拉源文件)
|
||||
asset_type = models.CharField(max_length=16, choices=Type.choices, default=Type.IMAGE)
|
||||
thumbnail_url = models.URLField(max_length=1024, blank=True)
|
||||
duration = models.FloatField(null=True, blank=True) # 视频/音频时长(秒)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.PROCESSING)
|
||||
error_message = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["group", "-created_at"]),
|
||||
models.Index(fields=["remote_asset_id"]),
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"free-asset:{self.name or self.remote_asset_id}"
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .free_library import (
|
||||
FreeAssetDetailView,
|
||||
FreeAssetGroupDetailView,
|
||||
FreeAssetGroupListView,
|
||||
FreeAssetPollView,
|
||||
FreeAssetUploadView,
|
||||
)
|
||||
from .views import AssetUploadView, AssetViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
@@ -8,4 +15,10 @@ router.register("", AssetViewSet, basename="asset")
|
||||
|
||||
urlpatterns = [
|
||||
path("upload/", AssetUploadView.as_view(), name="asset-upload"),
|
||||
# 自由创作·人物素材库(火山 Assets API 引用登记)。必须在 router 通配之前注册。
|
||||
path("free-groups/", FreeAssetGroupListView.as_view(), name="free-asset-group-list"),
|
||||
path("free-groups/<uuid:group_id>/", FreeAssetGroupDetailView.as_view(), name="free-asset-group-detail"),
|
||||
path("free-groups/<uuid:group_id>/assets/", FreeAssetUploadView.as_view(), name="free-asset-upload"),
|
||||
path("free-assets/<uuid:asset_id>/", FreeAssetDetailView.as_view(), name="free-asset-detail"),
|
||||
path("free-assets/<uuid:asset_id>/poll/", FreeAssetPollView.as_view(), name="free-asset-poll"),
|
||||
] + router.urls
|
||||
|
||||
Reference in New Issue
Block a user