完成视频复刻和优化

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