完成视频复刻和优化
This commit is contained in:
@@ -9,3 +9,14 @@ app = Celery("airshelf")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
app.autodiscover_tasks()
|
||||
|
||||
|
||||
from celery.signals import worker_ready # noqa: E402
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
def _kick_asset_review_drain(**kwargs):
|
||||
"""Worker 起来就自动转人脸审核,不用人去后台点送审。"""
|
||||
from apps.ai.tasks import drain_asset_reviews_task
|
||||
|
||||
drain_asset_reviews_task.apply_async(countdown=8)
|
||||
|
||||
|
||||
@@ -328,11 +328,23 @@ class AdminAssetReviewTests(TestCase):
|
||||
mock_submit.assert_not_called()
|
||||
|
||||
def test_poll_only_processing(self):
|
||||
with patch("apps.adminpanel.views.poll_asset_review", return_value="active") as mock_poll:
|
||||
with patch("apps.assets.review.submit_asset_for_review", return_value=False), patch(
|
||||
"apps.assets.review.poll_asset_review", return_value="active"
|
||||
) as mock_poll:
|
||||
r = self.ac.post("/api/admin/asset-reviews/poll/", {}, format="json")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.data["polled"], 1) # 仅 a_proc 处于 processing
|
||||
mock_poll.assert_called_once()
|
||||
self.assertEqual(r.data.get("submitted", 0), 0)
|
||||
|
||||
def test_poll_auto_submits_unsubmitted(self):
|
||||
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as mock_submit, patch(
|
||||
"apps.assets.review.poll_asset_review", return_value="processing"
|
||||
):
|
||||
r = self.ac.post("/api/admin/asset-reviews/poll/", {}, format="json")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertGreaterEqual(r.data["submitted"], 1)
|
||||
mock_submit.assert_called()
|
||||
|
||||
|
||||
class AdminTaskMonitorTests(TestCase):
|
||||
|
||||
@@ -406,15 +406,20 @@ def admin_asset_reviews_submit(request):
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_asset_reviews_poll(request):
|
||||
"""轮询审核中(processing)资产的最新状态。给 asset_ids 则只轮询这些,否则轮询全平台 processing。"""
|
||||
"""自动送审未送审资产 + 轮询审核中状态。给 asset_ids 则只轮询这些,否则全平台兜底。"""
|
||||
from apps.assets.review import drain_platform_reviews
|
||||
|
||||
ids = request.data.get("asset_ids")
|
||||
qs = Asset.objects.filter(category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False)
|
||||
if ids:
|
||||
qs = qs.filter(id__in=ids)
|
||||
statuses = {}
|
||||
for asset in qs:
|
||||
statuses[str(asset.id)] = poll_asset_review(asset)
|
||||
return Response({"polled": len(statuses), "statuses": statuses})
|
||||
qs = Asset.objects.filter(
|
||||
category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False, id__in=ids
|
||||
)
|
||||
statuses = {}
|
||||
for asset in qs:
|
||||
statuses[str(asset.id)] = poll_asset_review(asset)
|
||||
return Response({"polled": len(statuses), "submitted": 0, "statuses": statuses})
|
||||
result = drain_platform_reviews()
|
||||
return Response(result)
|
||||
|
||||
|
||||
def _refresh_inflight_task(task: AITask, *, operator) -> AITask:
|
||||
|
||||
@@ -31,6 +31,7 @@ from apps.billing.services.ledger import charge_reserved_credit, release_credit,
|
||||
from .models import AITask, ModelConfig
|
||||
from .providers.volcano import VolcanoArkProvider
|
||||
from .generation_errors import classify_generation_error, public_error_for_task
|
||||
from .media_probe import REF_DURATION_MAX
|
||||
from .video_errors import parse_provider_error
|
||||
from .video_pricing import get_resolution
|
||||
|
||||
@@ -319,7 +320,7 @@ def build_content_items(*, team, prompt: str, mode: str, references: list) -> di
|
||||
raise ValueError(f"参考音频最多 3 条,当前 {audio_n} 条,请减少后重试")
|
||||
if audio_n > 0 and image_n + video_n == 0:
|
||||
raise ValueError("音频不能单独作为参考素材,请同时提供参考图片或视频")
|
||||
if video_duration_total > 15:
|
||||
if video_duration_total > REF_DURATION_MAX:
|
||||
raise ValueError("参考视频总时长不能超过 15 秒,请缩短后重试")
|
||||
|
||||
# @label 替换:按 label 长度降序,防子串吞噬
|
||||
@@ -409,6 +410,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
||||
"""提交一条自由创作视频。校验/估价/预留在前(失败不留半套),火山调用在事务外。
|
||||
创建失败不抛:返回 FAILED 任务(带友好中文错误),前端渲染失败卡。校验类错误抛 ValueError → 400。"""
|
||||
prompt = str(params.get("prompt") or "").strip()
|
||||
feature = str(params.get("feature") or "free_video").strip() or "free_video"
|
||||
mode = str(params.get("mode") or "universal")
|
||||
model_name = str(params.get("model") or HIGH_RES_MODEL)
|
||||
aspect_ratio = str(params.get("aspect_ratio") or "16:9")
|
||||
@@ -486,7 +488,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
||||
reserve_amount = video_reserve_amount(quote.points)
|
||||
|
||||
request_payload = {
|
||||
"feature": "free_video",
|
||||
"feature": feature,
|
||||
"mode": mode,
|
||||
"model": model_name,
|
||||
"endpoint": model_config.endpoint,
|
||||
@@ -505,6 +507,13 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
||||
"references": built["snapshots"],
|
||||
"model_routing_v1": True,
|
||||
}
|
||||
extra = params.get("extra_payload")
|
||||
if isinstance(extra, dict):
|
||||
protected = set(request_payload)
|
||||
for key, value in extra.items():
|
||||
if key in protected or value in (None, ""):
|
||||
continue
|
||||
request_payload[key] = value
|
||||
|
||||
# 建任务 + 预留同一事务:余额不足/限额拦截时回滚任务行,不留半套
|
||||
with transaction.atomic():
|
||||
@@ -546,7 +555,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
||||
generate_audio=generate_audio,
|
||||
seed=seed if seed != -1 else None,
|
||||
search_mode=search_mode,
|
||||
request_summary={"feature": "free_video", "mode": mode},
|
||||
request_summary={"feature": feature, "mode": mode},
|
||||
)
|
||||
response, provider_task_id = routed.value
|
||||
# execute_model_call 以原子 F 表达式累计实际尝试的平台成本;刷新内存对象,
|
||||
@@ -625,7 +634,7 @@ def _notify_failure(task: AITask, *, raw: str, hint: str) -> None:
|
||||
task=task,
|
||||
project=None,
|
||||
recipient=task.created_by,
|
||||
stage_label="自由创作视频",
|
||||
stage_label="视频复刻" if (task.request_payload or {}).get("feature") == "video_replace" else "自由创作视频",
|
||||
raw=raw,
|
||||
hint=hint,
|
||||
)
|
||||
@@ -643,19 +652,27 @@ def _store_free_video_media(*, task: AITask, media: str) -> Asset:
|
||||
asset_id = uuid.uuid4()
|
||||
object_key = f"teams/{task.team_id}/free-create/{asset_id}.mp4"
|
||||
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
|
||||
prompt = (task.request_payload or {}).get("prompt") or ""
|
||||
payload = task.request_payload or {}
|
||||
prompt = payload.get("prompt") or ""
|
||||
feature = payload.get("feature") or "free_video"
|
||||
subject = str(payload.get("subject_name") or "").strip()
|
||||
if feature == "video_replace":
|
||||
mode_label = "角色复刻" if payload.get("replace_mode") == "character" else "商品复刻"
|
||||
asset_name = f"{subject}{mode_label}" if subject else mode_label
|
||||
else:
|
||||
asset_name = prompt[:255] or "自由创作视频"
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id,
|
||||
team=task.team,
|
||||
created_by=task.created_by,
|
||||
# Asset.name 是通用资产字段,受 255 字符上限约束;完整显示标题由资产接口从
|
||||
# 关联任务的 request_payload.prompt 派生,避免再出现 50 字符业务截断。
|
||||
name=(prompt[:255] or "自由创作视频"),
|
||||
name=asset_name[:255],
|
||||
asset_type=Asset.Type.VIDEO,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.FREE_CREATE,
|
||||
origin_task=task,
|
||||
metadata={"feature": "free_video"},
|
||||
metadata={"feature": feature},
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
@@ -892,6 +909,7 @@ def serialize_free_video_task(task: AITask, *, include_deleted_assets: bool = Fa
|
||||
"status": task.status,
|
||||
"mode": payload.get("mode") or "universal",
|
||||
"model": payload.get("model") or "",
|
||||
"feature": payload.get("feature") or "free_video",
|
||||
"prompt": payload.get("prompt") or "",
|
||||
"aspect_ratio": payload.get("aspect_ratio") or "16:9",
|
||||
"resolution": payload.get("resolution") or "720p",
|
||||
|
||||
@@ -8,6 +8,15 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# 参考视频/音频对外仍写「2-15 秒」。15 秒成片常被探测成 15.01–15.04(帧率取整 / AAC 对齐),给半秒容差。
|
||||
REF_DURATION_MIN = 1.95
|
||||
REF_DURATION_MAX = 15.5
|
||||
|
||||
|
||||
def duration_in_ref_range(seconds: float) -> bool:
|
||||
return REF_DURATION_MIN <= seconds <= REF_DURATION_MAX
|
||||
|
||||
|
||||
def probe_duration(file_path: str) -> float | None:
|
||||
"""ffprobe 取媒体时长(秒)。失败返回 None。"""
|
||||
try:
|
||||
|
||||
@@ -1571,7 +1571,10 @@ def stream_script_agent(
|
||||
target_index 非空 = 精准只改第 N 镜(读全脚本上下文,后端强制保留其余镜原样)。"""
|
||||
from apps.ai.services import create_ai_task, stream_routed_text_request
|
||||
|
||||
fmt, structure = coerce_combo(presentation_format, video_structure)
|
||||
# 极速成片与专业创作都只产出口播。历史项目、模板或请求里的短剧/Vlog
|
||||
# 仅作兼容读取,绝不能重新进入实际生成链路。
|
||||
del presentation_format
|
||||
fmt, structure = coerce_combo(DEFAULT_PRESENTATION_FORMAT, video_structure)
|
||||
|
||||
yield _sse({"type": "tool", "id": "skill", "label": f"加载套路:{PRESENTATION_FORMATS[fmt]} · {VIDEO_STRUCTURES[structure]}", "status": "running"})
|
||||
skill_loaded = bool(load_ecommerce_skill(fmt, structure))
|
||||
@@ -1898,7 +1901,8 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se
|
||||
raise ValueError(f"镜号越界:第 {target_index + 1} 镜(共 {seg_n} 镜)")
|
||||
total_duration = base_draft["total_duration"] # 已按各镜真实秒数加总,normalize 不会截掉用户增删后的镜
|
||||
# 改一镜要沿用原稿的套路,否则重写出来的那一镜镜头语言会跟其余镜打架
|
||||
fmt, structure = combo_keys(base_draft.get("presentation_format"), base_draft.get("video_structure"))
|
||||
# 单镜重写也必须遵守当前统一的口播规则,不能被旧稿的表现形式带回短剧/Vlog。
|
||||
fmt, structure = combo_keys(DEFAULT_PRESENTATION_FORMAT, base_draft.get("video_structure"))
|
||||
|
||||
product_image_urls = _script_product_reference_urls(project, model_config)
|
||||
messages = build_agent_messages(
|
||||
|
||||
@@ -31,6 +31,16 @@ def extract_entities_task(self, task_id: str) -> str:
|
||||
return task_id
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0)
|
||||
def run_video_digest_task(self, task_id: str) -> str:
|
||||
"""视频提炼页的慢活(抽帧 + Gemini,可达 1–2 分钟)在 worker 内跑,离开页面也不中断。
|
||||
幂等且失败自退费(见 run_team_digest_task),故 max_retries=0,不向上抛重试。"""
|
||||
from apps.ai.video_digest import run_team_digest_task
|
||||
|
||||
run_team_digest_task(task_id=task_id)
|
||||
return task_id
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0)
|
||||
def generate_base_asset_task(self, task_id: str) -> str:
|
||||
"""基础资产(商品/人物/场景立绘)的慢出图在 worker 内跑,Web 层不被占住。
|
||||
@@ -87,3 +97,27 @@ def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str:
|
||||
poll_free_video_task.apply_async(args=[task_id, attempt + 1], countdown=30)
|
||||
return task_id
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0)
|
||||
def drain_asset_reviews_task(self) -> str:
|
||||
"""全平台人脸审核兜底:未送审自动送火山、审核中自动拉绿/红盾。
|
||||
自重排(无 celery beat);eager 下不自转,避免单测死循环。"""
|
||||
from django.conf import settings as dj_settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from apps.assets.review import drain_platform_reviews
|
||||
|
||||
if cache.add("asset-review-drain-lock", "1", timeout=50):
|
||||
try:
|
||||
drain_platform_reviews()
|
||||
except Exception: # noqa: BLE001 — 单轮失败不终结循环
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning("drain_asset_reviews_task failed", exc_info=True)
|
||||
finally:
|
||||
cache.delete("asset-review-drain-lock")
|
||||
if getattr(dj_settings, "CELERY_TASK_ALWAYS_EAGER", False):
|
||||
return "ok"
|
||||
drain_asset_reviews_task.apply_async(countdown=45)
|
||||
return "ok"
|
||||
|
||||
|
||||
@@ -283,6 +283,7 @@ def _digest_with_duration(clip, duration: float):
|
||||
return frames_from_upload(clip)
|
||||
|
||||
|
||||
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
|
||||
class VideoDigestApiTests(TestCase):
|
||||
"""视频提炼页独立入口:不绑项目,缺文件直接 400。"""
|
||||
|
||||
@@ -300,42 +301,47 @@ class VideoDigestApiTests(TestCase):
|
||||
|
||||
def test_returns_digest_without_project(self):
|
||||
payload = {
|
||||
"text": "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3,
|
||||
"chars": 120,
|
||||
"frames": 4,
|
||||
"duration": 15.0,
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"task_id": "00000000-0000-0000-0000-000000000001",
|
||||
"status": "processing",
|
||||
"text": "",
|
||||
"prompt": "",
|
||||
"chars": 0,
|
||||
"duration": 15.0,
|
||||
"shots": 0,
|
||||
"file_name": "ref.mp4",
|
||||
"estimated_cost": "30",
|
||||
}
|
||||
with patch("apps.ai.video_digest.digest_team_video", return_value=payload):
|
||||
with patch("apps.ai.video_digest.submit_team_digest", return_value=payload):
|
||||
upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4")
|
||||
response = self.client.post("/api/ai/video-digest/", {"file": upload}, format="multipart")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertEqual(response.data["name"], "ref.mp4")
|
||||
self.assertIn("【第 1 镜】", response.data["text"])
|
||||
self.assertEqual(response.data["status"], "processing")
|
||||
self.assertEqual(response.data["task_id"], payload["task_id"])
|
||||
self.assertEqual(response.data["duration"], 15.0)
|
||||
|
||||
def test_forwards_model_config_id(self):
|
||||
payload = {
|
||||
"text": "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3,
|
||||
"chars": 120,
|
||||
"frames": 4,
|
||||
"duration": 15.0,
|
||||
"id": "00000000-0000-0000-0000-000000000002",
|
||||
"task_id": "00000000-0000-0000-0000-000000000002",
|
||||
"status": "processing",
|
||||
"text": "",
|
||||
"duration": 15.0,
|
||||
"estimated_cost": "30",
|
||||
}
|
||||
with patch("apps.ai.video_digest.digest_team_video", return_value=payload) as mocked:
|
||||
with patch("apps.ai.video_digest.submit_team_digest", return_value=payload) as mocked:
|
||||
upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4")
|
||||
response = self.client.post(
|
||||
"/api/ai/video-digest/",
|
||||
{"file": upload, "model_config_id": "abc-123"},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.status_code, 202)
|
||||
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.digest_team_video", side_effect=RuntimeError("upstream 402")):
|
||||
with patch("apps.ai.video_digest.submit_team_digest", side_effect=RuntimeError("upstream 402")):
|
||||
upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4")
|
||||
response = self.client.post("/api/ai/video-digest/", {"file": upload}, format="multipart")
|
||||
self.assertEqual(response.status_code, 502)
|
||||
@@ -392,6 +398,7 @@ class VideoDigestApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["results"], [])
|
||||
self.assertEqual(response.data["total"], 0)
|
||||
self.assertIsNone(response.data["inflight"])
|
||||
|
||||
def test_history_lists_succeeded_team_digests(self):
|
||||
from apps.ai.models import AITask
|
||||
@@ -477,6 +484,25 @@ class VideoDigestApiTests(TestCase):
|
||||
)
|
||||
self.assertEqual(missing.status_code, 404)
|
||||
|
||||
def test_polls_inflight_job_and_exposes_it_on_list(self):
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = self._seed_digest_task(status=AITask.Status.RESERVED, extra_key="inflight")
|
||||
listed = self.client.get("/api/ai/video-digest/")
|
||||
self.assertEqual(listed.status_code, 200)
|
||||
self.assertEqual(listed.data["inflight"]["id"], str(task.id))
|
||||
self.assertEqual(listed.data["inflight"]["status"], "processing")
|
||||
|
||||
polled = self.client.get(f"/api/ai/video-digest/{task.id}/")
|
||||
self.assertEqual(polled.status_code, 200)
|
||||
self.assertEqual(polled.data["status"], "processing")
|
||||
self.assertEqual(polled.data["task_id"], str(task.id))
|
||||
self.assertEqual(polled.data["file_name"], "参考视频.mp4")
|
||||
|
||||
def test_poll_missing_job_returns_404(self):
|
||||
missing = self.client.get("/api/ai/video-digest/00000000-0000-0000-0000-000000000099/")
|
||||
self.assertEqual(missing.status_code, 404)
|
||||
|
||||
|
||||
@override_settings(CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}})
|
||||
class VideoDigestBillingTests(TestCase):
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""视频复刻:专用提交/历史隔离/提示词后端写死。
|
||||
|
||||
运行:DB_ENGINE=sqlite python manage.py test apps.ai.test_video_replace --settings=airshelf.settings.test
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.free_video import submit_free_video
|
||||
from apps.ai.models import AITask, ModelConfig
|
||||
from apps.ai.test_free_video import STANDARD, _ark_create_response
|
||||
from apps.ai.video_replace import PRODUCT_PROMPT, submit_video_replace
|
||||
from apps.assets.models import Asset, AssetFile, Model
|
||||
from apps.billing.models import CreditAccount
|
||||
from apps.products.models import Product, ProductImage
|
||||
|
||||
|
||||
def _asset(team, user, *, kind=Asset.Type.IMAGE, name="素材", duration_ms=None, preview="http://tos/1.png"):
|
||||
asset = Asset.objects.create(
|
||||
team=team,
|
||||
created_by=user,
|
||||
name=name,
|
||||
asset_type=kind,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.UPLOAD,
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key="k/1",
|
||||
bucket="b",
|
||||
content_type="video/mp4" if kind == Asset.Type.VIDEO else "image/png",
|
||||
size_bytes=1,
|
||||
preview_url=preview,
|
||||
duration_ms=duration_ms,
|
||||
is_primary=True,
|
||||
)
|
||||
return asset
|
||||
|
||||
|
||||
class SubmitVideoReplaceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="vrep", password="p")
|
||||
self.team = Team.objects.create(name="VR", owner=self.user)
|
||||
CreditAccount.objects.create(team=self.team, balance="10000.0000")
|
||||
self.provider = MagicMock()
|
||||
self.provider.create_video_task.return_value = _ark_create_response()
|
||||
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
||||
patch("apps.assets.assets_client.is_enabled", return_value=False).start()
|
||||
self.addCleanup(patch.stopall)
|
||||
self.video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="参考.mp4", duration_ms=8000, preview="http://tos/video.mp4")
|
||||
self.image = _asset(self.team, self.user, name="精华.png", preview="http://tos/product.png")
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华", cover_asset=self.image)
|
||||
ProductImage.objects.create(product=self.product, asset=self.image, is_primary=True)
|
||||
|
||||
def _submit(self, **over):
|
||||
params = {
|
||||
"replace_mode": "product",
|
||||
"video_asset_id": str(self.video.id),
|
||||
"product_id": str(self.product.id),
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
}
|
||||
params.update(over)
|
||||
return submit_video_replace(team=self.team, user=self.user, params=params)
|
||||
|
||||
def test_product_library_uses_backend_prompt_and_feature(self):
|
||||
task = self._submit()
|
||||
self.assertEqual(task.status, AITask.Status.SUBMITTED)
|
||||
payload = task.request_payload
|
||||
self.assertEqual(payload["feature"], "video_replace")
|
||||
self.assertEqual(payload["replace_mode"], "product")
|
||||
self.assertEqual(payload["subject_name"], "净颜精华")
|
||||
self.assertEqual(payload["subject_source"], "library")
|
||||
self.assertEqual(payload["prompt"], PRODUCT_PROMPT)
|
||||
labels = [item["label"] for item in payload["references"]]
|
||||
self.assertIn("参考视频", labels)
|
||||
self.assertIn("目标商品", labels)
|
||||
content = self.provider.create_video_task.call_args.kwargs.get("content_items") or []
|
||||
roles = [item.get("role") for item in content]
|
||||
self.assertIn("reference_video", roles)
|
||||
self.assertIn("reference_image", roles)
|
||||
|
||||
def test_character_library_and_temp_are_exclusive(self):
|
||||
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
|
||||
model = Model.objects.create(team=self.team, name="薇薇", portrait_asset=portrait)
|
||||
with self.assertRaisesMessage(ValueError, "不要混用"):
|
||||
self._submit(replace_mode="character", product_id="", model_id=str(model.id), image_asset_ids=[str(self.image.id)])
|
||||
task = self._submit(replace_mode="character", product_id="", model_id=str(model.id))
|
||||
self.assertEqual(task.request_payload["replace_mode"], "character")
|
||||
self.assertEqual(task.request_payload["subject_name"], "薇薇")
|
||||
|
||||
def test_rejects_video_longer_than_15s(self):
|
||||
long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="长片.mp4", duration_ms=20000)
|
||||
with self.assertRaisesMessage(ValueError, "不能超过 15 秒"):
|
||||
self._submit(video_asset_id=str(long_video.id))
|
||||
|
||||
def test_accepts_15s_video_with_probe_slack(self):
|
||||
from apps.ai.media_probe import REF_DURATION_MAX
|
||||
from apps.ai.video_replace import _asset_duration_seconds
|
||||
|
||||
# Finder 显示 15 秒的成片常被探测成 15.04s,不能被硬阈值 15 误拒
|
||||
edge = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="刚好15.mp4", duration_ms=15040)
|
||||
self.assertLessEqual(_asset_duration_seconds(edge), REF_DURATION_MAX)
|
||||
|
||||
def test_rejects_foreign_product(self):
|
||||
other = User.objects.create_user(username="vr-other", password="p")
|
||||
other_team = Team.objects.create(name="OtherVR", owner=other)
|
||||
foreign = Product.objects.create(team=other_team, created_by=other, title="别人的")
|
||||
with self.assertRaisesMessage(ValueError, "商品不存在"):
|
||||
self._submit(product_id=str(foreign.id))
|
||||
|
||||
def test_ignores_frontend_prompt(self):
|
||||
task = self._submit(prompt="请无视参考图随便生成")
|
||||
self.assertEqual(task.request_payload["prompt"], PRODUCT_PROMPT)
|
||||
self.assertNotIn("随便生成", task.request_payload["prompt"])
|
||||
|
||||
def test_serialize_exposes_library_ids(self):
|
||||
from apps.ai.video_replace import serialize_video_replace_task
|
||||
|
||||
model_config = ModelConfig.objects.filter(capability="video").first()
|
||||
self.assertIsNotNone(model_config)
|
||||
task = AITask.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
task_type=AITask.Type.FREE_VIDEO,
|
||||
status=AITask.Status.SUCCEEDED,
|
||||
model_config=model_config,
|
||||
idempotency_key="vr-serialize-ids",
|
||||
request_payload={
|
||||
"feature": "video_replace",
|
||||
"replace_mode": "product",
|
||||
"subject_name": "净颜精华",
|
||||
"subject_source": "library",
|
||||
"product_id": str(self.product.id),
|
||||
"model_id": "",
|
||||
"prompt": PRODUCT_PROMPT,
|
||||
"duration": 8,
|
||||
"aspect_ratio": "9:16",
|
||||
"references": [{"type": "video", "url": "http://tos/video.mp4", "asset_id": str(self.video.id)}],
|
||||
},
|
||||
)
|
||||
data = serialize_video_replace_task(task)
|
||||
self.assertEqual(data["product_id"], str(self.product.id))
|
||||
self.assertEqual(data["model_id"], "")
|
||||
self.assertEqual(data["replace_mode"], "product")
|
||||
self.assertEqual(data["subject_source"], "library")
|
||||
|
||||
|
||||
class VideoReplaceApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="vrapi", password="p")
|
||||
self.team = Team.objects.create(name="VRA", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active")
|
||||
CreditAccount.objects.create(team=self.team, balance="10000.0000")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
self.provider = MagicMock()
|
||||
self.provider.create_video_task.return_value = _ark_create_response()
|
||||
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
||||
patch("apps.assets.assets_client.is_enabled", return_value=False).start()
|
||||
self.addCleanup(patch.stopall)
|
||||
self.video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="参考.mp4", duration_ms=5000, preview="http://tos/video.mp4")
|
||||
self.image = _asset(self.team, self.user, name="图.png", preview="http://tos/product.png")
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="轻醒咖啡", cover_asset=self.image)
|
||||
ProductImage.objects.create(product=self.product, asset=self.image, is_primary=True)
|
||||
|
||||
def test_submit_list_isolated_from_free_video(self):
|
||||
free = submit_free_video(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
params={
|
||||
"prompt": "一只猫",
|
||||
"mode": "universal",
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "16:9",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
"references": [],
|
||||
},
|
||||
)
|
||||
resp = self.client.post(
|
||||
"/api/ai/video-replace/",
|
||||
{
|
||||
"replace_mode": "product",
|
||||
"video_asset_id": str(self.video.id),
|
||||
"product_id": str(self.product.id),
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
replace_id = resp.json()["task"]["id"]
|
||||
self.assertEqual(resp.json()["task"]["replace_mode"], "product")
|
||||
self.assertEqual(resp.json()["task"]["feature"], "video_replace")
|
||||
|
||||
free_list = self.client.get("/api/ai/free-video/").json()
|
||||
free_ids = {item["id"] for item in free_list["results"]}
|
||||
self.assertIn(str(free.id), free_ids)
|
||||
self.assertNotIn(replace_id, free_ids)
|
||||
|
||||
replace_list = self.client.get("/api/ai/video-replace/").json()
|
||||
replace_ids = {item["id"] for item in replace_list["results"]}
|
||||
self.assertIn(replace_id, replace_ids)
|
||||
self.assertNotIn(str(free.id), replace_ids)
|
||||
|
||||
def test_legacy_prompt_prefix_shows_in_replace_history(self):
|
||||
task = submit_free_video(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
params={
|
||||
"prompt": "[视频复刻] 商品:旧稿。保留镜头。",
|
||||
"mode": "universal",
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
"references": [],
|
||||
},
|
||||
)
|
||||
listing = self.client.get("/api/ai/video-replace/").json()
|
||||
self.assertEqual(listing["results"][0]["id"], str(task.id))
|
||||
self.assertEqual(listing["results"][0]["replace_mode"], "product")
|
||||
self.assertEqual(self.client.get("/api/ai/free-video/").json()["total"], 0)
|
||||
|
||||
def test_validation_error_returns_400(self):
|
||||
resp = self.client.post("/api/ai/video-replace/", {"replace_mode": "product"}, format="json")
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
@@ -16,6 +16,8 @@ from .views import (
|
||||
GenerateImageView,
|
||||
ImageConversationViewSet,
|
||||
ModelConfigViewSet,
|
||||
VideoReplacePollView,
|
||||
VideoReplaceView,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
@@ -35,4 +37,6 @@ urlpatterns = [
|
||||
path("free-video/<uuid:task_id>/restore/", FreeVideoRestoreView.as_view(), name="ai-free-video-restore"),
|
||||
path("free-video/<uuid:task_id>/purge/", FreeVideoPurgeView.as_view(), name="ai-free-video-purge"),
|
||||
path("free-video/<uuid:task_id>/", FreeVideoDetailView.as_view(), name="ai-free-video-detail"),
|
||||
path("video-replace/", VideoReplaceView.as_view(), name="ai-video-replace"),
|
||||
path("video-replace/<uuid:task_id>/poll/", VideoReplacePollView.as_view(), name="ai-video-replace-poll"),
|
||||
] + router.urls
|
||||
|
||||
@@ -700,7 +700,274 @@ def save_digest_prompt(*, team, task_id, prompt: str) -> dict | None:
|
||||
return serialize_digest_history(task)
|
||||
|
||||
|
||||
def _digest_video(*, team, user, upload, project=None, product_hint="", model_config_id=None) -> dict:
|
||||
class _FileFromPath:
|
||||
def __init__(self, path: str, name: str):
|
||||
self.name = name
|
||||
self.size = Path(path).stat().st_size
|
||||
self._path = path
|
||||
|
||||
def chunks(self, chunk_size=1024 * 1024):
|
||||
with Path(self._path).open("rb") as handle:
|
||||
while True:
|
||||
data = handle.read(chunk_size)
|
||||
if not data:
|
||||
break
|
||||
yield data
|
||||
|
||||
|
||||
def _store_raw_source(*, team, path: str, suffix: str) -> tuple[str, str]:
|
||||
ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}"
|
||||
mime = _SUFFIX_MIME.get(ext.lower(), "video/mp4")
|
||||
key = f"teams/{team.id}/video-digest/{uuid.uuid4()}{ext.lower()}"
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
storage = TosStorage()
|
||||
with Path(path).open("rb") as fileobj:
|
||||
stored = storage.upload_fileobj(fileobj=fileobj, object_key=key, content_type=mime)
|
||||
return stored.object_key, storage.public_url(object_key=stored.object_key)
|
||||
|
||||
|
||||
def _download_source_to_temp(object_key: str, suffix: str) -> str:
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}"
|
||||
storage = TosStorage()
|
||||
body = storage.client.get_object(Bucket=storage.bucket, Key=object_key)["Body"].read()
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=ext.lower(), delete=False)
|
||||
try:
|
||||
tmp.write(body)
|
||||
tmp.flush()
|
||||
finally:
|
||||
tmp.close()
|
||||
return tmp.name
|
||||
|
||||
|
||||
def serialize_digest_job(task) -> dict:
|
||||
from apps.ai.generation_errors import public_error_for_task
|
||||
from apps.ai.models import AITask
|
||||
|
||||
req = task.request_payload or {}
|
||||
resp = task.response_payload or {}
|
||||
prompt = str(resp.get("prompt") or resp.get("digest") or "").strip()
|
||||
inflight = task.status in {
|
||||
AITask.Status.CREATED,
|
||||
AITask.Status.RESERVED,
|
||||
AITask.Status.SUBMITTED,
|
||||
AITask.Status.POLLING,
|
||||
AITask.Status.POSTPROCESSING,
|
||||
}
|
||||
if inflight:
|
||||
job_status = "processing"
|
||||
elif task.status == AITask.Status.SUCCEEDED:
|
||||
job_status = "succeeded"
|
||||
else:
|
||||
job_status = "failed"
|
||||
duration = float(req.get("duration_seconds") or 0)
|
||||
file_name = str(req.get("file_name") or "") or "参考视频.mp4"
|
||||
public_error = public_error_for_task(task, operation="video_digest") if job_status == "failed" else None
|
||||
video_url = _media_url_from_payload(req, "video_url", "video_key", signed=True) or _media_url_from_payload(
|
||||
req, "source_url", "source_key", signed=True
|
||||
)
|
||||
return {
|
||||
"id": str(task.id),
|
||||
"task_id": str(task.id),
|
||||
"status": job_status,
|
||||
"text": prompt,
|
||||
"prompt": prompt,
|
||||
"chars": len(prompt),
|
||||
"duration": round(duration, 1) if duration else 0,
|
||||
"shots": int(req.get("shot_count") or 0) or shot_count(prompt),
|
||||
"file_name": file_name,
|
||||
"title": str(req.get("title") or "").strip() or title_from_filename(file_name),
|
||||
"ratio": str(req.get("ratio") or ""),
|
||||
"width": int(req.get("width") or 0),
|
||||
"height": int(req.get("height") or 0),
|
||||
"cover_url": _media_url_from_payload(req, "cover_url", "cover_key"),
|
||||
"video_url": video_url,
|
||||
"estimated_cost": str(task.estimated_cost),
|
||||
"error_message": (public_error.fallback_message if public_error else "") or str(task.error_message or ""),
|
||||
}
|
||||
|
||||
|
||||
def get_team_digest_job(*, team, task_id) -> dict | None:
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = 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()
|
||||
if task is None:
|
||||
return None
|
||||
return serialize_digest_job(task)
|
||||
|
||||
|
||||
def get_inflight_team_digest(*, team) -> dict | None:
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = (
|
||||
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,
|
||||
},
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if task is None:
|
||||
return None
|
||||
return serialize_digest_job(task)
|
||||
|
||||
|
||||
def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict:
|
||||
"""秒回任务 id。慢活(抽帧 + Gemini)交给 worker,离开页面也不中断。"""
|
||||
from django.db import transaction
|
||||
|
||||
from apps.ai.models import AITask
|
||||
from apps.ai.tasks import run_video_digest_task
|
||||
from apps.billing.pricing import quote_video_digest
|
||||
from apps.billing.services.ledger import reserve_credit
|
||||
|
||||
model_config = resolve_digest_model_config(preferred_id=model_config_id)
|
||||
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("参考视频上传失败,请重试")
|
||||
|
||||
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"]
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
task = AITask.objects.create(
|
||||
team=team,
|
||||
created_by=user,
|
||||
project=None,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
status=AITask.Status.CREATED,
|
||||
model_config=model_config,
|
||||
idempotency_key=f"video_digest:{team.id}:{uuid.uuid4()}",
|
||||
request_payload=request_payload,
|
||||
estimated_cost=quote.points,
|
||||
base_cost=quote.base_cost_yuan,
|
||||
)
|
||||
reserve_credit(team=team, user=user, task=task, amount=quote.points)
|
||||
task.status = AITask.Status.RESERVED
|
||||
task.save(update_fields=["status", "updated_at"])
|
||||
except ValueError as exc:
|
||||
if "insufficient credit" in str(exc).lower():
|
||||
raise VideoDigestError("团队余额不足,请充值后重试") from exc
|
||||
raise VideoDigestError(str(exc)) from exc
|
||||
|
||||
run_video_digest_task.delay(str(task.id))
|
||||
return serialize_digest_job(task)
|
||||
|
||||
|
||||
def run_team_digest_task(*, task_id: str) -> None:
|
||||
"""Worker:从 TOS 取参考视频,跑抽帧 + Gemini,失败退费。"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = AITask.objects.select_related("team", "created_by", "model_config", "credit_reservation").filter(
|
||||
id=task_id,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
project__isnull=True,
|
||||
).first()
|
||||
if task is None:
|
||||
return
|
||||
if task.status == AITask.Status.SUCCEEDED:
|
||||
return
|
||||
if task.status not in {AITask.Status.RESERVED, AITask.Status.SUBMITTED}:
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
locked = AITask.objects.select_for_update().get(id=task.id)
|
||||
if locked.status == AITask.Status.SUCCEEDED:
|
||||
return
|
||||
if locked.status not in {AITask.Status.RESERVED, AITask.Status.SUBMITTED}:
|
||||
return
|
||||
locked.status = AITask.Status.SUBMITTED
|
||||
locked.submitted_at = locked.submitted_at or timezone.now()
|
||||
locked.save(update_fields=["status", "submitted_at", "updated_at"])
|
||||
task = locked
|
||||
|
||||
req = task.request_payload or {}
|
||||
source_key = str(req.get("source_key") or "")
|
||||
suffix = str(req.get("suffix") or ".mp4")
|
||||
file_name = str(req.get("file_name") or "参考视频.mp4")
|
||||
if not source_key:
|
||||
reservation = getattr(task, "credit_reservation", None)
|
||||
_fail_digest_task(task, reservation, "参考视频丢失,请重新上传")
|
||||
return
|
||||
|
||||
local_path = ""
|
||||
try:
|
||||
local_path = _download_source_to_temp(source_key, suffix)
|
||||
upload = _FileFromPath(local_path, file_name)
|
||||
_digest_video(
|
||||
team=task.team,
|
||||
user=task.created_by,
|
||||
upload=upload,
|
||||
project=None,
|
||||
product_hint="",
|
||||
model_config_id=str(task.model_config_id) if task.model_config_id else None,
|
||||
existing_task=task,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("async video digest failed for %s", task.id)
|
||||
task.refresh_from_db()
|
||||
if task.status not in {AITask.Status.SUCCEEDED, AITask.Status.FAILED}:
|
||||
reservation = getattr(task, "credit_reservation", None)
|
||||
_fail_digest_task(task, reservation, str(exc))
|
||||
finally:
|
||||
if local_path:
|
||||
Path(local_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _digest_video(*, team, user, upload, project=None, product_hint="", model_config_id=None, existing_task=None) -> dict:
|
||||
"""上传视频 → 分镜稿。抽帧在建任务之前做,文件不合格不占积分。"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
@@ -720,7 +987,11 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
height = int(extras.get("height") or 0)
|
||||
file_size = int(extras.get("file_size") or getattr(upload, "size", 0) or 0)
|
||||
|
||||
model_config = resolve_digest_model_config(preferred_id=model_config_id)
|
||||
model_config = (
|
||||
existing_task.model_config
|
||||
if existing_task is not None and existing_task.model_config_id
|
||||
else resolve_digest_model_config(preferred_id=model_config_id)
|
||||
)
|
||||
if model_config is None:
|
||||
if source_path:
|
||||
Path(source_path).unlink(missing_ok=True)
|
||||
@@ -765,7 +1036,13 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
if quote.meta.get("rate"):
|
||||
request_payload = {**request_payload, "points_per_yuan_snapshot": quote.meta["rate"]}
|
||||
|
||||
if project is not None:
|
||||
if existing_task is not None:
|
||||
task = existing_task
|
||||
existing_req = dict(task.request_payload or {})
|
||||
request_payload = {**existing_req, **request_payload}
|
||||
task.request_payload = request_payload
|
||||
task.save(update_fields=["request_payload", "updated_at"])
|
||||
elif project is not None:
|
||||
task = create_ai_task(
|
||||
project=project,
|
||||
user=user,
|
||||
@@ -831,18 +1108,25 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
_fail_digest_task(task, reservation, str(exc))
|
||||
raise
|
||||
|
||||
cover_key, cover_url, video_key, video_url = "", "", "", ""
|
||||
existing_req = dict(task.request_payload or {})
|
||||
cover_key = str(existing_req.get("cover_key") or "")
|
||||
cover_url = str(existing_req.get("cover_url") or "")
|
||||
video_key = str(existing_req.get("video_key") or "")
|
||||
video_url = str(existing_req.get("video_url") or "")
|
||||
if project is None:
|
||||
if not cover_key:
|
||||
try:
|
||||
cover_key, cover_url = _store_digest_cover(team=team, jpeg=extras.get("cover_jpeg") or b"")
|
||||
except Exception: # noqa: BLE001 — 封面失败不挡拆解结果
|
||||
logger.warning("video digest cover upload failed", exc_info=True)
|
||||
try:
|
||||
cover_key, cover_url = _store_digest_cover(team=team, jpeg=extras.get("cover_jpeg") or b"")
|
||||
except Exception: # noqa: BLE001 — 封面失败不挡拆解结果
|
||||
logger.warning("video digest cover upload failed", exc_info=True)
|
||||
try:
|
||||
video_key, video_url = _store_digest_video(
|
||||
stored_key, stored_url = _store_digest_video(
|
||||
team=team,
|
||||
path=source_path,
|
||||
suffix=str(extras.get("suffix") or ".mp4"),
|
||||
)
|
||||
if stored_key:
|
||||
video_key, video_url = stored_key, stored_url
|
||||
except Exception: # noqa: BLE001 — 原片失败仍可看提示词,封面不能播
|
||||
logger.warning("video digest source upload failed", exc_info=True)
|
||||
if source_path:
|
||||
@@ -851,13 +1135,13 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
|
||||
shots = shot_count(digest)
|
||||
request_payload = {
|
||||
**(task.request_payload or {}),
|
||||
**existing_req,
|
||||
**request_payload,
|
||||
"shot_count": shots,
|
||||
"cover_key": cover_key,
|
||||
"cover_url": cover_url,
|
||||
"video_key": video_key,
|
||||
"video_url": video_url,
|
||||
"cover_key": cover_key or existing_req.get("cover_key") or "",
|
||||
"cover_url": cover_url or existing_req.get("cover_url") or "",
|
||||
"video_key": video_key or existing_req.get("video_key") or "",
|
||||
"video_url": video_url or existing_req.get("video_url") or "",
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""视频复刻:参考视频 + 商品图/人物图 → Seedance 换商品或换角色。
|
||||
|
||||
不新建任务类型、不接检测/抠图。提交仍走 submit_free_video,只把
|
||||
feature=video_replace 和 replace_mode 写进 payload,提示词由后端写死。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.assets.models import Asset, Model
|
||||
from apps.products.models import Product
|
||||
|
||||
from .free_video import HIGH_RES_MODEL, serialize_free_video_task, submit_free_video
|
||||
from .media_probe import REF_DURATION_MAX
|
||||
|
||||
FEATURE = "video_replace"
|
||||
REPLACE_MODES = {"product", "character"}
|
||||
MAX_IMAGES = 9
|
||||
LEGACY_PROMPT_PREFIX = "[视频复刻]"
|
||||
|
||||
PRODUCT_PROMPT = (
|
||||
"使用@参考视频作为镜头、节奏与口播氛围基准,"
|
||||
"将画面中需要替换的原商品完整替换为@目标商品的外观。"
|
||||
"保留参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围,"
|
||||
"商品外观、材质、包装必须与参考图一致,不要改变原片构图和人物表演。"
|
||||
)
|
||||
CHARACTER_PROMPT = (
|
||||
"使用@参考视频作为镜头、节奏与口播氛围基准,"
|
||||
"将画面中需要替换的原人物完整替换为@目标角色。"
|
||||
"保留参考视频的商品、场景、镜头运动、剪辑节奏与口播氛围,"
|
||||
"角色五官、发型、体态必须与参考图一致,不要改变原片构图和商品展示。"
|
||||
)
|
||||
|
||||
|
||||
def is_video_replace_task(task) -> bool:
|
||||
payload = task.request_payload or {}
|
||||
if payload.get("feature") == FEATURE:
|
||||
return True
|
||||
return str(payload.get("prompt") or "").startswith(LEGACY_PROMPT_PREFIX)
|
||||
|
||||
|
||||
def video_replace_q() -> Q:
|
||||
return Q(request_payload__feature=FEATURE) | Q(request_payload__prompt__startswith=LEGACY_PROMPT_PREFIX)
|
||||
|
||||
|
||||
def serialize_video_replace_task(task, *, include_deleted_assets: bool = False) -> dict:
|
||||
data = serialize_free_video_task(task, include_deleted_assets=include_deleted_assets)
|
||||
payload = task.request_payload or {}
|
||||
replace_mode = payload.get("replace_mode") or _legacy_replace_mode(payload.get("prompt") or "")
|
||||
data.update({
|
||||
"feature": FEATURE,
|
||||
"replace_mode": replace_mode,
|
||||
"subject_name": payload.get("subject_name") or "",
|
||||
"subject_source": payload.get("subject_source") or "",
|
||||
"product_id": payload.get("product_id") or "",
|
||||
"model_id": payload.get("model_id") or "",
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
def submit_video_replace(*, team, user, params: dict):
|
||||
"""校验素材 → 套提示词 → 复用 free_video 提交。失败抛 ValueError。"""
|
||||
replace_mode = str(params.get("replace_mode") or "").strip()
|
||||
if replace_mode not in REPLACE_MODES:
|
||||
raise ValueError("请选择替换商品或替换角色")
|
||||
|
||||
product_id = _optional_uuid(params.get("product_id"), "商品")
|
||||
model_id = _optional_uuid(params.get("model_id"), "角色")
|
||||
image_ids = _uuid_list(params.get("image_asset_ids"), "参考图")
|
||||
has_product = product_id is not None
|
||||
has_model = model_id is not None
|
||||
has_temp = bool(image_ids)
|
||||
|
||||
if replace_mode == "product":
|
||||
if has_model:
|
||||
raise ValueError("商品复刻请选择商品,不要同时选择角色")
|
||||
if has_product and has_temp:
|
||||
raise ValueError("请从商品库选择,或临时上传商品图,不要混用")
|
||||
if not has_product and not has_temp:
|
||||
raise ValueError("请选择商品或上传商品参考图")
|
||||
else:
|
||||
if has_product:
|
||||
raise ValueError("角色复刻请选择角色,不要同时选择商品")
|
||||
if has_model and has_temp:
|
||||
raise ValueError("请从人物库选择,或临时上传角色图,不要混用")
|
||||
if not has_model and not has_temp:
|
||||
raise ValueError("请选择角色或上传角色参考图")
|
||||
|
||||
video = _team_asset(team, params.get("video_asset_id"), kind=Asset.Type.VIDEO, label="参考视频")
|
||||
video_seconds = _asset_duration_seconds(video)
|
||||
if video_seconds > REF_DURATION_MAX:
|
||||
raise ValueError("参考视频不能超过 15 秒,请剪短后重试")
|
||||
|
||||
if has_product:
|
||||
subject_name, image_refs, subject_source = _product_library_refs(team, product_id)
|
||||
elif has_model:
|
||||
subject_name, image_refs, subject_source = _character_library_refs(team, model_id)
|
||||
else:
|
||||
noun = "商品" if replace_mode == "product" else "角色"
|
||||
subject_name, image_refs, subject_source = _temporary_image_refs(team, image_ids, noun=noun)
|
||||
|
||||
prompt = PRODUCT_PROMPT if replace_mode == "product" else CHARACTER_PROMPT
|
||||
duration = _output_duration(params.get("duration"), video_seconds)
|
||||
references = [
|
||||
_owned_ref(video, kind="video", role="reference_video", label="参考视频"),
|
||||
*image_refs,
|
||||
]
|
||||
return submit_free_video(
|
||||
team=team,
|
||||
user=user,
|
||||
params={
|
||||
"prompt": prompt,
|
||||
"mode": "universal",
|
||||
"model": str(params.get("model") or HIGH_RES_MODEL),
|
||||
"aspect_ratio": str(params.get("aspect_ratio") or "9:16"),
|
||||
"resolution": str(params.get("resolution") or "720p"),
|
||||
"duration": duration,
|
||||
"seed": params.get("seed", -1),
|
||||
"generate_audio": True,
|
||||
"references": references,
|
||||
"feature": FEATURE,
|
||||
"extra_payload": {
|
||||
"replace_mode": replace_mode,
|
||||
"subject_name": subject_name,
|
||||
"subject_source": subject_source,
|
||||
"product_id": str(product_id) if product_id else "",
|
||||
"model_id": str(model_id) if model_id else "",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _legacy_replace_mode(prompt: str) -> str:
|
||||
return "character" if prompt.startswith("[视频复刻·角色]") else "product"
|
||||
|
||||
|
||||
def _optional_uuid(value, label: str):
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(text)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{label}无效") from exc
|
||||
|
||||
|
||||
def _uuid_list(value, label: str) -> list:
|
||||
if value in (None, ""):
|
||||
return []
|
||||
if not isinstance(value, (list, tuple)):
|
||||
raise ValueError(f"{label}格式无效")
|
||||
if len(value) > MAX_IMAGES:
|
||||
raise ValueError(f"{label}最多 {MAX_IMAGES} 张")
|
||||
seen = set()
|
||||
out = []
|
||||
for item in value:
|
||||
parsed = _optional_uuid(item, label)
|
||||
if parsed is None or parsed in seen:
|
||||
continue
|
||||
seen.add(parsed)
|
||||
out.append(parsed)
|
||||
return out
|
||||
|
||||
|
||||
def _team_asset(team, asset_id, *, kind: str, label: str) -> Asset:
|
||||
parsed = _optional_uuid(asset_id, label)
|
||||
if parsed is None:
|
||||
raise ValueError(f"请先上传{label}")
|
||||
asset = Asset.objects.filter(id=parsed, team=team, is_deleted=False, purged_at__isnull=True).first()
|
||||
if asset is None:
|
||||
raise ValueError(f"{label}不存在或已被删除")
|
||||
if asset.asset_type != kind:
|
||||
raise ValueError(f"{label}类型不正确")
|
||||
return asset
|
||||
|
||||
|
||||
def _asset_duration_seconds(asset: Asset) -> float:
|
||||
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
|
||||
if primary is None or not primary.duration_ms:
|
||||
return 0.0
|
||||
return primary.duration_ms / 1000.0
|
||||
|
||||
|
||||
def _output_duration(requested, video_seconds: float) -> int:
|
||||
try:
|
||||
value = int(requested) if requested not in (None, "") else 0
|
||||
except (TypeError, ValueError):
|
||||
value = 0
|
||||
if value:
|
||||
return min(15, max(4, value))
|
||||
if video_seconds:
|
||||
return min(15, max(4, int(round(video_seconds))))
|
||||
return 15
|
||||
|
||||
|
||||
def _owned_ref(asset: Asset, *, kind: str, role: str, label: str) -> dict:
|
||||
from .services import _asset_preview_url
|
||||
|
||||
url = _asset_preview_url(asset)
|
||||
if not url:
|
||||
raise ValueError(f"「{label}」没有可用文件")
|
||||
ref = {
|
||||
"url": url,
|
||||
"type": kind,
|
||||
"role": role,
|
||||
"label": label,
|
||||
"source": "upload",
|
||||
"asset_id": str(asset.id),
|
||||
}
|
||||
seconds = _asset_duration_seconds(asset)
|
||||
if seconds:
|
||||
ref["duration"] = seconds
|
||||
return ref
|
||||
|
||||
|
||||
def _library_image_ref(asset: Asset, *, team, label: str) -> dict:
|
||||
from .services import _asset_preview_url, _seedance_ref_url
|
||||
|
||||
if asset.team_id == team.id and not asset.is_deleted:
|
||||
return {
|
||||
"url": _asset_preview_url(asset) or "",
|
||||
"type": "image",
|
||||
"role": "reference_image",
|
||||
"label": label,
|
||||
"source": "asset",
|
||||
"asset_id": str(asset.id),
|
||||
}
|
||||
raw = _asset_preview_url(asset)
|
||||
url = _seedance_ref_url(raw, asset.review_status, asset.review_remote_id)
|
||||
if not url:
|
||||
raise ValueError(f"「{label}」没有可用文件")
|
||||
return {
|
||||
"url": url,
|
||||
"type": "image",
|
||||
"role": "reference_image",
|
||||
"label": label,
|
||||
"source": "upload",
|
||||
"asset_id": str(asset.id),
|
||||
}
|
||||
|
||||
|
||||
def _product_library_refs(team, product_id: uuid.UUID) -> tuple[str, list, str]:
|
||||
product = (
|
||||
Product.objects.filter(id=product_id, team=team, purged_at__isnull=True, status=Product.Status.ACTIVE)
|
||||
.select_related("cover_asset")
|
||||
.prefetch_related("images__asset")
|
||||
.first()
|
||||
)
|
||||
if product is None:
|
||||
raise ValueError("商品不存在或已被删除")
|
||||
assets = []
|
||||
seen = set()
|
||||
for image in product.images.all():
|
||||
asset = image.asset
|
||||
if asset is None or asset.id in seen or asset.is_deleted:
|
||||
continue
|
||||
seen.add(asset.id)
|
||||
assets.append(asset)
|
||||
if len(assets) >= MAX_IMAGES:
|
||||
break
|
||||
if not assets and product.cover_asset_id and not product.cover_asset.is_deleted:
|
||||
assets.append(product.cover_asset)
|
||||
if not assets:
|
||||
raise ValueError("这个商品还没有可用图片")
|
||||
refs = [_library_image_ref(asset, team=team, label="目标商品" if index == 0 else f"目标商品{index + 1}") for index, asset in enumerate(assets)]
|
||||
return product.title, refs, "library"
|
||||
|
||||
|
||||
def _character_library_refs(team, model_id: uuid.UUID) -> tuple[str, list, str]:
|
||||
model = (
|
||||
Model.objects.filter(Q(team=team) | Q(is_official=True), id=model_id, is_deleted=False, purged_at__isnull=True)
|
||||
.select_related("portrait_asset", "triview_asset")
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
raise ValueError("角色不存在或已被删除")
|
||||
assets = []
|
||||
seen = set()
|
||||
for asset in (model.portrait_asset, model.triview_asset):
|
||||
if asset is None or asset.id in seen or asset.is_deleted:
|
||||
continue
|
||||
seen.add(asset.id)
|
||||
assets.append(asset)
|
||||
if len(assets) >= MAX_IMAGES:
|
||||
break
|
||||
if not assets:
|
||||
raise ValueError("这个角色还没有可用图片")
|
||||
labels = ["目标角色", "目标角色三视图"]
|
||||
refs = [_library_image_ref(asset, team=team, label=labels[index] if index < len(labels) else f"目标角色{index + 1}") for index, asset in enumerate(assets)]
|
||||
return model.name, refs, "library"
|
||||
|
||||
|
||||
def _temporary_image_refs(team, image_ids: list, *, noun: str) -> tuple[str, list, str]:
|
||||
refs = []
|
||||
for index, asset_id in enumerate(image_ids):
|
||||
asset = _team_asset(team, asset_id, kind=Asset.Type.IMAGE, label=f"{noun}参考图")
|
||||
label = "目标商品" if noun == "商品" else "目标角色"
|
||||
if index > 0:
|
||||
label = f"{label}{index + 1}"
|
||||
refs.append(_owned_ref(asset, kind="image", role="reference_image", label=label))
|
||||
fallback = "临时商品素材" if noun == "商品" else "临时角色素材"
|
||||
name = Asset.objects.filter(id=image_ids[0]).values_list("name", flat=True).first() or fallback
|
||||
subject = name.rsplit(".", 1)[0] if name else fallback
|
||||
if len(refs) > 1:
|
||||
subject = f"{subject}({len(refs)}张参考图)"
|
||||
return subject, refs, "temporary"
|
||||
+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":
|
||||
|
||||
@@ -123,7 +123,7 @@ def _upload_asset_to_group(request, *, team, group: FreeAssetGroup, image_only:
|
||||
file_id = uuid.uuid4()
|
||||
|
||||
if kind != "image":
|
||||
from apps.ai.media_probe import extract_video_poster, probe_duration
|
||||
from apps.ai.media_probe import duration_in_ref_range, extract_video_poster, probe_duration
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="airshelf-fc-lib-") as tmp:
|
||||
tmp_path = Path(tmp) / f"in{suffix}"
|
||||
@@ -131,7 +131,7 @@ def _upload_asset_to_group(request, *, team, group: FreeAssetGroup, image_only:
|
||||
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":
|
||||
|
||||
@@ -116,6 +116,73 @@ def reference_review_state(asset: Asset) -> str:
|
||||
return "unsubmitted"
|
||||
|
||||
|
||||
def _unsubmitted_review_qs(*, team=None):
|
||||
qs = Asset.objects.filter(
|
||||
category__in=Asset.REVIEW_CATEGORIES,
|
||||
review_status="",
|
||||
is_deleted=False,
|
||||
)
|
||||
if team is not None:
|
||||
qs = qs.filter(team=team)
|
||||
return qs
|
||||
|
||||
|
||||
def _processing_review_qs(*, team=None):
|
||||
qs = Asset.objects.filter(
|
||||
category__in=Asset.REVIEW_CATEGORIES,
|
||||
review_status="processing",
|
||||
is_deleted=False,
|
||||
)
|
||||
if team is not None:
|
||||
qs = qs.filter(team=team)
|
||||
return qs
|
||||
|
||||
|
||||
def submit_unsubmitted_reviews(*, team=None, limit: int = 25) -> int:
|
||||
"""把未送审的人脸资产自动丢给火山,不用人去后台点「批量送审」。"""
|
||||
submitted = 0
|
||||
cap = max(1, min(int(limit), 50))
|
||||
for asset in _unsubmitted_review_qs(team=team).order_by("created_at")[:cap]:
|
||||
if submit_asset_for_review(asset):
|
||||
submitted += 1
|
||||
return submitted
|
||||
|
||||
|
||||
def poll_processing_reviews(*, team=None, limit: int = 80) -> dict[str, str]:
|
||||
cap = max(1, min(int(limit), 200))
|
||||
out: dict[str, str] = {}
|
||||
for asset in _processing_review_qs(team=team).order_by("updated_at")[:cap]:
|
||||
out[str(asset.id)] = poll_asset_review(asset)
|
||||
return out
|
||||
|
||||
|
||||
def drain_platform_reviews(*, submit_limit: int = 25, poll_limit: int = 80) -> dict:
|
||||
"""全平台:未送审自动送 + 审核中自动拉状态。worker 兜底循环,人不在后台页也会跑。"""
|
||||
submitted = submit_unsubmitted_reviews(limit=submit_limit)
|
||||
statuses = poll_processing_reviews(limit=poll_limit)
|
||||
return {"submitted": submitted, "polled": len(statuses), "statuses": statuses}
|
||||
|
||||
|
||||
def ensure_review_drain_loop() -> None:
|
||||
"""第一次有人轮询审核时,顺手把 worker 兜底循环拉起来(没有 beat 也能自转)。"""
|
||||
import sys
|
||||
|
||||
from django.conf import settings as dj_settings
|
||||
from django.core.cache import cache
|
||||
|
||||
if getattr(dj_settings, "CELERY_TASK_ALWAYS_EAGER", False) or "test" in sys.argv:
|
||||
return
|
||||
if not cache.add("asset-review-drain-kick", "1", timeout=300):
|
||||
return
|
||||
try:
|
||||
from apps.ai.tasks import drain_asset_reviews_task
|
||||
|
||||
drain_asset_reviews_task.delay()
|
||||
except Exception: # noqa: BLE001
|
||||
cache.delete("asset-review-drain-kick")
|
||||
logger.warning("kick asset review drain loop failed", exc_info=True)
|
||||
|
||||
|
||||
def poll_asset_review(asset: Asset) -> str:
|
||||
"""查单个真人资产审核状态并更新 review_status。返回最新状态。
|
||||
只在状态变化时落库(保留 updated_at 作为「进入 processing 的时刻」);processing 超时兜底为 failed。"""
|
||||
@@ -144,11 +211,8 @@ def poll_asset_review(asset: Asset) -> str:
|
||||
|
||||
|
||||
def poll_team_reviews(team) -> dict:
|
||||
"""轮询该团队所有「审核中」真人资产,更新状态。返回 {asset_id: status}。"""
|
||||
out: dict[str, str] = {}
|
||||
pending = Asset.objects.filter(
|
||||
team=team, category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False
|
||||
)
|
||||
for asset in pending:
|
||||
out[str(asset.id)] = poll_asset_review(asset)
|
||||
return out
|
||||
"""先把本团队未送审的人脸资产送出去,再轮询审核中状态。
|
||||
前端基础资产趴定时打这里:用户不用去后台点送审,也不用等管理员。"""
|
||||
ensure_review_drain_loop()
|
||||
submit_unsubmitted_reviews(team=team, limit=20)
|
||||
return poll_processing_reviews(team=team, limit=80)
|
||||
|
||||
@@ -890,3 +890,40 @@ class ReviewScopeTests(TestCase):
|
||||
with patch("apps.assets.review.assets_client.is_enabled", return_value=False):
|
||||
out = review.poll_team_reviews(team)
|
||||
self.assertEqual(set(out.keys()), set(keep.values()))
|
||||
|
||||
def test_poll_team_reviews_auto_submits_unsubmitted(self):
|
||||
from apps.assets import review
|
||||
|
||||
_, team = _mk_team("usub", "TeamUSub")
|
||||
empty = Asset.objects.create(
|
||||
team=team, name="待审角色", asset_type="image", source="ai_generated",
|
||||
category=Asset.Category.PERSON, review_status="",
|
||||
)
|
||||
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub, patch(
|
||||
"apps.assets.review.ensure_review_drain_loop"
|
||||
):
|
||||
review.poll_team_reviews(team)
|
||||
sub.assert_called()
|
||||
self.assertEqual(sub.call_args.args[0].id, empty.id)
|
||||
|
||||
def test_drain_platform_reviews_submits_then_polls(self):
|
||||
from apps.assets import review
|
||||
|
||||
_, team = _mk_team("drain", "TeamDrain")
|
||||
Asset.objects.create(
|
||||
team=team, name="未送", asset_type="image", source="ai_generated",
|
||||
category=Asset.Category.PERSON, review_status="",
|
||||
)
|
||||
proc = Asset.objects.create(
|
||||
team=team, name="审中", asset_type="image", source="ai_generated",
|
||||
category=Asset.Category.PERSON, review_status="processing",
|
||||
)
|
||||
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub, patch(
|
||||
"apps.assets.review.poll_asset_review", return_value="active"
|
||||
) as polled:
|
||||
out = review.drain_platform_reviews()
|
||||
self.assertEqual(out["submitted"], 1)
|
||||
self.assertEqual(out["polled"], 1)
|
||||
self.assertEqual(out["statuses"][str(proc.id)], "active")
|
||||
sub.assert_called_once()
|
||||
polled.assert_called_once()
|
||||
|
||||
@@ -142,5 +142,5 @@ def require_worker_task(task_name: str) -> None:
|
||||
_registered_task_cache[task_name] = (available, now + ttl)
|
||||
if available is False:
|
||||
exc = WorkerUnavailable()
|
||||
exc.detail = "极速成片后台任务尚未加载,请重启 Celery worker 后再试。"
|
||||
exc.detail = "一键成片后台任务尚未加载,请重启 Celery worker 后再试。"
|
||||
raise exc
|
||||
|
||||
@@ -170,14 +170,14 @@ def _public_error(raw: str) -> str:
|
||||
text = (raw or "").strip()
|
||||
lower = text.lower()
|
||||
if QUICK_SCRIPT_MODEL_NAME in lower:
|
||||
return "极速成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"
|
||||
return "一键成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"
|
||||
if "insufficient credit" in lower or "额度不足" in text:
|
||||
return "可用积分不足,极速成片已暂停"
|
||||
return "可用积分不足,一键成片已暂停"
|
||||
if "no active" in lower or "not configured" in lower or "没有可用" in text:
|
||||
return "当前缺少可用的生成模型,请联系管理员配置"
|
||||
if _looks_like_review_error(text):
|
||||
return REVIEW_FAIL_MESSAGE
|
||||
return "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
return "一键成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
|
||||
|
||||
def _safe_error(exc: Exception) -> str:
|
||||
@@ -301,7 +301,7 @@ def fail_quick_create(job: QuickCreateJob, message: str, *, internal_error: str
|
||||
job.refresh_from_db(fields=["status", "metadata", "phase"])
|
||||
if job.status in {QuickCreateJob.Status.SUCCEEDED, QuickCreateJob.Status.CANCELLED}:
|
||||
return
|
||||
public_message = (message or "极速成片暂未完成,请稍后重试").strip()[:500]
|
||||
public_message = (message or "一键成片暂未完成,请稍后重试").strip()[:500]
|
||||
metadata = dict(job.metadata or {})
|
||||
if internal_error:
|
||||
metadata["internal_error"] = internal_error[:2000]
|
||||
@@ -365,7 +365,7 @@ def _consume_script_agent(job: QuickCreateJob) -> None:
|
||||
settings = _quick_settings(project)
|
||||
user = job.created_by or project.created_by
|
||||
if user is None:
|
||||
raise ValueError("极速成片任务缺少创建人")
|
||||
raise ValueError("一键成片任务缺少创建人")
|
||||
model_config = get_quick_script_model()
|
||||
if model_config is None:
|
||||
raise ValueError(f"{QUICK_SCRIPT_MODEL_NAME} is not configured")
|
||||
@@ -376,7 +376,8 @@ def _consume_script_agent(job: QuickCreateJob) -> None:
|
||||
getattr(product, "category", "") or "",
|
||||
getattr(product, "title", "") or "",
|
||||
)
|
||||
fmt = wizard.get("presentation_format") or rec["format"]
|
||||
# 极速成片与专业创作一致,脚本表现形式统一为口播。
|
||||
fmt = "oral"
|
||||
structure = wizard.get("video_structure") or rec["structure"]
|
||||
persona = wizard.get("persona") or rec["persona"]
|
||||
format_label = PRESENTATION_FORMATS.get(fmt, fmt)
|
||||
@@ -486,7 +487,7 @@ def _advance_product(job: QuickCreateJob) -> int:
|
||||
ProductSellingPoint.objects.create(
|
||||
product=product,
|
||||
title=product.title,
|
||||
detail="由极速成片根据商品名称自动填写",
|
||||
detail="由一键成片根据商品名称自动填写",
|
||||
sort_order=0,
|
||||
)
|
||||
_save_job(
|
||||
@@ -695,7 +696,7 @@ def _start_base_assets(job: QuickCreateJob) -> None:
|
||||
project = job.project
|
||||
user = job.created_by or project.created_by
|
||||
if user is None:
|
||||
raise ValueError("极速成片任务缺少创建人")
|
||||
raise ValueError("一键成片任务缺少创建人")
|
||||
with transaction.atomic():
|
||||
job = QuickCreateJob.objects.select_for_update().select_related("project").get(id=job.id)
|
||||
metadata = dict(job.metadata or {})
|
||||
@@ -939,7 +940,7 @@ def _start_videos(job: QuickCreateJob) -> None:
|
||||
submit_video_segment(
|
||||
video_segment=segment,
|
||||
user=job.created_by or job.project.created_by,
|
||||
prompt="极速成片自动生成,严格遵循本镜故事板与脚本。",
|
||||
prompt="一键成片自动生成,严格遵循本镜故事板与脚本。",
|
||||
model_config_id=settings["video_model_config_id"] or None,
|
||||
aspect_ratio=settings["aspect_ratio"],
|
||||
resolution=settings["resolution"],
|
||||
|
||||
@@ -307,14 +307,14 @@ class QuickCreateApiTests(TestCase):
|
||||
|
||||
def test_retry_api_resumes_failed_job(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="续跑商品")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="续跑商品 · 极速成片")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="续跑商品 · 一键成片")
|
||||
job = QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
project=project,
|
||||
status=QuickCreateJob.Status.FAILED,
|
||||
phase=QuickCreateJob.Phase.PRODUCTION,
|
||||
message="极速成片暂未完成,请稍后重试或进入专业模式查看",
|
||||
message="一键成片暂未完成,请稍后重试或进入专业模式查看",
|
||||
)
|
||||
with patch("apps.projects.tasks.advance_quick_create_task.apply_async") as enqueue:
|
||||
response = self.client.post(f"/api/projects/quick-create-retry/{job.id}/")
|
||||
@@ -325,7 +325,7 @@ class QuickCreateApiTests(TestCase):
|
||||
|
||||
def test_history_lists_completed_jobs_only(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="历史商品")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="历史商品 · 极速成片")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="历史商品 · 一键成片")
|
||||
QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
@@ -334,17 +334,17 @@ class QuickCreateApiTests(TestCase):
|
||||
phase=QuickCreateJob.Phase.COMPLETE,
|
||||
)
|
||||
failed_product = Product.objects.create(team=self.team, created_by=self.user, title="失败商品")
|
||||
failed_project = Project.objects.create(team=self.team, created_by=self.user, product=failed_product, name="失败商品 · 极速成片")
|
||||
failed_project = Project.objects.create(team=self.team, created_by=self.user, product=failed_product, name="失败商品 · 一键成片")
|
||||
QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
project=failed_project,
|
||||
status=QuickCreateJob.Status.FAILED,
|
||||
phase=QuickCreateJob.Phase.PRODUCTION,
|
||||
message="极速成片暂未完成,请稍后重试或进入专业模式查看",
|
||||
message="一键成片暂未完成,请稍后重试或进入专业模式查看",
|
||||
)
|
||||
running_product = Product.objects.create(team=self.team, created_by=self.user, title="进行中商品")
|
||||
running_project = Project.objects.create(team=self.team, created_by=self.user, product=running_product, name="进行中商品 · 极速成片")
|
||||
running_project = Project.objects.create(team=self.team, created_by=self.user, product=running_product, name="进行中商品 · 一键成片")
|
||||
QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
@@ -369,13 +369,13 @@ class QuickCreateApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
titles = [item["title"] for item in response.data["results"]]
|
||||
self.assertIn("历史商品 · 极速成片", titles)
|
||||
self.assertNotIn("失败商品 · 极速成片", titles)
|
||||
self.assertNotIn("进行中商品 · 极速成片", titles)
|
||||
self.assertIn("历史商品 · 一键成片", titles)
|
||||
self.assertNotIn("失败商品 · 一键成片", titles)
|
||||
self.assertNotIn("进行中商品 · 一键成片", titles)
|
||||
|
||||
def test_history_hides_deleted_projects(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="还在的商品")
|
||||
alive = Project.objects.create(team=self.team, created_by=self.user, product=product, name="还在的商品 · 极速成片")
|
||||
alive = Project.objects.create(team=self.team, created_by=self.user, product=product, name="还在的商品 · 一键成片")
|
||||
QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
@@ -388,7 +388,7 @@ class QuickCreateApiTests(TestCase):
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=deleted_product,
|
||||
name="已删商品 · 极速成片",
|
||||
name="已删商品 · 一键成片",
|
||||
is_deleted=True,
|
||||
)
|
||||
QuickCreateJob.objects.create(
|
||||
@@ -403,8 +403,8 @@ class QuickCreateApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
titles = [item["title"] for item in response.data["results"]]
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
self.assertIn("还在的商品 · 极速成片", titles)
|
||||
self.assertNotIn("已删商品 · 极速成片", titles)
|
||||
self.assertIn("还在的商品 · 一键成片", titles)
|
||||
self.assertNotIn("已删商品 · 一键成片", titles)
|
||||
|
||||
def test_list_serializer_flags_quick_create_projects(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="列表商品")
|
||||
@@ -412,7 +412,7 @@ class QuickCreateApiTests(TestCase):
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=product,
|
||||
name="列表商品 · 极速成片",
|
||||
name="列表商品 · 一键成片",
|
||||
metadata={"quick_create": True},
|
||||
)
|
||||
normal = Project.objects.create(
|
||||
@@ -436,7 +436,7 @@ class QuickCreateApiTests(TestCase):
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=product,
|
||||
name="锁单商品 · 极速成片",
|
||||
name="锁单商品 · 一键成片",
|
||||
metadata={"quick_create": True},
|
||||
)
|
||||
job = QuickCreateJob.objects.create(
|
||||
@@ -448,7 +448,7 @@ class QuickCreateApiTests(TestCase):
|
||||
|
||||
blocked = self.client.patch(f"/api/projects/{project.id}/", {"name": "不该改"}, format="json")
|
||||
self.assertEqual(blocked.status_code, 409)
|
||||
self.assertIn("极速成片", str(blocked.data))
|
||||
self.assertIn("一键成片", str(blocked.data))
|
||||
|
||||
allowed = self.client.get(f"/api/projects/{project.id}/")
|
||||
self.assertEqual(allowed.status_code, 200)
|
||||
@@ -473,7 +473,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=self.product,
|
||||
name="测试精华 · 极速成片",
|
||||
name="测试精华 · 一键成片",
|
||||
status=Project.Status.SCRIPTING,
|
||||
current_stage=ProjectStage.Stage.SCRIPT,
|
||||
)
|
||||
@@ -554,7 +554,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
QuickCreateJob.objects.filter(id=self.job.id).update(
|
||||
status=QuickCreateJob.Status.QUEUED,
|
||||
phase=QuickCreateJob.Phase.PRODUCT,
|
||||
message="等待开始极速成片",
|
||||
message="等待开始一键成片",
|
||||
updated_at=timezone.now() - timedelta(seconds=30),
|
||||
)
|
||||
self.job.refresh_from_db()
|
||||
@@ -748,7 +748,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
self.job.metadata = {"storyboard_started": True}
|
||||
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
|
||||
|
||||
fail_quick_create(self.job, "极速成片暂未完成,请稍后重试或进入专业模式查看", internal_error="Timeout reading from socket")
|
||||
fail_quick_create(self.job, "一键成片暂未完成,请稍后重试或进入专业模式查看", internal_error="Timeout reading from socket")
|
||||
self.job.refresh_from_db()
|
||||
self.project.refresh_from_db()
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.FAILED)
|
||||
@@ -772,7 +772,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
def test_recover_clears_false_failed_project_before_video_starts(self):
|
||||
self.project.status = Project.Status.FAILED
|
||||
self.project.current_stage = ProjectStage.Stage.VIDEO
|
||||
self.project.failure_reason = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.project.failure_reason = "一键成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.project.save(update_fields=["status", "current_stage", "failure_reason", "updated_at"])
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
@@ -805,7 +805,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
def test_resume_failed_production_job_keeps_progress(self, enqueue):
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.metadata = {"storyboard_started": True, "transient_retries": 8, "video_fail_retries": 2}
|
||||
self.job.save(update_fields=["status", "phase", "error_message", "metadata", "updated_at"])
|
||||
|
||||
@@ -859,7 +859,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
def test_failed_job_serializer_rewrites_hidden_moderation_error(self):
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.metadata = {"internal_error": "400 moderation_blocked safety_violations=[sexual]"}
|
||||
self.job.save(update_fields=["status", "phase", "error_message", "metadata", "updated_at"])
|
||||
data = QuickCreateJobSerializer(self.job).data
|
||||
@@ -882,7 +882,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
segment.save(update_fields=["adopted_version", "status", "updated_at"])
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.save(update_fields=["status", "phase", "error_message", "updated_at"])
|
||||
|
||||
recover_quick_create(self.job)
|
||||
@@ -894,7 +894,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
"""专业模式完成后,极速任务的旧失败状态必须自动被回收。"""
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.save(update_fields=["status", "phase", "error_message", "updated_at"])
|
||||
self.project.status = Project.Status.COMPLETED
|
||||
self.project.current_stage = ProjectStage.Stage.VIDEO
|
||||
@@ -1143,7 +1143,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
base_ids = self._ready_asset_tasks()
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.ASSETS
|
||||
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.metadata = {
|
||||
"base_asset_task_ids": base_ids,
|
||||
"assets_started": True,
|
||||
@@ -1194,7 +1194,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="极速成片",
|
||||
name="一键成片",
|
||||
asset_type=Asset.Type.VIDEO,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.VIDEO_CLIP,
|
||||
|
||||
@@ -98,7 +98,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class QuickCreateInProgress(APIException):
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
default_detail = "该项目正在极速成片中,请到极速成片页查看进度"
|
||||
default_detail = "该项目正在一键成片中,请到一键成片页查看进度"
|
||||
default_code = "quick_create_running"
|
||||
|
||||
|
||||
@@ -553,7 +553,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
]
|
||||
if text_model is None:
|
||||
return Response(
|
||||
{"detail": "极速成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"},
|
||||
{"detail": "一键成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
if missing:
|
||||
@@ -653,7 +653,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
product=product,
|
||||
name=f"{name} · 极速成片",
|
||||
name=f"{name} · 一键成片",
|
||||
status=Project.Status.SCRIPTING,
|
||||
current_stage=ProjectStage.Stage.SCRIPT,
|
||||
metadata={
|
||||
@@ -665,7 +665,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"video_model_config_id": str(video_model.id),
|
||||
"video_model_name": video_model.name,
|
||||
"video_model_label": video_model.display_name,
|
||||
"presentation_format": rec["format"],
|
||||
"presentation_format": "oral",
|
||||
"video_structure": rec["structure"],
|
||||
"persona": rec["persona"],
|
||||
},
|
||||
@@ -676,7 +676,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
ProductSellingPoint.objects.create(
|
||||
product=product,
|
||||
title=name,
|
||||
detail="由极速成片根据商品名称自动填写",
|
||||
detail="由一键成片根据商品名称自动填写",
|
||||
sort_order=0,
|
||||
)
|
||||
job = QuickCreateJob.objects.create(
|
||||
@@ -686,7 +686,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
status=QuickCreateJob.Status.QUEUED,
|
||||
phase=QuickCreateJob.Phase.PRODUCT,
|
||||
progress=0,
|
||||
message="等待开始极速成片",
|
||||
message="等待开始一键成片",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -722,7 +722,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
def quick_create_status(self, request, job_id=None):
|
||||
job = self._quick_job_queryset().filter(id=job_id).first()
|
||||
if job is None:
|
||||
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response({"detail": "一键成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
from .services.quick_create import recover_quick_create
|
||||
|
||||
try:
|
||||
@@ -736,7 +736,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
def quick_create_cancel(self, request, job_id=None):
|
||||
job = self._quick_job_queryset().filter(id=job_id).first()
|
||||
if job is None:
|
||||
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response({"detail": "一键成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if job.status == QuickCreateJob.Status.SUCCEEDED:
|
||||
return Response({"detail": "已完成的任务不能取消"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
from .services.quick_create import cancel_quick_create
|
||||
@@ -749,7 +749,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
def quick_create_retry(self, request, job_id=None):
|
||||
job = self._quick_job_queryset().filter(id=job_id).first()
|
||||
if job is None:
|
||||
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response({"detail": "一键成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if job.status == QuickCreateJob.Status.SUCCEEDED:
|
||||
return Response({"detail": "任务已完成"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if job.status == QuickCreateJob.Status.CANCELLED:
|
||||
@@ -808,14 +808,13 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"template_outline": render_outline_text({"outline": template.outline, "cta": template.cta}),
|
||||
}
|
||||
)
|
||||
# 套路参数只在模板真有值时覆盖,空值不要把设定卡的智能推荐顶掉。
|
||||
# 旧模板可能存了中文标签(「短剧」),这里归一成 key 再写入 wizard。
|
||||
# 表现形式已统一为口播;旧模板里的短剧/Vlog 只兼容读取结构,不能覆盖当前规则。
|
||||
raw_format = getattr(template, "presentation_format", "") or ""
|
||||
raw_structure = getattr(template, "video_structure", "") or ""
|
||||
if raw_format or raw_structure:
|
||||
fmt, structure = coerce_template_combo(raw_format, raw_structure)
|
||||
wizard["presentation_format"] = fmt
|
||||
_fmt, structure = coerce_template_combo(raw_format, raw_structure)
|
||||
wizard["video_structure"] = structure
|
||||
wizard["presentation_format"] = "oral"
|
||||
persona = coerce_persona(getattr(template, "persona", "") or "")
|
||||
if persona:
|
||||
wizard["persona"] = persona
|
||||
@@ -860,7 +859,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"""对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。
|
||||
请求体:mode(auto|theme|revise)、prompt、model_config_id、selling_point_ids、persona、
|
||||
base_version_id(改稿)、aspect_ratio、total_duration(15/30/45/60)、
|
||||
presentation_format(oral|drama|vlog)、video_structure(pain|contrast|review|scene)。
|
||||
video_structure(pain|contrast|review|scene)。表现形式固定为口播。
|
||||
响应:text/event-stream,逐帧吐 tool/delta/draft/saved/done/error。"""
|
||||
project = self.get_object()
|
||||
mode = str(request.data.get("mode") or "auto")
|
||||
@@ -870,7 +869,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
# 非法值一律由 agent 侧 coerce 兜底(夹区间/回落默认),这里不做 400,避免生成被参数噪声打断
|
||||
total_duration = coerce_total_duration(request.data.get("total_duration"))
|
||||
presentation_format, video_structure = combo_keys(
|
||||
request.data.get("presentation_format"),
|
||||
"oral",
|
||||
request.data.get("video_structure"),
|
||||
)
|
||||
wizard = ((project.metadata or {}).get("wizard") if isinstance(project.metadata, dict) else None) or {}
|
||||
|
||||
Reference in New Issue
Block a user