完成视频复刻和优化
This commit is contained in:
+110
-13
@@ -15,7 +15,7 @@ from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
from apps.assets.models import Asset
|
||||
from apps.assets.serializers import AssetFileSerializer, AssetSerializer
|
||||
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
||||
from apps.common.celery_health import require_worker
|
||||
from apps.common.celery_health import require_worker, require_worker_task
|
||||
from apps.products.models import Product
|
||||
|
||||
from .generation_errors import classify_generation_error, public_error_for_task
|
||||
@@ -666,11 +666,13 @@ def _free_video_task_queryset(team):
|
||||
)
|
||||
|
||||
|
||||
def _free_video_list_queryset(team):
|
||||
def _free_video_list_queryset(team, *, include_replace=False):
|
||||
"""正常任务流隐藏已从资产库删除的成品,但保留生成中/失败及无落库资产的历史任务。"""
|
||||
from .video_replace import video_replace_q
|
||||
|
||||
video_assets = Asset.objects.filter(origin_task_id=OuterRef("pk"), asset_type=Asset.Type.VIDEO)
|
||||
active_video_assets = video_assets.filter(is_deleted=False, purged_at__isnull=True)
|
||||
return (
|
||||
qs = (
|
||||
_free_video_task_queryset(team)
|
||||
.annotate(
|
||||
_has_video_asset=Exists(video_assets),
|
||||
@@ -682,6 +684,9 @@ def _free_video_list_queryset(team):
|
||||
_has_active_video_asset=False,
|
||||
)
|
||||
)
|
||||
if include_replace:
|
||||
return qs.filter(video_replace_q())
|
||||
return qs.exclude(video_replace_q())
|
||||
|
||||
|
||||
def _free_video_trash_queryset(team):
|
||||
@@ -699,28 +704,33 @@ def _set_free_video_generated_assets_deleted(task, deleted):
|
||||
class VideoDigestView(APIView):
|
||||
"""视频提炼 · 上传参考视频提炼分镜稿(不绑项目)。
|
||||
|
||||
POST /api/ai/video-digest/ multipart file → 中文分镜稿。慢(30~60 秒),固定 30 积分/次,失败退还。
|
||||
POST /api/ai/video-digest/ multipart file → 秒回任务,worker 抽帧 + Gemini。失败退还。
|
||||
GET /api/ai/video-digest/ 本团队已完成的提炼历史(新→旧)。
|
||||
"""
|
||||
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
def get(self, request):
|
||||
from .video_digest import list_team_digest_history
|
||||
from .video_digest import get_inflight_team_digest, list_team_digest_history
|
||||
|
||||
team = get_current_team(request.user)
|
||||
results = list_team_digest_history(team=team)
|
||||
return Response({"results": results, "total": len(results)})
|
||||
return Response({
|
||||
"results": results,
|
||||
"total": len(results),
|
||||
"inflight": get_inflight_team_digest(team=team),
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
from .video_digest import VideoDigestError, digest_team_video
|
||||
|
||||
upload = request.FILES.get("file") or request.data.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "请先上传参考视频"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
require_worker_task("apps.ai.tasks.run_video_digest_task")
|
||||
from .video_digest import VideoDigestError, submit_team_digest
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
result = digest_team_video(
|
||||
result = submit_team_digest(
|
||||
team=team,
|
||||
user=request.user,
|
||||
upload=upload,
|
||||
@@ -735,14 +745,25 @@ class VideoDigestView(APIView):
|
||||
{"detail": public_error.fallback_message, "error": public_error.as_dict()},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
return Response({"name": getattr(upload, "name", "") or "参考视频", **result})
|
||||
return Response({"name": getattr(upload, "name", "") or "参考视频", **result}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
class VideoDigestDetailView(APIView):
|
||||
"""PATCH /api/ai/video-digest/<id>/ 保存编辑后的提示词到这条历史。"""
|
||||
"""GET /api/ai/video-digest/<id>/ 轮询提炼任务。
|
||||
PATCH /api/ai/video-digest/<id>/ 保存编辑后的提示词到这条历史。
|
||||
"""
|
||||
|
||||
parser_classes = [JSONParser, FormParser]
|
||||
|
||||
def get(self, request, task_id):
|
||||
from .video_digest import get_team_digest_job
|
||||
|
||||
team = get_current_team(request.user)
|
||||
item = get_team_digest_job(team=team, task_id=task_id)
|
||||
if item is None:
|
||||
return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(item)
|
||||
|
||||
def patch(self, request, task_id):
|
||||
from .video_digest import VideoDigestError, save_digest_prompt
|
||||
|
||||
@@ -817,6 +838,82 @@ class FreeVideoView(APIView):
|
||||
)
|
||||
|
||||
|
||||
class VideoReplaceView(APIView):
|
||||
"""视频复刻:参考视频 + 商品图/人物图,走 Seedance 换商品或换角色。
|
||||
|
||||
POST /api/ai/video-replace/ 提交(提示词后端写死)
|
||||
GET /api/ai/video-replace/ 本页历史(不含自由创作)
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
require_worker()
|
||||
from .video_replace import serialize_video_replace_task, submit_video_replace
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
task = submit_video_replace(team=team, user=request.user, params=request.data or {})
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
internal_kind = (
|
||||
"user_credit_insufficient" if "余额不足" in message
|
||||
else "model_unavailable" if "模型未配置" in message
|
||||
else "provider_rate_limited" if "任务进行中" in message
|
||||
else "invalid_input"
|
||||
)
|
||||
public_error = classify_generation_error(
|
||||
exc, operation="video_generate", internal_kind=internal_kind
|
||||
)
|
||||
return Response(
|
||||
{"detail": public_error.fallback_message, "error": public_error.as_dict()},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
task = _free_video_task_queryset(team).get(id=task.id)
|
||||
return Response({"task": serialize_video_replace_task(task)}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
def get(self, request):
|
||||
from .video_replace import serialize_video_replace_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_list_queryset(team, include_replace=True).order_by("-created_at")
|
||||
total = qs.count()
|
||||
tasks = list(qs[offset : offset + page_size])
|
||||
return Response(
|
||||
{
|
||||
"results": [serialize_video_replace_task(t) for t in tasks],
|
||||
"total": total,
|
||||
"has_more": offset + page_size < total,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class VideoReplacePollView(APIView):
|
||||
"""POST /api/ai/video-replace/<id>/poll/ —— 与自由创作共用 finalize,只认复刻任务。"""
|
||||
|
||||
def post(self, request, task_id):
|
||||
from .free_video import finalize_free_video
|
||||
from .video_replace import is_video_replace_task, serialize_video_replace_task
|
||||
|
||||
team = get_current_team(request.user)
|
||||
task = _free_video_task_queryset(team).filter(id=task_id).first()
|
||||
if task is None or not is_video_replace_task(task):
|
||||
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 — 单次轮询失败不终结任务
|
||||
logger.warning("video replace poll failed for %s", task_id, exc_info=True)
|
||||
task = _free_video_task_queryset(team).get(id=task.id)
|
||||
return Response({"task": serialize_video_replace_task(task)})
|
||||
|
||||
|
||||
class FreeVideoPollView(APIView):
|
||||
"""POST /api/ai/free-video/<id>/poll/ —— web 进程内单次轮询+终态化(幂等)。
|
||||
前端渐进轮询打这里;本地无 worker 也能全程收尾(与 pipeline poll-video-segment 同模式)。"""
|
||||
@@ -963,7 +1060,7 @@ class FreeVideoUploadView(APIView):
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
from .media_probe import extract_video_poster, probe_duration
|
||||
from .media_probe import duration_in_ref_range, extract_video_poster, probe_duration
|
||||
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
@@ -1015,7 +1112,7 @@ class FreeVideoUploadView(APIView):
|
||||
duration = probe_duration(str(tmp_path))
|
||||
if duration is None:
|
||||
return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not (2 <= duration <= 15):
|
||||
if not duration_in_ref_range(duration):
|
||||
label = "视频" if kind == "video" else "音频"
|
||||
return Response({"detail": f"{label}时长需在 2-15 秒之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if kind == "video":
|
||||
|
||||
Reference in New Issue
Block a user