完成视频复刻和优化

This commit is contained in:
Azmat@qq.com
2026-08-27 11:54:47 +08:00
parent c25060c6c6
commit 51620bf25b
46 changed files with 2575 additions and 663 deletions
+110 -13
View File
@@ -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":