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:
@@ -2,6 +2,7 @@ from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
@@ -308,6 +309,253 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
return Response({"conversation_id": str(conversation.id), "tasks": data})
|
||||
|
||||
|
||||
def _free_video_task_queryset(team):
|
||||
return (
|
||||
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=False)
|
||||
.select_related("model_config")
|
||||
.prefetch_related("generated_assets", "generated_assets__files")
|
||||
)
|
||||
|
||||
|
||||
class FreeVideoView(APIView):
|
||||
"""自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。
|
||||
|
||||
POST /api/ai/free-video/ 提交任务,秒回(火山 create 同步调、轮询交给 worker 兜底 + 前端主动 poll)
|
||||
GET /api/ai/free-video/ 任务流分页(offset/page_size,新→旧)
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
require_worker() # 生成闸:无 worker 时任务提交火山后无人兜底轮询(额度冻结、结果丢失)
|
||||
from .free_video import serialize_free_video_task, submit_free_video
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
task = submit_free_video(team=team, user=request.user, params=request.data or {})
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# 重取带 prefetch 的实例,序列化统一走同一条路
|
||||
task = _free_video_task_queryset(team).get(id=task.id)
|
||||
return Response({"task": serialize_free_video_task(task)}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
def get(self, request):
|
||||
from .free_video import serialize_free_video_task
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
offset = max(0, int(request.query_params.get("offset") or 0))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
try:
|
||||
page_size = min(50, max(1, int(request.query_params.get("page_size") or 20)))
|
||||
except (TypeError, ValueError):
|
||||
page_size = 20
|
||||
qs = _free_video_task_queryset(team).order_by("-created_at")
|
||||
total = qs.count()
|
||||
tasks = list(qs[offset : offset + page_size])
|
||||
return Response(
|
||||
{
|
||||
"results": [serialize_free_video_task(t) for t in tasks],
|
||||
"total": total,
|
||||
"has_more": offset + page_size < total,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FreeVideoPollView(APIView):
|
||||
"""POST /api/ai/free-video/<id>/poll/ —— web 进程内单次轮询+终态化(幂等)。
|
||||
前端渐进轮询打这里;本地无 worker 也能全程收尾(与 pipeline poll-video-segment 同模式)。"""
|
||||
|
||||
def post(self, request, task_id):
|
||||
from .free_video import finalize_free_video, serialize_free_video_task
|
||||
|
||||
team = get_current_team(request.user)
|
||||
task = _free_video_task_queryset(team).filter(id=task_id).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||||
try:
|
||||
task = finalize_free_video(task=task)
|
||||
except Exception: # noqa: BLE001 — 单次轮询失败(网络抖动)不终结任务,返回现状继续轮
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning("free video poll failed for %s", task_id, exc_info=True)
|
||||
# 终态后重取(finalize 里可能新建了资产)
|
||||
task = _free_video_task_queryset(team).get(id=task.id)
|
||||
return Response({"task": serialize_free_video_task(task)})
|
||||
|
||||
|
||||
class FreeVideoFavoriteView(APIView):
|
||||
"""POST /api/ai/free-video/<id>/favorite/ —— 收藏开关。"""
|
||||
|
||||
def post(self, request, task_id):
|
||||
team = get_current_team(request.user)
|
||||
task = AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
task.is_favorited = not task.is_favorited
|
||||
task.save(update_fields=["is_favorited", "updated_at"])
|
||||
return Response({"is_favorited": task.is_favorited})
|
||||
|
||||
|
||||
class FreeVideoDetailView(APIView):
|
||||
"""DELETE /api/ai/free-video/<id>/ —— 软删(在途任务拒删,等终态)。"""
|
||||
|
||||
def delete(self, request, task_id):
|
||||
team = get_current_team(request.user)
|
||||
task = AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING, AITask.Status.POSTPROCESSING):
|
||||
return Response({"detail": "任务生成中,请等待完成后再删除"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
task.is_deleted = True
|
||||
task.save(update_fields=["is_deleted", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# 上传参考素材的格式/尺寸限制(与 jimeng inputBar 校验对齐;后端兜底,前端也拦)
|
||||
_FREE_REF_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
_FREE_REF_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
|
||||
_FREE_REF_AUDIO_TYPES = {"audio/mpeg", "audio/wav", "audio/x-wav", "audio/wave"}
|
||||
_FREE_REF_IMAGE_MAX = 30 * 1024 * 1024
|
||||
_FREE_REF_VIDEO_MAX = 50 * 1024 * 1024
|
||||
_FREE_REF_AUDIO_MAX = 15 * 1024 * 1024
|
||||
|
||||
|
||||
class FreeVideoUploadView(APIView):
|
||||
"""POST /api/ai/free-video/upload/ —— 参考素材上传(图/视频/音频)。
|
||||
|
||||
校验(图 300-6000px、比例(0.4,2.5)、≤30MB;视频 mp4/mov ≤50MB、2-15s;音频 mp3/wav ≤15MB、2-15s)
|
||||
→ TOS → Asset(source=UPLOAD, in_library=False) → {asset_id,url,type,duration,thumb_url}。
|
||||
视频顺带 ffmpeg 抽首帧缩略图。"""
|
||||
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request):
|
||||
import tempfile
|
||||
import uuid as _uuid
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
from .media_probe import extract_video_poster, probe_duration
|
||||
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "缺少文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
team = get_current_team(request.user)
|
||||
content_type = (upload.content_type or "").lower()
|
||||
size = upload.size or 0
|
||||
|
||||
if content_type in _FREE_REF_IMAGE_TYPES:
|
||||
kind, asset_type, suffix = "image", Asset.Type.IMAGE, {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[content_type]
|
||||
if size > _FREE_REF_IMAGE_MAX:
|
||||
return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif content_type in _FREE_REF_VIDEO_TYPES:
|
||||
kind, asset_type, suffix = "video", Asset.Type.VIDEO, ".mp4" if content_type == "video/mp4" else ".mov"
|
||||
if size > _FREE_REF_VIDEO_MAX:
|
||||
return Response({"detail": "视频大小不能超过 50MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif content_type in _FREE_REF_AUDIO_TYPES:
|
||||
kind, asset_type, suffix = "audio", Asset.Type.AUDIO, ".mp3" if content_type == "audio/mpeg" else ".wav"
|
||||
if size > _FREE_REF_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()
|
||||
width = height = None
|
||||
duration = None
|
||||
poster_bytes = None
|
||||
|
||||
if kind == "image":
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(BytesIO(raw)) as im:
|
||||
width, height = im.size
|
||||
except Exception: # noqa: BLE001
|
||||
return Response({"detail": "图片解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not (300 <= width <= 6000 and 300 <= height <= 6000):
|
||||
return Response({"detail": "图片边长需在 300-6000 像素之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
ratio = width / height
|
||||
if not (0.4 <= ratio <= 2.5):
|
||||
return Response({"detail": "图片宽高比需在 0.4-2.5 之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
with tempfile.TemporaryDirectory(prefix="airshelf-fc-upload-") 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_bytes = extract_video_poster(str(tmp_path))
|
||||
|
||||
asset_id = _uuid.uuid4()
|
||||
storage = TosStorage()
|
||||
object_key = f"teams/{team.id}/free-create/uploads/{asset_id}{suffix}"
|
||||
stored = storage.upload_fileobj(fileobj=BytesIO(raw), object_key=object_key, content_type=content_type)
|
||||
name = (upload.name or f"素材{suffix}")[:255]
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id,
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
name=name,
|
||||
asset_type=asset_type,
|
||||
source=Asset.Source.UPLOAD,
|
||||
category=Asset.Category.UPLOAD,
|
||||
in_library=False, # 仅作生成参考,不进资产库列表
|
||||
metadata={"feature": "free_video_reference"},
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key=stored.object_key,
|
||||
bucket=stored.bucket,
|
||||
content_type=stored.content_type,
|
||||
size_bytes=stored.size_bytes,
|
||||
width=width,
|
||||
height=height,
|
||||
duration_ms=int(duration * 1000) if duration else None,
|
||||
is_primary=True,
|
||||
)
|
||||
url = storage.public_url(object_key=stored.object_key)
|
||||
thumb_url = ""
|
||||
if poster_bytes:
|
||||
poster_key = f"teams/{team.id}/free-create/uploads/{asset_id}-poster.jpg"
|
||||
poster_stored = storage.upload_fileobj(
|
||||
fileobj=BytesIO(poster_bytes), object_key=poster_key, content_type="image/jpeg"
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key=poster_stored.object_key,
|
||||
bucket=poster_stored.bucket,
|
||||
content_type=poster_stored.content_type,
|
||||
size_bytes=poster_stored.size_bytes,
|
||||
is_primary=False,
|
||||
)
|
||||
thumb_url = storage.public_url(object_key=poster_key)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"asset_id": str(asset.id),
|
||||
"url": url,
|
||||
"type": kind,
|
||||
"name": name,
|
||||
"duration": duration,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"thumb_url": thumb_url or (url if kind == "image" else ""),
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class ModelConfigViewSet(ReadOnlyModelViewSet):
|
||||
# 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致
|
||||
# (否则 DB 默认序不稳定,可能默认选到 Gemini 等;用户要默认 = 豆包 2.0 Pro,它最早创建)
|
||||
|
||||
Reference in New Issue
Block a user