优化视频提炼
This commit is contained in:
@@ -340,6 +340,27 @@ class VideoDigestApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertEqual(mocked.call_args.kwargs["model_config_id"], "abc-123")
|
||||
|
||||
def test_forwards_reuse_task_id_without_file(self):
|
||||
payload = {
|
||||
"id": "00000000-0000-0000-0000-000000000003",
|
||||
"task_id": "00000000-0000-0000-0000-000000000003",
|
||||
"status": "processing",
|
||||
"text": "",
|
||||
"duration": 15.0,
|
||||
"file_name": "参考视频.mp4",
|
||||
"estimated_cost": "30",
|
||||
}
|
||||
with patch("apps.ai.video_digest.submit_team_digest", return_value=payload) as mocked:
|
||||
response = self.client.post(
|
||||
"/api/ai/video-digest/",
|
||||
{"reuse_task_id": "00000000-0000-0000-0000-000000000099", "model_config_id": "abc-123"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertEqual(mocked.call_args.kwargs["reuse_task_id"], "00000000-0000-0000-0000-000000000099")
|
||||
self.assertIsNone(mocked.call_args.kwargs["upload"])
|
||||
self.assertEqual(mocked.call_args.kwargs["model_config_id"], "abc-123")
|
||||
|
||||
def test_provider_failure_returns_json_not_500(self):
|
||||
with patch("apps.ai.video_digest.submit_team_digest", side_effect=RuntimeError("upstream 402")):
|
||||
upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4")
|
||||
@@ -387,6 +408,9 @@ class VideoDigestApiTests(TestCase):
|
||||
"shot_count": 5,
|
||||
"video_url": "https://cdn.example/coffee.mp4",
|
||||
"cover_url": "https://cdn.example/coffee.jpg",
|
||||
"source_key": f"digest-source-{extra_key or 'ok'}.mp4",
|
||||
"source_url": "https://cdn.example/coffee-source.mp4",
|
||||
"suffix": ".mp4",
|
||||
},
|
||||
response_payload={"digest": prompt, "prompt": prompt},
|
||||
is_deleted=is_deleted,
|
||||
@@ -503,6 +527,31 @@ class VideoDigestApiTests(TestCase):
|
||||
missing = self.client.get("/api/ai/video-digest/00000000-0000-0000-0000-000000000099/")
|
||||
self.assertEqual(missing.status_code, 404)
|
||||
|
||||
def test_cancel_inflight_job(self):
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = self._seed_digest_task(status=AITask.Status.RESERVED, extra_key="cancel")
|
||||
response = self.client.delete(f"/api/ai/video-digest/{task.id}/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["status"], "cancelled")
|
||||
task.refresh_from_db()
|
||||
self.assertEqual(task.status, AITask.Status.CANCELLED)
|
||||
|
||||
def test_stale_inflight_is_auto_cancelled_on_list(self):
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = self._seed_digest_task(status=AITask.Status.RESERVED, extra_key="stale")
|
||||
AITask.objects.filter(id=task.id).update(created_at=timezone.now() - timedelta(minutes=20))
|
||||
listed = self.client.get("/api/ai/video-digest/")
|
||||
self.assertEqual(listed.status_code, 200)
|
||||
self.assertIsNone(listed.data["inflight"])
|
||||
task.refresh_from_db()
|
||||
self.assertEqual(task.status, AITask.Status.CANCELLED)
|
||||
|
||||
|
||||
@override_settings(CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}})
|
||||
class VideoDigestBillingTests(TestCase):
|
||||
|
||||
@@ -19,6 +19,7 @@ import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
@@ -760,6 +761,8 @@ def serialize_digest_job(task) -> dict:
|
||||
job_status = "processing"
|
||||
elif task.status == AITask.Status.SUCCEEDED:
|
||||
job_status = "succeeded"
|
||||
elif task.status == AITask.Status.CANCELLED:
|
||||
job_status = "cancelled"
|
||||
else:
|
||||
job_status = "failed"
|
||||
duration = float(req.get("duration_seconds") or 0)
|
||||
@@ -805,6 +808,81 @@ def get_team_digest_job(*, team, task_id) -> dict | None:
|
||||
return serialize_digest_job(task)
|
||||
|
||||
|
||||
_STALE_DIGEST = timedelta(minutes=15)
|
||||
|
||||
|
||||
def _task_reservation(task):
|
||||
try:
|
||||
return task.credit_reservation
|
||||
except Exception: # noqa: BLE001 — 无预留行
|
||||
return None
|
||||
|
||||
|
||||
def cancel_team_digest(*, team, task_id, reason: str = "用户取消") -> dict | None:
|
||||
"""取消进行中的提炼:停 UI 轮询、退预留积分。worker 跑到一半会看见 CANCELLED 不再落成功。"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask
|
||||
from apps.billing.services.ledger import release_credit
|
||||
|
||||
with transaction.atomic():
|
||||
task = (
|
||||
AITask.objects.select_for_update()
|
||||
.filter(
|
||||
id=task_id,
|
||||
team=team,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
project__isnull=True,
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if task is None:
|
||||
return None
|
||||
if task.status == AITask.Status.SUCCEEDED:
|
||||
return serialize_digest_job(task)
|
||||
if task.status in {AITask.Status.FAILED, AITask.Status.CANCELLED}:
|
||||
return serialize_digest_job(task)
|
||||
reservation = _task_reservation(task)
|
||||
task.status = AITask.Status.CANCELLED
|
||||
task.error_message = (reason or "用户取消")[:2000]
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||
if reservation is not None:
|
||||
try:
|
||||
release_credit(reservation=reservation, reason=(reason or "用户取消")[:200])
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("cancel digest release_credit failed for %s", task_id, exc_info=True)
|
||||
return serialize_digest_job(task)
|
||||
|
||||
|
||||
def expire_stale_team_digests(*, team) -> None:
|
||||
"""卡住超过 15 分钟的提炼自动取消并退费,避免进页永远「正在拆解」。"""
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask
|
||||
|
||||
stale = AITask.objects.filter(
|
||||
team=team,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
project__isnull=True,
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
status__in={
|
||||
AITask.Status.CREATED,
|
||||
AITask.Status.RESERVED,
|
||||
AITask.Status.SUBMITTED,
|
||||
AITask.Status.POLLING,
|
||||
AITask.Status.POSTPROCESSING,
|
||||
},
|
||||
created_at__lt=timezone.now() - _STALE_DIGEST,
|
||||
)
|
||||
for task in stale:
|
||||
cancel_team_digest(team=team, task_id=task.id, reason="提炼超时已自动取消")
|
||||
|
||||
|
||||
def get_inflight_team_digest(*, team) -> dict | None:
|
||||
from apps.ai.models import AITask
|
||||
|
||||
@@ -831,8 +909,11 @@ def get_inflight_team_digest(*, team) -> dict | None:
|
||||
return serialize_digest_job(task)
|
||||
|
||||
|
||||
def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict:
|
||||
"""秒回任务 id。慢活(抽帧 + Gemini)交给 worker,离开页面也不中断。"""
|
||||
def submit_team_digest(*, team, user, upload=None, reuse_task_id=None, model_config_id=None) -> dict:
|
||||
"""秒回任务 id。慢活(抽帧 + Gemini)交给 worker,离开页面也不中断。
|
||||
|
||||
取消后再点生成时,可带 reuse_task_id 复用 TOS 上已有的参考视频,不用重新上传。
|
||||
"""
|
||||
from django.db import transaction
|
||||
|
||||
from apps.ai.models import AITask
|
||||
@@ -844,39 +925,46 @@ def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict:
|
||||
if model_config is None:
|
||||
raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员")
|
||||
|
||||
path, suffix, size, duration = _materialize_upload(upload)
|
||||
file_name = Path(getattr(upload, "name", "") or "参考视频.mp4").name or "参考视频.mp4"
|
||||
width = height = 0
|
||||
source_key = source_url = cover_key = cover_url = ""
|
||||
try:
|
||||
width, height = probe_video_size(path)
|
||||
cover_key, cover_url = _store_digest_cover(team=team, jpeg=extract_cover_jpeg(path, duration))
|
||||
source_key, source_url = _store_raw_source(team=team, path=path, suffix=suffix)
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
if not source_key:
|
||||
raise VideoDigestError("参考视频上传失败,请重试")
|
||||
if upload is not None:
|
||||
path, suffix, size, duration = _materialize_upload(upload)
|
||||
file_name = Path(getattr(upload, "name", "") or "参考视频.mp4").name or "参考视频.mp4"
|
||||
width = height = 0
|
||||
source_key = source_url = cover_key = cover_url = ""
|
||||
try:
|
||||
width, height = probe_video_size(path)
|
||||
cover_key, cover_url = _store_digest_cover(team=team, jpeg=extract_cover_jpeg(path, duration))
|
||||
source_key, source_url = _store_raw_source(team=team, path=path, suffix=suffix)
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
if not source_key:
|
||||
raise VideoDigestError("参考视频上传失败,请重试")
|
||||
request_payload = {
|
||||
"model": model_config.name,
|
||||
"endpoint": model_config.endpoint,
|
||||
"feature": "video_remix",
|
||||
"duration_seconds": round(duration, 2),
|
||||
"file_name": file_name,
|
||||
"title": title_from_filename(file_name),
|
||||
"file_size": size,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"ratio": ratio_label(width, height),
|
||||
"suffix": suffix,
|
||||
"source_key": source_key,
|
||||
"source_url": source_url,
|
||||
"cover_key": cover_key,
|
||||
"cover_url": cover_url,
|
||||
}
|
||||
else:
|
||||
request_payload = _payload_from_existing_source(
|
||||
team=team,
|
||||
reuse_task_id=reuse_task_id,
|
||||
model_config=model_config,
|
||||
)
|
||||
|
||||
quote = quote_video_digest(team=team, model_config=model_config)
|
||||
request_payload = {
|
||||
"model": model_config.name,
|
||||
"endpoint": model_config.endpoint,
|
||||
"feature": "video_remix",
|
||||
"duration_seconds": round(duration, 2),
|
||||
"file_name": file_name,
|
||||
"title": title_from_filename(file_name),
|
||||
"file_size": size,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"ratio": ratio_label(width, height),
|
||||
"suffix": suffix,
|
||||
"source_key": source_key,
|
||||
"source_url": source_url,
|
||||
"cover_key": cover_key,
|
||||
"cover_url": cover_url,
|
||||
}
|
||||
if quote.meta.get("rate"):
|
||||
request_payload["points_per_yuan_snapshot"] = quote.meta["rate"]
|
||||
request_payload = {**request_payload, "points_per_yuan_snapshot": quote.meta["rate"]}
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
@@ -904,6 +992,53 @@ def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict:
|
||||
return serialize_digest_job(task)
|
||||
|
||||
|
||||
def _payload_from_existing_source(*, team, reuse_task_id, model_config) -> dict:
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task_id = str(reuse_task_id or "").strip()
|
||||
if not task_id:
|
||||
raise VideoDigestError("请先上传参考视频")
|
||||
try:
|
||||
previous = AITask.objects.filter(
|
||||
id=task_id,
|
||||
team=team,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
project__isnull=True,
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
).first()
|
||||
except (ValueError, ValidationError, TypeError):
|
||||
raise VideoDigestError("请先上传参考视频") from None
|
||||
if previous is None:
|
||||
raise VideoDigestError("请先上传参考视频")
|
||||
req = previous.request_payload or {}
|
||||
source_key = str(req.get("source_key") or "").strip()
|
||||
if not source_key:
|
||||
raise VideoDigestError("参考视频已失效,请重新上传")
|
||||
file_name = str(req.get("file_name") or "") or "参考视频.mp4"
|
||||
return {
|
||||
"model": model_config.name,
|
||||
"endpoint": model_config.endpoint,
|
||||
"feature": "video_remix",
|
||||
"duration_seconds": float(req.get("duration_seconds") or 0),
|
||||
"file_name": file_name,
|
||||
"title": str(req.get("title") or "").strip() or title_from_filename(file_name),
|
||||
"file_size": int(req.get("file_size") or 0),
|
||||
"width": int(req.get("width") or 0),
|
||||
"height": int(req.get("height") or 0),
|
||||
"ratio": str(req.get("ratio") or ""),
|
||||
"suffix": str(req.get("suffix") or ".mp4"),
|
||||
"source_key": source_key,
|
||||
"source_url": str(req.get("source_url") or ""),
|
||||
"cover_key": str(req.get("cover_key") or ""),
|
||||
"cover_url": str(req.get("cover_url") or ""),
|
||||
"video_key": str(req.get("video_key") or ""),
|
||||
"video_url": str(req.get("video_url") or ""),
|
||||
}
|
||||
|
||||
|
||||
def run_team_digest_task(*, task_id: str) -> None:
|
||||
"""Worker:从 TOS 取参考视频,跑抽帧 + Gemini,失败退费。"""
|
||||
from django.db import transaction
|
||||
@@ -1078,6 +1213,11 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
raise VideoDigestError(str(exc)) from exc
|
||||
reservation = task.credit_reservation
|
||||
try:
|
||||
task.refresh_from_db()
|
||||
if task.status == AITask.Status.CANCELLED:
|
||||
if source_path:
|
||||
Path(source_path).unlink(missing_ok=True)
|
||||
return serialize_digest_job(task)
|
||||
task.status = AITask.Status.SUBMITTED
|
||||
task.submitted_at = timezone.now()
|
||||
task.save(update_fields=["status", "submitted_at", "updated_at"])
|
||||
@@ -1105,6 +1245,9 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if source_path:
|
||||
Path(source_path).unlink(missing_ok=True)
|
||||
task.refresh_from_db()
|
||||
if task.status == AITask.Status.CANCELLED:
|
||||
return serialize_digest_job(task)
|
||||
_fail_digest_task(task, reservation, str(exc))
|
||||
raise
|
||||
|
||||
@@ -1145,6 +1288,10 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
locked = AITask.objects.select_for_update().get(id=task.id)
|
||||
if locked.status == AITask.Status.CANCELLED:
|
||||
return serialize_digest_job(locked)
|
||||
task = locked
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
task.request_payload = request_payload
|
||||
task.response_payload = {"digest": digest[:32000], "prompt": digest[:32000]}
|
||||
|
||||
@@ -711,9 +711,10 @@ class VideoDigestView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
def get(self, request):
|
||||
from .video_digest import get_inflight_team_digest, list_team_digest_history
|
||||
from .video_digest import expire_stale_team_digests, get_inflight_team_digest, list_team_digest_history
|
||||
|
||||
team = get_current_team(request.user)
|
||||
expire_stale_team_digests(team=team)
|
||||
results = list_team_digest_history(team=team)
|
||||
return Response({
|
||||
"results": results,
|
||||
@@ -723,7 +724,8 @@ class VideoDigestView(APIView):
|
||||
|
||||
def post(self, request):
|
||||
upload = request.FILES.get("file") or request.data.get("file")
|
||||
if upload is None:
|
||||
reuse_task_id = request.data.get("reuse_task_id") or None
|
||||
if upload is None and not reuse_task_id:
|
||||
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
|
||||
@@ -734,6 +736,7 @@ class VideoDigestView(APIView):
|
||||
team=team,
|
||||
user=request.user,
|
||||
upload=upload,
|
||||
reuse_task_id=reuse_task_id,
|
||||
model_config_id=request.data.get("model_config_id") or None,
|
||||
)
|
||||
except VideoDigestError as exc:
|
||||
@@ -745,12 +748,16 @@ 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}, status=status.HTTP_202_ACCEPTED)
|
||||
return Response(
|
||||
{"name": getattr(upload, "name", "") or result.get("file_name") or "参考视频", **result},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
|
||||
class VideoDigestDetailView(APIView):
|
||||
"""GET /api/ai/video-digest/<id>/ 轮询提炼任务。
|
||||
PATCH /api/ai/video-digest/<id>/ 保存编辑后的提示词到这条历史。
|
||||
DELETE /api/ai/video-digest/<id>/ 取消进行中的提炼并退预留积分。
|
||||
"""
|
||||
|
||||
parser_classes = [JSONParser, FormParser]
|
||||
@@ -780,6 +787,15 @@ class VideoDigestDetailView(APIView):
|
||||
return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(item)
|
||||
|
||||
def delete(self, request, task_id):
|
||||
from .video_digest import cancel_team_digest
|
||||
|
||||
team = get_current_team(request.user)
|
||||
item = cancel_team_digest(team=team, task_id=task_id)
|
||||
if item is None:
|
||||
return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(item)
|
||||
|
||||
|
||||
class FreeVideoView(APIView):
|
||||
"""自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。
|
||||
|
||||
Reference in New Issue
Block a user