完善视频提炼和视频复刻完善提交测试

This commit is contained in:
Azmat@qq.com
2026-08-28 10:15:15 +08:00
parent 52c5cdae1d
commit 849b14a0f6
33 changed files with 1475 additions and 208 deletions
+47
View File
@@ -523,6 +523,53 @@ class VideoDigestApiTests(TestCase):
self.assertEqual(polled.data["task_id"], str(task.id))
self.assertEqual(polled.data["file_name"], "参考视频.mp4")
def test_inflight_without_preview_urls_is_still_restorable(self):
"""封面/原片直链拿不到(TOS 慢、签名失败)不代表任务死了。
前端曾拿「有没有预览图」当判活依据,退出再进来时把正在跑的任务直接取消掉,
用户看到的就是「任务凭空消失」。这里锁住:没有任何预览 URL 也照样回 processing。
"""
from apps.ai.models import AITask
task = self._seed_digest_task(status=AITask.Status.SUBMITTED, extra_key="nopreview")
task.request_payload = {
**task.request_payload,
"video_url": "", "cover_url": "", "source_url": "", "source_key": "", "cover_key": "",
}
task.save(update_fields=["request_payload"])
listed = self.client.get("/api/ai/video-digest/")
self.assertEqual(listed.data["inflight"]["id"], str(task.id))
self.assertEqual(listed.data["inflight"]["status"], "processing")
self.assertEqual(listed.data["inflight"]["video_url"], "")
self.assertEqual(listed.data["inflight"]["cover_url"], "")
polled = self.client.get(f"/api/ai/video-digest/{task.id}/")
self.assertEqual(polled.data["status"], "processing")
task.refresh_from_db()
self.assertEqual(task.status, AITask.Status.SUBMITTED) # 只是查看,不该被动到
def test_second_submit_is_blocked_while_one_is_running(self):
"""已有提炼在跑时再提交要 409,并把在跑的那条带回去让前端接上。"""
from apps.ai.models import AITask
running = self._seed_digest_task(status=AITask.Status.SUBMITTED, extra_key="busy")
resp = self.client.post(
"/api/ai/video-digest/",
{"reuse_task_id": str(running.id)},
format="multipart",
)
self.assertEqual(resp.status_code, 409)
self.assertIn("完成或取消后", resp.json()["detail"])
self.assertEqual(resp.json()["inflight"]["id"], str(running.id))
# 收掉之后放行(这里只验闸门,不验后面的真实提炼)
running.status = AITask.Status.CANCELLED
running.save(update_fields=["status"])
from apps.ai.video_digest import get_inflight_team_digest
self.assertIsNone(get_inflight_team_digest(team=self.team))
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)
+148 -3
View File
@@ -172,6 +172,12 @@ class SubmitVideoReplaceTests(TestCase):
self.assertNotIn("reference_video", roles) # 参考视频不再发给火山
self.assertNotIn("video_url", [item.get("type") for item in content])
def _retire(self, task):
"""把任务收成终态,好让单飞闸放行下一次提交。"""
task.status = AITask.Status.CANCELLED
task.save(update_fields=["status", "updated_at"])
return task
def _fake_source_file(self):
"""_FileFromPath 会 stat 真实文件,给个空的临时 mp4 顶替 TOS 下载结果。"""
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
@@ -240,6 +246,38 @@ class SubmitVideoReplaceTests(TestCase):
# 提炼阶段绝不能掺进商品名,否则拆出来的就不是「这条参考视频原本的样子」
self.assertNotIn("净颜精华", str(seen["messages"]))
def test_digest_call_is_recorded_for_admin_task_monitor(self):
"""复刻里的提炼要在运营后台任务详情的尝试链里看得到,否则出问题无从查起。"""
from apps.ai.models import AIModelAttempt
task = self._run_digest(self._submit())
attempts = list(AIModelAttempt.objects.filter(task=task, operation="video_digest").order_by("sequence"))
self.assertEqual(len(attempts), 1)
self.assertEqual(attempts[0].status, AIModelAttempt.Status.SUCCEEDED)
self.assertEqual(attempts[0].capability, "text")
self.assertEqual(attempts[0].request_summary.get("for"), "video_replace")
self.assertTrue(attempts[0].response_summary.get("shots"))
self.assertIsNotNone(attempts[0].finished_at)
def test_failed_digest_attempts_are_recorded_too(self):
"""废稿重试也要各留一条,才能看出是「模型抽风」而不是「没跑」。"""
from apps.ai import video_digest
from apps.ai.models import AIModelAttempt
task = self._submit()
text_model = ModelConfig.objects.filter(capability="text", status="active").first()
with patch.object(video_digest, "_download_source_to_temp", return_value=self._fake_source_file()), \
patch.object(
video_digest, "digest_input_from_upload",
return_value=(None, [], 8.0, {"width": 720, "height": 1280, "file_name": "参考.mp4"}),
), \
patch.object(video_digest, "resolve_digest_model_config", return_value=text_model), \
patch("apps.ai.services._collect_extract_text", side_effect=[("", {}), (DIGEST_SAMPLE, {})]):
run_replace_digest(task)
attempts = list(AIModelAttempt.objects.filter(task=task, operation="video_digest").order_by("sequence"))
self.assertEqual([a.status for a in attempts], ["failed", "succeeded"])
self.assertTrue(attempts[1].is_retry)
def test_digest_retries_once_before_giving_up(self):
"""Gemini 偶尔吐废稿。复刻失败要用户从头重选素材,所以同模型再试一次。"""
from apps.ai import video_digest
@@ -278,6 +316,52 @@ class SubmitVideoReplaceTests(TestCase):
self.assertFalse(self.provider.create_video_task.called)
self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0)
def test_second_submit_is_blocked_while_one_is_running(self):
"""刷新页面时前端拉状态有空档,用户容易以为「没任务」再点一次。
后端必须拦住,否则重复建任务、重复扣费。"""
from apps.ai.video_replace import VideoReplaceInProgress, get_inflight_video_replace
first = self._submit()
with self.assertRaises(VideoReplaceInProgress) as ctx:
self._submit()
self.assertEqual(ctx.exception.task.id, first.id) # 409 要带上在跑的那条
self.assertIn("完成或取消后", str(ctx.exception))
self.assertEqual(get_inflight_video_replace(self.team).id, first.id)
# 收掉之后放行
self._retire(first)
self.assertIsNone(get_inflight_video_replace(self.team))
self.assertEqual(self._submit().status, AITask.Status.CREATED)
def test_replace_api_returns_409_with_inflight(self):
"""前端拿这个 409 直接切到「进行中」,而不是弹个错就完了。"""
from rest_framework.test import APIClient
from apps.accounts.models import TeamMember
TeamMember.objects.get_or_create(
team=self.team, user=self.user, defaults={"role": "owner", "status": "active"}
)
client = APIClient()
client.force_authenticate(self.user)
payload = {
"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,
}
first = client.post("/api/ai/video-replace/", payload, format="json")
self.assertEqual(first.status_code, 202)
again = client.post("/api/ai/video-replace/", payload, format="json")
self.assertEqual(again.status_code, 409)
self.assertEqual(again.json()["inflight"]["id"], first.json()["task"]["id"])
listed = client.get("/api/ai/video-replace/").json()
self.assertEqual(listed["inflight"]["id"], first.json()["task"]["id"])
def test_missing_digest_model_is_rejected_at_submit(self):
"""提炼模型没配好要当场 400,不能建了任务、等一轮提炼才告诉用户。"""
@@ -340,6 +424,26 @@ class SubmitVideoReplaceTests(TestCase):
self.assertNotIn("三视图", prompt)
self.assertIn("@目标商品", prompt)
def test_triview_only_product_still_fills_target_slot(self):
"""商品只有三视图、没有实拍图时,三视图要顶上来当 @目标商品 ——
否则提示词里的 @目标商品 找不到同名 label,火山收到的是一句没有指代的字面量。"""
bare = Product.objects.create(team=self.team, created_by=self.user, title="只有三视图")
tri = _asset(self.team, self.user, name="三视图.png", preview="http://tos/only-tri.png")
tri.metadata = {"product_id": str(bare.id), "view": "three_view"}
tri.save(update_fields=["metadata"])
task = self._submit(product_id=str(bare.id))
labels = [ref["label"] for ref in task.request_payload["references"]]
self.assertEqual(labels, ["目标商品"])
task = self._run_digest(task)
self.assertEqual(task.status, AITask.Status.SUBMITTED)
prompt = task.request_payload["prompt"]
self.assertIn("@目标商品", prompt)
self.assertNotIn("@目标商品三视图", prompt) # 没有第二张图就别点名它
api_prompt = task.request_payload.get("api_prompt") or ""
self.assertNotIn("@目标商品", api_prompt) # 已被换成火山认的「图片N」
def test_triview_yields_budget_so_refs_never_exceed_nine(self):
"""三视图占一张名额:商品图要让位,否则火山那边超 9 张直接拒。"""
from apps.ai.video_replace import MAX_IMAGES
@@ -369,10 +473,51 @@ class SubmitVideoReplaceTests(TestCase):
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)
def test_product_accepts_up_to_60s_but_character_still_caps_at_15s(self):
"""商品复刻的参考视频只喂提炼模型,不发火山 → 放宽到 60 秒;
角色复刻仍直传火山,15 秒是火山硬限制,不能跟着放宽。"""
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
model = Model.objects.create(team=self.team, name="薇薇", portrait_asset=portrait)
mid = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="半分钟.mp4", duration_ms=30000)
long_ok = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="一分钟.mp4", duration_ms=60000)
too_long = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="超长.mp4", duration_ms=75000)
# 商品复刻:30s / 60s 都放行。单飞闸只允许一条在跑,所以每次提交后收掉再试下一条。
self.assertEqual(self._retire(self._submit(video_asset_id=str(mid.id))).status, AITask.Status.CANCELLED)
self.assertEqual(self._retire(self._submit(video_asset_id=str(long_ok.id))).status, AITask.Status.CANCELLED)
with self.assertRaisesMessage(ValueError, "不能超过 60 秒"):
self._submit(video_asset_id=str(too_long.id))
# 角色复刻:超过 15 秒仍然拒
with self.assertRaisesMessage(ValueError, "不能超过 15 秒"):
self._submit(video_asset_id=str(long_video.id))
self._submit(
replace_mode="character", product_id="", model_id=str(model.id),
video_asset_id=str(mid.id),
)
def test_long_source_asks_model_to_condense(self):
"""60 秒参考稿 + 15 秒成片:必须让模型压缩改编,不然会照着拍到一半戛然而止。"""
long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="一分钟.mp4", duration_ms=58000)
# duration=None → 按参考视频推导成片时长(火山单次上限 15 秒),与线上一致
task = self._run_digest(self._submit(video_asset_id=str(long_video.id), duration=None))
prompt = task.request_payload["prompt"]
self.assertIn("压缩改编", prompt)
self.assertIn("58 秒", prompt)
self.assertIn("15 秒", prompt)
def test_short_source_skips_condense_note(self):
"""参考视频和成片时长差不多时别啰嗦。"""
task = self._run_digest(self._submit(duration=None)) # setUp 的参考视频 8 秒 → 成片也 8 秒
self.assertNotIn("压缩改编", task.request_payload["prompt"])
def test_prompt_always_pins_character_consistency(self):
"""上传图里可能有人物(服饰模特图/真人手持图),要锁住人物一致;
没有人物时也要说清楚「别从商品图凭空套脸」。"""
prompt = self._run_digest(self._submit()).request_payload["prompt"]
self.assertIn("自始至终是同一个人", prompt)
self.assertIn("不要中途换脸", prompt)
self.assertIn("参考图里如果有人物", prompt)
self.assertIn("不要从商品图上凭空套一张脸", prompt)
def test_accepts_15s_video_with_probe_slack(self):
from apps.ai.media_probe import REF_DURATION_MAX
+103 -2
View File
@@ -59,6 +59,14 @@ class VideoDigestError(ValueError):
"""用户可见的失败(文件不合格 / ffmpeg 读不动),一律 400。"""
class VideoDigestInProgress(VideoDigestError):
"""本团队已有提炼在跑。视图转 409,并把在跑的那条带回去让前端接上。"""
def __init__(self, job: dict):
super().__init__("已有一个视频正在提炼中,完成或取消后才能再提交")
self.job = job
@dataclass(frozen=True)
class VideoFrame:
at_seconds: int
@@ -923,6 +931,14 @@ def submit_team_digest(*, team, user, upload=None, reuse_task_id=None, model_con
from apps.billing.pricing import quote_video_digest
from apps.billing.services.ledger import reserve_credit
# 单飞闸:一个团队同时只允许一条提炼在跑。
# 刷新页面时前端要异步拉状态,这个空档用户很容易以为「没任务」再传一次 —— 前端加载态
# 只是治标,真正兜底在这里。先跑过期回收,死任务不会把人永久锁住。
expire_stale_team_digests(team=team)
running = get_inflight_team_digest(team=team)
if running:
raise VideoDigestInProgress(running)
model_config = resolve_digest_model_config(preferred_id=model_config_id)
if model_config is None:
raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员")
@@ -1359,6 +1375,9 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
复刻本来就是一次收费,拆解是它的内部工序。模型调用的审计仍记在传入的复刻
task 上(AIModelAttempt),出问题查得到。
"""
import time
from apps.ai.models import AIModelAttempt
from apps.ai.services import _collect_extract_text, get_text_provider
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
@@ -1408,7 +1427,12 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
expected_frames = len(frames) or (24 if video else 0)
digest = ""
last_error: Exception | None = None
for attempt in range(1, DIGEST_MAX_ATTEMPTS + 1):
for attempt_no in range(1, DIGEST_MAX_ATTEMPTS + 1):
# 这一步不走 execute_model_call(它要求 task.model_config 就是本次主模型,
# 而复刻任务的主模型是 Seedance),所以尝试记录得自己落 —— 不落的话
# 运营后台的任务详情看不到这次 Gemini 调用,出问题无从查起。
record = _open_digest_attempt(task, model_config, attempt_no, duration, expected_frames, video)
started = time.monotonic()
try:
text, _payload = _collect_extract_text(
provider,
@@ -1418,12 +1442,19 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
extra_body={"max_tokens": DIGEST_MAX_TOKENS},
)
digest = validate_digest_text(text, duration=duration, frame_count=expected_frames)
_close_digest_attempt(
record, AIModelAttempt.Status.SUCCEEDED, started,
summary={"chars": len(digest), "shots": shot_count(digest)},
)
break
except Exception as exc: # noqa: BLE001 — 空文/废稿/网络抖动都值得再来一次
last_error = exc
_close_digest_attempt(
record, AIModelAttempt.Status.FAILED, started, error=str(exc),
)
logger.warning(
"replace digest attempt %s/%s failed for task %s: %s",
attempt, DIGEST_MAX_ATTEMPTS, task.id, exc,
attempt_no, DIGEST_MAX_ATTEMPTS, task.id, exc,
)
if not digest:
raise VideoDigestError(f"参考视频拆解失败:{last_error}")
@@ -1439,3 +1470,73 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
finally:
if local_path:
Path(local_path).unlink(missing_ok=True)
def _open_digest_attempt(task, model_config, attempt_no: int, duration: float, frames: int, video):
"""给复刻任务补一条「提炼」尝试记录,让运营后台的尝试链能看到这次 Gemini 调用。
审计失败绝不能拖垮拆解本身,所以整段吞异常、返回 None。
"""
from django.db import transaction
from django.db.models import Max
from django.utils import timezone
from apps.ai.models import AIModelAttempt, AITask
try:
with transaction.atomic():
AITask.objects.select_for_update().only("id").get(pk=task.pk)
sequence = (
AIModelAttempt.objects.filter(task_id=task.pk).aggregate(m=Max("sequence"))["m"] or 0
) + 1
provider = model_config.provider
return AIModelAttempt.objects.create(
task_id=task.pk,
sequence=sequence,
provider=provider,
model_config=model_config,
provider_name=provider.name,
provider_display_name=provider.display_name,
model_name=model_config.name,
model_display_name=model_config.display_name,
public_model_name=model_config.display_name or model_config.name,
capability="text",
operation="video_digest",
status=AIModelAttempt.Status.STARTED,
is_retry=attempt_no > 1,
started_at=timezone.now(),
request_summary={
"for": "video_replace",
"duration_seconds": round(float(duration or 0), 2),
"frame_count": frames,
"input": "native_video" if video is not None else "frames",
},
)
except Exception: # noqa: BLE001 — 审计写失败不影响出片
logger.warning("video replace digest attempt record failed", exc_info=True)
return None
def _close_digest_attempt(record, status, started_at: float, *, summary: dict | None = None, error: str = ""):
import time
from django.utils import timezone
if record is None:
return
try:
record.status = status
record.finished_at = timezone.now()
record.duration_ms = max(0, round((time.monotonic() - started_at) * 1000))
if summary:
record.response_summary = summary
if error:
record.error_type = "processing_failed"
record.raw_error = error[:2000]
record.safe_error_summary = "参考视频拆解未通过校验"
record.save(update_fields=[
"status", "finished_at", "duration_ms", "response_summary",
"error_type", "raw_error", "safe_error_summary", "updated_at",
])
except Exception: # noqa: BLE001
logger.warning("video replace digest attempt close failed", exc_info=True)
+84 -6
View File
@@ -43,10 +43,38 @@ LEGACY_PROMPT_PREFIX = "[视频复刻]"
# 商品三视图在商品库是 standalone Asset(metadata.view=three_view + product_id),
# 和项目内的 BaseAssetGroup 是两套存储,这里直接取商品库那份。
TRIVIEW_LABEL = "目标商品三视图"
# 商品复刻的参考视频不进火山、只喂提炼模型,所以不受火山 15 秒参考视频限制。
# 半秒容差同 media_probe 的老规矩(60 秒成片常被探成 60.02)。角色复刻仍是 15 秒。
PRODUCT_REF_DURATION_MAX = 60.5
REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试"
REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试"
REVIEW_SUBMIT_FAILED = "素材提交审核失败,请稍后重试"
class VideoReplaceInProgress(ValueError):
"""本团队已有复刻在跑。视图转 409,并把在跑的那条带回去让前端接上。"""
def __init__(self, task):
super().__init__("已有一个视频正在复刻中,完成或取消后才能再提交")
self.task = task
def get_inflight_video_replace(team):
"""本团队正在跑的复刻任务(含审核中 / 提炼中)。没有返回 None。"""
_reap_stale_free_video_tasks(team=team)
return (
AITask.objects.filter(
team=team,
task_type=AITask.Type.FREE_VIDEO,
status__in=IN_FLIGHT_STATUSES,
is_deleted=False,
purged_at__isnull=True,
)
.filter(video_replace_q())
.order_by("-created_at")
.first()
)
# 商品复刻不再把参考视频直接丢给火山(实测换不动商品,出片跑偏),改走两步:
# 先用「提炼提示词」那一整套(同一份 SKILL.md + 同一个 Gemini 3.1 Pro)把参考视频拆成
# 中文分镜稿,再把分镜稿当导演脚本、连同商品图一起交给 Seedance。
@@ -62,8 +90,22 @@ PRODUCT_DIGEST_TAIL = (
"不要改造型、不要改配色、不要凭空补细节。\n"
"{triview}"
"3. 人物、场景、光线、色调、动作和表情按分镜稿保持不变,只换商品。\n"
" · 全片的人物必须自始至终是同一个人:五官、发型、体态和服装在所有镜头里保持一致,"
"不要中途换脸、换人或换装。\n"
" · 参考图里如果有人物(例如服饰类的模特图、真人手持商品的图),画面中的人物就以那张参考图为准,"
"五官、发型、体态和服装必须与之一致;参考图里只有商品、没有人物时,就按分镜稿描述的人物去演,"
"不要从商品图上凭空套一张脸。\n"
"4. 台词/旁白按分镜稿逐字念出;原文提到旧商品名称的地方,改说「{subject}」。\n"
"5. 分镜稿里的「字幕」栏只是对原片的记录,不要把这些文字画到画面上。"
"{condense}"
)
# 参考视频比成片长时才追加(火山单次出片上限 15 秒,60 秒参考稿必须压缩改编,
# 否则模型会照着分镜稿从头拍、拍到 15 秒戛然而止)。
PRODUCT_CONDENSE_NOTE = (
"\n6. 参考视频约 {source} 秒,而本次成片只有 {output} 秒:请按分镜稿的结构和节奏**压缩改编**,"
"保留主线、最关键的卖点镜头和结尾收束,可以合并或省略次要镜头;"
"不要只拍开头几镜就停,也不要把画面加速成快放。"
)
# 商品库里有三视图时才追加这一条(没有就不提,免得模型去找一张不存在的图)。
PRODUCT_TRIVIEW_NOTE = (
@@ -107,7 +149,14 @@ def compact_digest_for_video(digest_text: str) -> str:
return "\n".join(line for line in lines if line.strip()).strip()
def build_product_replace_prompt(digest_text: str, subject_name: str, *, has_triview: bool = False) -> str:
def build_product_replace_prompt(
digest_text: str,
subject_name: str,
*,
has_triview: bool = False,
source_seconds: float = 0,
output_seconds: int = 0,
) -> str:
"""分镜稿 + 商品替换要求 → 交给 Seedance 的完整提示词。
@目标商品 必须与 references 里第一张商品图的 label 对上,否则 build_content_items
@@ -119,9 +168,18 @@ def build_product_replace_prompt(digest_text: str, subject_name: str, *, has_tri
if not body:
raise ValueError("参考视频拆解结果为空,请重试")
subject = (subject_name or "").strip() or "目标商品"
source = int(round(float(source_seconds or 0)))
output = int(output_seconds or 0)
# 差 3 秒以内不啰嗦,模型自己会收;差得多才明确要求压缩改编。
condense = (
PRODUCT_CONDENSE_NOTE.format(source=source, output=output)
if source and output and source - output >= 3
else ""
)
tail = PRODUCT_DIGEST_TAIL.format(
subject=subject,
triview=PRODUCT_TRIVIEW_NOTE if has_triview else "",
condense=condense,
)
return enforce_no_embedded_captions(f"{PRODUCT_DIGEST_HEAD}\n\n{body}\n\n{tail}")
@@ -190,9 +248,20 @@ def submit_video_replace(*, team, user, params: dict):
if not has_model and not has_temp:
raise ValueError("请选择角色或上传角色参考图")
# 单飞闸:一个团队同时只允许一条复刻在跑。刷新页面时前端拉状态有空档,
# 用户容易以为「没任务」再点一次,重复扣费。_reap_stale_free_video_tasks 会先回收
# 死任务(CREATED 超 16 分钟等),所以不会被永久锁住。
running = get_inflight_video_replace(team)
if running is not None:
raise VideoReplaceInProgress(running)
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:
if replace_mode == "product":
if video_seconds > PRODUCT_REF_DURATION_MAX:
raise ValueError("参考视频不能超过 60 秒,请剪短后重试")
elif video_seconds > REF_DURATION_MAX:
# 角色复刻把参考视频直传火山,15 秒是火山那边的硬限制,不能跟着放宽。
raise ValueError("参考视频不能超过 15 秒,请剪短后重试")
if has_product:
@@ -345,7 +414,11 @@ def run_replace_digest(task) -> None:
(ref or {}).get("label") == TRIVIEW_LABEL for ref in (payload.get("references") or [])
)
prompt = build_product_replace_prompt(
digest, str(payload.get("subject_name") or ""), has_triview=has_triview
digest,
str(payload.get("subject_name") or ""),
has_triview=has_triview,
source_seconds=float(payload.get("digest_source_duration") or 0),
output_seconds=int(payload.get("duration") or 0),
)
except (VideoDigestError, ValueError) as exc:
_fail_reviewing_task(task, str(exc), error_code="processing_failed")
@@ -776,10 +849,15 @@ def _product_library_refs(team, product_id: uuid.UUID) -> tuple[str, list, str]:
assets.append(asset)
if len(assets) >= image_budget:
break
if not assets and product.cover_asset_id and not product.cover_asset.is_deleted:
if not assets and product.cover_asset_id and product.cover_asset_id not in seen and not product.cover_asset.is_deleted:
assets.append(product.cover_asset)
if not assets and triview is None:
raise ValueError("这个商品还没有可用图片")
if not assets:
# 只有三视图、没有任何实拍图时,把三视图顶上来当 @目标商品。
# 否则提示词里的 @目标商品 找不到同名 label,火山拿到的是一句没有指代的字面量。
if triview is None:
raise ValueError("这个商品还没有可用图片")
assets = [triview]
triview = None
refs = [_library_image_ref(asset, team=team, label="目标商品" if index == 0 else f"目标商品{index + 1}") for index, asset in enumerate(assets)]
if triview is not None:
# 放最后:@目标商品 仍指向第一张实拍图,三视图作为「各面长什么样」的补充证据。
+44 -7
View File
@@ -728,7 +728,7 @@ class VideoDigestView(APIView):
if upload is None and not reuse_task_id:
return Response({"detail": "请先上传参考视频"}, status=status.HTTP_400_BAD_REQUEST)
require_worker_task("apps.ai.tasks.run_video_digest_task")
from .video_digest import VideoDigestError, submit_team_digest
from .video_digest import VideoDigestError, VideoDigestInProgress, submit_team_digest
team = get_current_team(request.user)
try:
@@ -739,6 +739,9 @@ class VideoDigestView(APIView):
reuse_task_id=reuse_task_id,
model_config_id=request.data.get("model_config_id") or None,
)
except VideoDigestInProgress as exc:
# 409 + 在跑的那条:前端据此直接切到进行中状态,而不是弹个错就完了
return Response({"detail": str(exc), "inflight": exc.job}, status=status.HTTP_409_CONFLICT)
except VideoDigestError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc: # noqa: BLE001 — 模型/网络失败:走统一安全文案
@@ -866,11 +869,21 @@ class VideoReplaceView(APIView):
# 商品复刻的拆解在 worker 里跑;worker 还是旧镜像的话消息会被静默丢弃 → 永久「提炼中」。
if str((request.data or {}).get("replace_mode") or "") == "product":
require_worker_task("apps.ai.tasks.run_video_replace_digest_task")
from .video_replace import serialize_video_replace_task, submit_video_replace
from .video_replace import (
VideoReplaceInProgress,
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 VideoReplaceInProgress as exc:
# 409 + 在跑的那条:前端据此直接切到进行中状态,而不是弹个错就完了
return Response(
{"detail": str(exc), "inflight": serialize_video_replace_task(exc.task)},
status=status.HTTP_409_CONFLICT,
)
except ValueError as exc:
message = str(exc)
internal_kind = (
@@ -906,11 +919,16 @@ class VideoReplaceView(APIView):
qs = _free_video_list_queryset(team, include_replace=True).order_by("-created_at")
total = qs.count()
tasks = list(qs[offset : offset + page_size])
from .video_replace import get_inflight_video_replace
running = get_inflight_video_replace(team) if offset == 0 else None
return Response(
{
"results": [serialize_video_replace_task(t) for t in tasks],
"total": total,
"has_more": offset + page_size < total,
# 首页才带:前端刷新后据此恢复「进行中」,不必自己在列表里翻状态
"inflight": serialize_video_replace_task(running) if running is not None else None,
}
)
@@ -1059,6 +1077,13 @@ _FREE_REF_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
_FREE_REF_AUDIO_TYPES = {"audio/mpeg", "audio/wav", "audio/x-wav", "audio/wave"}
_FREE_REF_IMAGE_MAX = 30 * 1024 * 1024
_FREE_REF_VIDEO_MAX = 50 * 1024 * 1024
# 视频复刻·商品:参考视频只喂给提炼模型(不发火山),没有 15 秒的硬限制,
# 按「提炼提示词」页那套放宽到 60 秒 / 200MB。自由创作和角色复刻仍走上面的 15 秒。
# 拿这个 purpose 传上来的长视频,若被拿去自由创作/角色复刻,下游 build_content_items
# 与 submit_video_replace 各自还有 15 秒校验兜底,越不过去。
_REPLACE_SOURCE_PURPOSE = "video_replace_product"
_REPLACE_SOURCE_VIDEO_MAX = 200 * 1024 * 1024
_REPLACE_SOURCE_DURATION_MAX = 60.5
_FREE_REF_AUDIO_MAX = 15 * 1024 * 1024
@@ -1088,6 +1113,7 @@ class FreeVideoUploadView(APIView):
team = get_current_team(request.user)
content_type = (upload.content_type or "").lower()
size = upload.size or 0
long_source = str(request.data.get("purpose") or "").strip() == _REPLACE_SOURCE_PURPOSE
if content_type in _FREE_REF_IMAGE_TYPES:
kind, asset_type, suffix = "image", Asset.Type.IMAGE, {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[content_type]
@@ -1095,8 +1121,12 @@ class FreeVideoUploadView(APIView):
return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST)
elif content_type in _FREE_REF_VIDEO_TYPES:
kind, asset_type, suffix = "video", Asset.Type.VIDEO, ".mp4" if content_type == "video/mp4" else ".mov"
if size > _FREE_REF_VIDEO_MAX:
return Response({"detail": "视频大小不能超过 50MB"}, status=status.HTTP_400_BAD_REQUEST)
video_max = _REPLACE_SOURCE_VIDEO_MAX if long_source else _FREE_REF_VIDEO_MAX
if size > video_max:
return Response(
{"detail": f"视频大小不能超过 {video_max // 1024 // 1024}MB"},
status=status.HTTP_400_BAD_REQUEST,
)
elif content_type in _FREE_REF_AUDIO_TYPES:
kind, asset_type, suffix = "audio", Asset.Type.AUDIO, ".mp3" if content_type == "audio/mpeg" else ".wav"
if size > _FREE_REF_AUDIO_MAX:
@@ -1132,9 +1162,16 @@ class FreeVideoUploadView(APIView):
duration = probe_duration(str(tmp_path))
if duration is None:
return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
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)
from .media_probe import REF_DURATION_MIN
if long_source and kind == "video":
in_range = REF_DURATION_MIN <= duration <= _REPLACE_SOURCE_DURATION_MAX
range_hint = "视频时长需在 2-60 秒之间"
else:
in_range = duration_in_ref_range(duration)
range_hint = f"{'视频' if kind == 'video' else '音频'}时长需在 2-15 秒之间"
if not in_range:
return Response({"detail": range_hint}, status=status.HTTP_400_BAD_REQUEST)
if kind == "video":
poster_bytes = extract_video_poster(str(tmp_path))