测试视频复刻
This commit is contained in:
@@ -11,3 +11,12 @@ DATABASES = {
|
||||
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
|
||||
# 测试必须完全离线:base.py 的 Redis cache 会让 billing 等普通业务逻辑在
|
||||
# 单测中意外连到实际环境。测试用进程内缓存即可,既不依赖网络,也不会碰线上数据。
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "airshelf-test-cache",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ VOLCANO_MODELS = [
|
||||
},
|
||||
},
|
||||
{
|
||||
# 视频复刻(商品 / 角色)固定用这一档:只有它支持 30 秒单次出片。
|
||||
# 视频复刻固定用这一档(单次最长 30 秒)。原片只提炼分镜,不传给火山。
|
||||
# ⚠️ pricing 暂用 Seedance-2.0 标准档的数字占位 —— 火山 2.5 的官方价目待核对。
|
||||
# 偏低只会让平台少收,不会多扣用户;核准后改这里(或直接在运营后台改 metadata.pricing)。
|
||||
"display_name": "Seedance-2.5",
|
||||
|
||||
@@ -44,7 +44,7 @@ FREE_VIDEO_MODELS = {
|
||||
"doubao-seedance-2-0-mini-260615",
|
||||
}
|
||||
HIGH_RES_MODEL = "doubao-seedance-2-0-260128" # 1080p/4k 仅标准档(火山限制)
|
||||
# 视频复刻固定用这一档:只有它支持 30 秒单次出片
|
||||
# 视频复刻固定 Seedance 2.5(单次最长 30 秒)。原片只用来提炼分镜,不传给火山。
|
||||
REPLACE_MODEL = "doubao-seedance-2-5-260628"
|
||||
# 出片时长的兜底上限。真实上限按 ModelConfig.metadata.durations 取(见 model_duration_range),
|
||||
# 元数据缺失时回落这里 —— 别写死 15,否则新模型上来就被老常量卡住。
|
||||
@@ -350,7 +350,7 @@ def build_content_items(
|
||||
if audio_n > 0 and image_n + video_n == 0:
|
||||
raise ValueError("音频不能单独作为参考素材,请同时提供参考图片或视频")
|
||||
if video_duration_total > max_ref_seconds:
|
||||
# 上限跟着模型走:自由创作是 Seedance 2.0(15 秒),视频复刻走 2.5(30 秒)。
|
||||
# 上限跟着模型走:2.0 是 15 秒,2.5 是 30 秒。
|
||||
raise ValueError(f"参考视频总时长不能超过 {int(max_ref_seconds)} 秒,请缩短后重试")
|
||||
|
||||
# @label 替换:按 label 长度降序,防子串吞噬
|
||||
@@ -619,7 +619,10 @@ def start_pending_free_video(task: AITask) -> AITask:
|
||||
seed = int(payload.get("seed") if payload.get("seed") is not None else -1)
|
||||
except (TypeError, ValueError):
|
||||
seed = -1
|
||||
references = payload.get("references") or []
|
||||
references = list(payload.get("references") or [])
|
||||
if feature == "video_replace":
|
||||
# 复刻:用户原片只用来提炼,绝不能进 Seedance,否则真人素材直接被火山拒。
|
||||
references = [item for item in references if (item or {}).get("type") != "video"]
|
||||
try:
|
||||
built = build_content_items(
|
||||
team=task.team, prompt=prompt, mode=mode, references=references,
|
||||
|
||||
@@ -11,7 +11,7 @@ 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.free_video import REPLACE_MODEL, 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 (
|
||||
@@ -20,8 +20,8 @@ from apps.ai.video_replace import (
|
||||
REVIEW_FAILED,
|
||||
REVIEW_UNAVAILABLE,
|
||||
advance_video_replace,
|
||||
build_shot_plan,
|
||||
compact_digest_for_video,
|
||||
build_character_replace_prompt,
|
||||
build_product_replace_prompt,
|
||||
run_replace_digest,
|
||||
submit_video_replace,
|
||||
)
|
||||
@@ -175,7 +175,7 @@ class SubmitVideoReplaceTests(TestCase):
|
||||
return submit_video_replace(team=self.team, user=self.user, params=params)
|
||||
|
||||
def test_product_submit_waits_for_digest_and_keeps_video_out_of_review_refs(self):
|
||||
"""商品复刻第一步:秒回 CREATED。审核列表只有商品图;原片快照另存,出片时再带上。"""
|
||||
"""商品复刻第一步:秒回 CREATED。审核列表只有商品图;原片快照另存,不传 Seedance。"""
|
||||
task = self._submit()
|
||||
self.assertEqual(task.status, AITask.Status.CREATED)
|
||||
payload = task.request_payload
|
||||
@@ -193,7 +193,7 @@ class SubmitVideoReplaceTests(TestCase):
|
||||
self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0)
|
||||
|
||||
def test_product_digest_becomes_prompt_and_video_goes_to_ark(self):
|
||||
"""第二步:worker 拆完 → 按镜拆成生成计划 → 原片 + 商品图一起提交第一镜。"""
|
||||
"""第二步:worker 拆完 → 整份分镜稿一次交给 Seedance 2.5。原片不传火山。"""
|
||||
task = self._submit()
|
||||
with patch(
|
||||
"apps.ai.video_digest.digest_asset_video",
|
||||
@@ -205,28 +205,25 @@ class SubmitVideoReplaceTests(TestCase):
|
||||
payload = task.request_payload
|
||||
self.assertFalse(payload["digest_pending"])
|
||||
self.assertEqual(payload["digest_shots"], 2)
|
||||
plan = payload["shot_plan"]
|
||||
self.assertEqual(len(plan), 2)
|
||||
self.assertEqual(plan[0]["seconds"], 4)
|
||||
self.assertEqual(plan[1]["seconds"], 4)
|
||||
self.assertFalse(payload.get("shot_plan"))
|
||||
prompt = payload["prompt"]
|
||||
self.assertIn("@参考视频", prompt) # 2.0 锁切镜的锚点
|
||||
self.assertIn("第 1/", prompt) # 只喂当前这一镜
|
||||
self.assertIn("倒进玻璃杯", prompt) # 提炼原文画面保留
|
||||
self.assertIn("台词/旁白", prompt) # 不改写成「口播」
|
||||
self.assertIn("【镜头 01】", prompt)
|
||||
self.assertIn("【镜头 02】", prompt)
|
||||
self.assertIn("倒进玻璃杯", prompt)
|
||||
self.assertIn("转动瓶身", prompt)
|
||||
self.assertIn("台词/旁白", prompt)
|
||||
self.assertIn("@目标商品", prompt)
|
||||
self.assertIn("净颜精华", prompt)
|
||||
self.assertNotIn("【拆解存疑】", prompt)
|
||||
self.assertNotIn("字幕:", prompt)
|
||||
self.assertNotIn("转动瓶身", prompt) # 第二镜的动作不能漏进第一镜
|
||||
self.assertNotIn("@参考视频", prompt)
|
||||
self.assertEqual(self.provider.create_video_task.call_count, 1)
|
||||
self.assertEqual(self.provider.create_video_task.call_args.kwargs.get("duration"), 4)
|
||||
self.assertEqual(self.provider.create_video_task.call_args.kwargs.get("duration"), 8)
|
||||
|
||||
content = self.provider.create_video_task.call_args.kwargs.get("content_items") or []
|
||||
roles = [item.get("role") for item in content]
|
||||
self.assertIn("reference_image", roles)
|
||||
self.assertIn("reference_video", roles)
|
||||
self.assertIn("video_url", [item.get("type") for item in content])
|
||||
self.assertNotIn("reference_video", roles)
|
||||
self.assertNotIn("video_url", [item.get("type") for item in content])
|
||||
|
||||
def _retire(self, task):
|
||||
"""把任务收成终态,好让单飞闸放行下一次提交。"""
|
||||
@@ -262,7 +259,7 @@ class SubmitVideoReplaceTests(TestCase):
|
||||
run_replace_digest(task)
|
||||
task.refresh_from_db()
|
||||
self.assertEqual(task.status, AITask.Status.SUBMITTED)
|
||||
self.assertIn("第 1/", task.request_payload["prompt"])
|
||||
self.assertIn("【镜头 01】", task.request_payload["prompt"])
|
||||
self.assertIn("@目标商品", task.request_payload["prompt"])
|
||||
|
||||
def test_replace_digest_prompt_matches_standalone(self):
|
||||
@@ -512,100 +509,71 @@ class SubmitVideoReplaceTests(TestCase):
|
||||
self.assertEqual(len(refs), MAX_IMAGES)
|
||||
self.assertEqual(refs[-1]["label"], "目标商品三视图")
|
||||
|
||||
def test_digest_becomes_a_shot_table_for_the_video_model(self):
|
||||
"""分镜稿是给人校对的导演稿。喂给视频模型前要转成只讲「镜头怎么拍、人说什么」
|
||||
的镜头表 —— 元信息和音轨描述留着只会稀释真正的画面指令。"""
|
||||
table = compact_digest_for_video(DIGEST_SAMPLE)
|
||||
# 镜头结构和画面指令留下
|
||||
self.assertIn("镜头 01 | 00:00-00:04", table)
|
||||
self.assertIn("镜头 02 | 00:04-00:08", table)
|
||||
self.assertIn("画面:女生站在窗边", table)
|
||||
self.assertIn("口播:每天早上我都要来一支旧牌精华。", table)
|
||||
self.assertIn("镜头数:2 镜", table)
|
||||
# 整片设定只留对画面有用的
|
||||
self.assertIn("整体风格:写实、暖色调、清晨感", table)
|
||||
self.assertNotIn("片名", table)
|
||||
self.assertNotIn("视频类型", table)
|
||||
self.assertNotIn("结构:", table)
|
||||
# 非画面指令全部剔除
|
||||
for noise in ("字幕:", "音效:", "背景音乐:", "备注:", "【拆解存疑】"):
|
||||
self.assertNotIn(noise, table)
|
||||
# 空值行不占篇幅("表情:不可见" 这种)
|
||||
self.assertNotIn("不可见", table)
|
||||
def test_digest_keeps_remix_page_shot_skeleton(self):
|
||||
"""喂给 Seedance 的稿要和「提炼提示词」页一样:【镜头 01】骨架原文照传。"""
|
||||
from apps.ai.video_replace import _digest_for_seedance
|
||||
|
||||
out = _digest_for_seedance(DIGEST_SAMPLE)
|
||||
self.assertIn("【镜头 01】", out)
|
||||
self.assertIn("【镜头 02】", out)
|
||||
self.assertIn("台词/旁白:每天早上我都要来一支旧牌精华。", out)
|
||||
self.assertIn("字幕:每天早上来一支", out)
|
||||
self.assertNotIn("【拆解存疑】", out)
|
||||
|
||||
def test_unparseable_digest_falls_back_to_raw_text(self):
|
||||
"""模型没按 skill 格式吐时,宁可原文照传也不能把镜头信息弄丢。"""
|
||||
from apps.ai.video_replace import _digest_for_seedance
|
||||
|
||||
messy = "这条片子讲了一个女生的早晨。\n她拿起瓶子。\n【拆解存疑】\n- 看不清"
|
||||
out = compact_digest_for_video(messy)
|
||||
out = _digest_for_seedance(messy)
|
||||
self.assertIn("这条片子讲了一个女生的早晨。", out)
|
||||
self.assertNotIn("【拆解存疑】", out)
|
||||
|
||||
def test_shot_plan_keeps_one_seedance_job_per_shot(self):
|
||||
plan = build_shot_plan(DIGEST_SAMPLE, "净颜精华", output_seconds=8)
|
||||
self.assertEqual([item["seconds"] for item in plan], [4, 4])
|
||||
self.assertIn("第 1/", plan[0]["prompt"])
|
||||
self.assertIn("@参考视频", plan[0]["prompt"])
|
||||
self.assertIn("倒进玻璃杯", plan[0]["prompt"])
|
||||
self.assertIn("台词/旁白", plan[0]["prompt"])
|
||||
self.assertNotIn("转动瓶身", plan[0]["prompt"])
|
||||
self.assertIn("第 2/", plan[1]["prompt"])
|
||||
self.assertIn("@目标商品", plan[0]["prompt"])
|
||||
self.assertNotIn("@目标角色", plan[0]["prompt"])
|
||||
def test_product_prompt_swaps_product_not_person(self):
|
||||
prompt = build_product_replace_prompt(DIGEST_SAMPLE, "净颜精华")
|
||||
self.assertIn("@目标商品", prompt)
|
||||
self.assertIn("【镜头 01】", prompt)
|
||||
self.assertIn("倒进玻璃杯", prompt)
|
||||
self.assertNotIn("@目标角色", prompt)
|
||||
self.assertNotIn("@参考视频", prompt)
|
||||
|
||||
def test_character_shot_plan_swaps_person_not_product(self):
|
||||
plan = build_shot_plan(DIGEST_SAMPLE, "薇薇", replace_mode="character", output_seconds=8)
|
||||
self.assertIn("@目标角色", plan[0]["prompt"])
|
||||
self.assertIn("@参考视频", plan[0]["prompt"])
|
||||
self.assertIn("商品", plan[0]["prompt"])
|
||||
self.assertNotIn("@目标商品", plan[0]["prompt"])
|
||||
self.assertNotIn("净颜精华", plan[0]["prompt"])
|
||||
def test_character_prompt_swaps_person_not_product(self):
|
||||
prompt = build_character_replace_prompt(DIGEST_SAMPLE)
|
||||
self.assertIn("@目标角色", prompt)
|
||||
self.assertIn("【镜头 01】", prompt)
|
||||
self.assertIn("商品", prompt)
|
||||
self.assertNotIn("@目标商品", prompt)
|
||||
self.assertNotIn("@参考视频", prompt)
|
||||
|
||||
def test_adjacent_sub_four_second_shots_merge(self):
|
||||
digest = """【镜头 01】
|
||||
时长:2秒
|
||||
画面:第一下。
|
||||
【镜头 02】
|
||||
时长:2秒
|
||||
画面:第二下。
|
||||
"""
|
||||
plan = build_shot_plan(digest, "精华", output_seconds=8)
|
||||
self.assertEqual(len(plan), 1)
|
||||
self.assertEqual(plan[0]["seconds"], 4)
|
||||
self.assertIn("切点", plan[0]["prompt"])
|
||||
self.assertIn("第一下", plan[0]["prompt"])
|
||||
self.assertIn("第二下", plan[0]["prompt"])
|
||||
|
||||
def test_shots_are_generated_one_by_one_then_concatenated(self):
|
||||
"""2.5 不能一次塞完整镜头表。第一镜出完再提第二镜,全部完成才拼接。"""
|
||||
self.provider.create_video_task.side_effect = [
|
||||
_ark_create_response("ark-1"),
|
||||
_ark_create_response("ark-2"),
|
||||
]
|
||||
self.provider.poll_video_task.side_effect = [
|
||||
{"status": "succeeded", "usage": {"total_tokens": 1000}},
|
||||
{"status": "succeeded", "usage": {"total_tokens": 1200}},
|
||||
]
|
||||
self.provider.extract_first_media_url.side_effect = ["http://ark/s1.mp4", "http://ark/s2.mp4"]
|
||||
def test_digest_generates_once_without_source_video(self):
|
||||
"""拆完只提交一次 Seedance,不把用户原片传进去。"""
|
||||
task = self._run_digest(self._submit())
|
||||
self.assertEqual(task.provider_task_id, "ark-1")
|
||||
self.assertEqual(self.provider.create_video_task.call_count, 1)
|
||||
self.assertEqual(self.provider.create_video_task.call_args.kwargs.get("duration"), 8)
|
||||
content = self.provider.create_video_task.call_args.kwargs.get("content_items") or []
|
||||
self.assertNotIn("video_url", [item.get("type") for item in content])
|
||||
self.assertIn("【镜头 01】", task.request_payload["prompt"])
|
||||
self.assertIn("【镜头 02】", task.request_payload["prompt"])
|
||||
self.assertFalse(task.request_payload.get("shot_plan"))
|
||||
|
||||
with patch("apps.ai.video_replace._concat_shot_media", return_value=b"concat-bytes") as concat, patch(
|
||||
"apps.ai.free_video._store_free_video_media"
|
||||
) as store:
|
||||
task = advance_video_replace(task)
|
||||
self.assertEqual(task.provider_task_id, "ark-2")
|
||||
self.assertEqual(self.provider.create_video_task.call_count, 2)
|
||||
self.assertEqual(task.request_payload["shot_plan"][0]["status"], "succeeded")
|
||||
self.assertFalse(store.called)
|
||||
|
||||
task = advance_video_replace(task)
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
concat.assert_called_once()
|
||||
self.assertEqual(concat.call_args[0][0], ["http://ark/s1.mp4", "http://ark/s2.mp4"])
|
||||
store.assert_called_once()
|
||||
self.assertEqual(store.call_args.kwargs.get("video_bytes"), b"concat-bytes")
|
||||
self.assertEqual(task.request_payload.get("actual_tokens"), 2200)
|
||||
def test_leftover_source_video_is_stripped_before_seedance(self):
|
||||
"""旧任务 payload 里还躺着原片 / shot_plan 时,出片入口也必须丢掉,不能再喂火山。"""
|
||||
task = self._submit()
|
||||
payload = dict(task.request_payload)
|
||||
payload["references"] = [
|
||||
payload["digest_source_ref"],
|
||||
*(payload.get("references") or []),
|
||||
]
|
||||
payload["shot_plan"] = [{"index": 1, "seconds": 7, "prompt": "使用@参考视频", "status": "queued"}]
|
||||
payload["shot_total"] = 5
|
||||
task.request_payload = payload
|
||||
task.save(update_fields=["request_payload", "updated_at"])
|
||||
task = self._run_digest(task)
|
||||
self.assertEqual(self.provider.create_video_task.call_count, 1)
|
||||
content = self.provider.create_video_task.call_args.kwargs.get("content_items") or []
|
||||
self.assertNotIn("video_url", [item.get("type") for item in content])
|
||||
self.assertFalse(task.request_payload.get("shot_plan"))
|
||||
self.assertNotIn("@参考视频", task.request_payload["prompt"])
|
||||
|
||||
def test_character_library_and_temp_are_exclusive(self):
|
||||
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
|
||||
@@ -620,61 +588,67 @@ class SubmitVideoReplaceTests(TestCase):
|
||||
self.assertNotIn("参考视频", labels)
|
||||
self.assertIn("目标角色", labels)
|
||||
|
||||
def test_both_modes_accept_up_to_30s(self):
|
||||
"""商品和角色都走 Seedance 2.5(单次能出 30 秒),参考视频统一按 30 秒收口。"""
|
||||
def test_both_modes_use_seedance_25_and_cap_at_30s(self):
|
||||
"""商品和角色都走 Seedance 2.5,参考视频按 30 秒收口。"""
|
||||
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)
|
||||
too_long = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="超长.mp4", duration_ms=45000)
|
||||
mid = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="三十秒.mp4", duration_ms=30000)
|
||||
too_long = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="超长.mp4", duration_ms=32000)
|
||||
|
||||
# 单飞闸只允许一条在跑,所以每次提交后收掉再试下一条
|
||||
product_task = self._retire(self._submit(video_asset_id=str(mid.id)))
|
||||
self.assertEqual(product_task.status, AITask.Status.CANCELLED)
|
||||
self.assertEqual(product_task.request_payload["model"], "doubao-seedance-2-5-260628")
|
||||
self.assertEqual(product_task.request_payload["model"], REPLACE_MODEL)
|
||||
|
||||
character_task = self._retire(self._submit(
|
||||
replace_mode="character", product_id="", model_id=str(model.id),
|
||||
video_asset_id=str(mid.id),
|
||||
))
|
||||
self.assertEqual(character_task.request_payload["model"], "doubao-seedance-2-5-260628")
|
||||
self.assertEqual(character_task.request_payload["model"], REPLACE_MODEL)
|
||||
|
||||
for mode, extra in (("product", {}), ("character", {"product_id": "", "model_id": str(model.id)})):
|
||||
with self.assertRaisesMessage(ValueError, "不能超过 30 秒"):
|
||||
self._submit(replace_mode=mode, video_asset_id=str(too_long.id), **extra)
|
||||
|
||||
def test_long_source_drops_trailing_shots(self):
|
||||
"""参考片比成片长很多时整镜省略尾镜,不要压缩画面。"""
|
||||
long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="半分钟.mp4", duration_ms=28000)
|
||||
def test_long_source_adds_condense_note(self):
|
||||
"""参考片比成片长很多时只加一句取舍,整份分镜稿仍一次出。"""
|
||||
long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="十五秒.mp4", duration_ms=15000)
|
||||
task = self._run_digest(self._submit(video_asset_id=str(long_video.id), duration=10), digest=DIGEST_FOUR)
|
||||
plan = task.request_payload["shot_plan"]
|
||||
self.assertEqual(len(plan), 2)
|
||||
self.assertIn("第一镜开场", plan[0]["prompt"])
|
||||
self.assertIn("第二镜展示", plan[1]["prompt"])
|
||||
self.assertNotIn("第四镜收束", plan[0]["prompt"] + plan[1]["prompt"])
|
||||
self.assertFalse(task.request_payload.get("shot_plan"))
|
||||
prompt = task.request_payload["prompt"]
|
||||
self.assertIn("【镜头 01】", prompt)
|
||||
self.assertIn("第四镜收束", prompt)
|
||||
self.assertIn("省略次要镜头", prompt)
|
||||
self.assertEqual(self.provider.create_video_task.call_count, 1)
|
||||
self.assertEqual(self.provider.create_video_task.call_args.kwargs.get("duration"), 10)
|
||||
|
||||
def test_short_source_keeps_all_shots(self):
|
||||
"""时长一致时保留全部镜头。"""
|
||||
"""时长一致时保留全部镜头,不加取舍句。"""
|
||||
task = self._run_digest(self._submit(duration=None)) # setUp 的参考视频 8 秒 → 成片也 8 秒
|
||||
self.assertEqual(len(task.request_payload["shot_plan"]), 2)
|
||||
self.assertNotIn("时长不一致时的取舍", task.request_payload["prompt"])
|
||||
self.assertNotIn("省略次要镜头", task.request_payload["prompt"])
|
||||
self.assertFalse(task.request_payload.get("shot_plan"))
|
||||
prompt = task.request_payload["prompt"]
|
||||
self.assertIn("【镜头 01】", prompt)
|
||||
self.assertIn("【镜头 02】", prompt)
|
||||
self.assertNotIn("省略次要镜头", prompt)
|
||||
|
||||
def test_prompt_copies_source_video_instead_of_rewriting_a_script(self):
|
||||
"""切镜靠 @参考视频,提示词只钉「换商品」+ 这一镜原文,不要再扩写成导演长文。"""
|
||||
def test_prompt_keeps_digest_page_format(self):
|
||||
"""分镜稿格式和提炼页一样,只加换商品。不传原片、不改写成导演长文。"""
|
||||
prompt = self._run_digest(self._submit()).request_payload["prompt"]
|
||||
self.assertIn("@参考视频", prompt)
|
||||
self.assertIn("人物", prompt)
|
||||
self.assertIn("【镜头 01】", prompt)
|
||||
self.assertIn("时间:00:00-00:04", prompt)
|
||||
self.assertIn("台词/旁白", prompt)
|
||||
self.assertNotIn("@参考视频", prompt)
|
||||
self.assertNotIn("必须原样保持", prompt)
|
||||
self.assertNotIn("不要从商品图凭空套脸", prompt)
|
||||
|
||||
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
|
||||
def test_accepts_30s_video_with_probe_slack(self):
|
||||
from apps.ai.video_replace import REPLACE_REF_DURATION_MAX, _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)
|
||||
# Finder 显示 30 秒的成片常被探测成 30.04s,不能被硬阈值 30 误拒
|
||||
edge = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="刚好30.mp4", duration_ms=30040)
|
||||
self.assertLessEqual(_asset_duration_seconds(edge), REPLACE_REF_DURATION_MAX)
|
||||
task = self._retire(self._submit(video_asset_id=str(edge.id)))
|
||||
self.assertEqual(task.request_payload["model"], REPLACE_MODEL)
|
||||
|
||||
def test_rejects_foreign_product(self):
|
||||
other = User.objects.create_user(username="vr-other", password="p")
|
||||
@@ -807,7 +781,7 @@ class VideoReplaceApiTests(TestCase):
|
||||
|
||||
|
||||
class VideoReplaceReviewGateTests(TestCase):
|
||||
"""审核闸只盯人物图/商品图。原片出片时直传火山锁切镜,不进人物素材库送审。"""
|
||||
"""审核闸只盯人物图/商品图。用户原片只用来提炼,不传 Seedance。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="vrrev", password="p")
|
||||
@@ -891,7 +865,7 @@ class VideoReplaceReviewGateTests(TestCase):
|
||||
self.assertTrue(self.provider.create_video_task.called)
|
||||
content = self.provider.create_video_task.call_args.kwargs.get("content_items") or []
|
||||
types = [item.get("type") for item in content]
|
||||
self.assertIn("video_url", types)
|
||||
self.assertNotIn("video_url", types)
|
||||
image_urls = [
|
||||
(item.get("image_url") or {}).get("url")
|
||||
for item in content if item.get("type") == "image_url"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""视频复刻:参考视频 + 商品图/人物图 → Seedance 2.5 换商品或换角色。
|
||||
"""视频复刻:参考视频提炼分镜稿 → Seedance 2.5 按稿出片。
|
||||
|
||||
2.0 效果好是因为把 @参考视频 直接喂给火山锁切镜。2.5 单次能出 30 秒,模型换成
|
||||
2.5 即可;不要改写成一大段分镜散文再文生视频,否则切镜对不上、描写过头。
|
||||
流程很短:
|
||||
1. 用户上传的参考视频只走「提炼提示词」同一套接口,拆成中文分镜稿;
|
||||
2. 把这份稿(格式与提炼页一模一样)加上换商品/换角色,交给 Seedance 2.5;
|
||||
3. 只带商品图/角色图。用户原片不传火山,避免素材审核报错。
|
||||
|
||||
流程:「提炼提示词」同一套接口拆出每镜时间轴 → 一镜一次 Seedance(仍带上原片)
|
||||
→ 按原片顺序拼接。商品图/人物图过审后用 asset://;审核失败不扣费。
|
||||
商品图/人物图过审后用 asset://;审核失败不扣费。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -78,19 +79,15 @@ def get_inflight_video_replace(team):
|
||||
.first()
|
||||
)
|
||||
|
||||
# 2.0 就是这四句话 + @参考视频。不要再扩写成「镜头表 / 必须原样保持」长文,
|
||||
# 2.5 会把多出来的描写当成新画面去演。
|
||||
PRODUCT_PROMPT = (
|
||||
"使用@参考视频作为镜头、节奏与口播氛围基准,"
|
||||
"将画面中需要替换的原商品完整替换为@目标商品的外观。"
|
||||
"保留参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围,"
|
||||
"商品外观、材质、包装必须与参考图一致,不要改变原片构图和人物表演。"
|
||||
"按下面这份分镜稿生成视频。镜头数量、每镜时长、景别、机位、运镜、人物、场景、口播全部按稿执行,"
|
||||
"不要增删镜头、不要改顺序。"
|
||||
"把稿里的原商品换成@目标商品,外观、材质、包装以参考图为准。"
|
||||
)
|
||||
CHARACTER_PROMPT = (
|
||||
"使用@参考视频作为镜头、节奏与口播氛围基准,"
|
||||
"将画面中需要替换的原人物完整替换为@目标角色。"
|
||||
"保留参考视频的商品、场景、镜头运动、剪辑节奏与口播氛围,"
|
||||
"角色五官、发型、体态必须与参考图一致,不要改变原片构图和商品展示。"
|
||||
"按下面这份分镜稿生成视频。镜头数量、每镜时长、景别、机位、运镜、商品、场景、口播全部按稿执行,"
|
||||
"不要增删镜头、不要改顺序。"
|
||||
"把稿里的原人物换成@目标角色,五官、发型、体态以参考图为准。"
|
||||
)
|
||||
|
||||
# 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。
|
||||
@@ -175,10 +172,9 @@ def _render_shot_table(header: dict, shots: list[dict]) -> str:
|
||||
|
||||
|
||||
# —— 逐镜生成 ——
|
||||
# 2.5 一次塞整条 30 秒会自己切镜。拆成一镜一次,每段只还原原片对应时间,
|
||||
# 并且把 @参考视频 一起带上(2.0 锁切镜的那一招),再按顺序拼起来。
|
||||
# 一镜一次 Seedance,每段只还原原片对应时间,并把 @参考视频 带上锁切镜。
|
||||
MIN_SHOT_SECONDS = 4 # 火山单次出片下限
|
||||
MAX_CLIP_SECONDS = 30 # Seedance 2.5 单次上限;单镜再长也一次出,避免凭空切一刀
|
||||
MAX_CLIP_SECONDS = 15 # Seedance 2.0 单次上限;单镜再长也一次出,避免凭空切一刀
|
||||
MAX_SHOTS = 12 # 再多就是积分黑洞,也超出用户预期
|
||||
_CLOCK_RE = re.compile(r"(\d{1,2}):(\d{2})")
|
||||
|
||||
@@ -339,17 +335,24 @@ def build_shot_prompt(
|
||||
return enforce_no_embedded_captions("\n".join(part for part in parts if part))
|
||||
|
||||
|
||||
def _digest_for_seedance(digest_text: str) -> str:
|
||||
"""提炼页同款分镜稿。只砍给用户校对的【拆解存疑】,其余原文照传。"""
|
||||
body = (digest_text or "").strip()
|
||||
if not body:
|
||||
raise ValueError("参考视频拆解结果为空,请重试")
|
||||
head, sep, _tail = body.partition(DIGEST_TAIL_SECTION)
|
||||
return (head if sep else body).strip()
|
||||
|
||||
|
||||
def build_character_replace_prompt(
|
||||
digest_text: str,
|
||||
*,
|
||||
source_seconds: float = 0,
|
||||
output_seconds: int = 0,
|
||||
) -> str:
|
||||
"""解析不出逐镜计划时的整片回落:2.0 短指令,切镜靠 @参考视频。"""
|
||||
"""提炼页分镜稿 + 换角色。原片不传 Seedance。"""
|
||||
from .services import enforce_no_embedded_captions
|
||||
|
||||
if not (digest_text or "").strip():
|
||||
raise ValueError("参考视频拆解结果为空,请重试")
|
||||
source = int(round(float(source_seconds or 0)))
|
||||
output = int(output_seconds or 0)
|
||||
condense = (
|
||||
@@ -357,7 +360,9 @@ def build_character_replace_prompt(
|
||||
if source and output and source - output >= 3
|
||||
else ""
|
||||
)
|
||||
return enforce_no_embedded_captions("\n".join(part for part in (CHARACTER_PROMPT, condense) if part))
|
||||
return enforce_no_embedded_captions(
|
||||
"\n".join(part for part in (CHARACTER_PROMPT, _digest_for_seedance(digest_text), condense) if part)
|
||||
)
|
||||
|
||||
|
||||
def compact_digest_for_video(digest_text: str) -> str:
|
||||
@@ -382,11 +387,9 @@ def build_product_replace_prompt(
|
||||
source_seconds: float = 0,
|
||||
output_seconds: int = 0,
|
||||
) -> str:
|
||||
"""解析不出逐镜计划时的整片回落:2.0 短指令,切镜靠 @参考视频。"""
|
||||
"""提炼页分镜稿 + 换商品。原片不传 Seedance。"""
|
||||
from .services import enforce_no_embedded_captions
|
||||
|
||||
if not (digest_text or "").strip():
|
||||
raise ValueError("参考视频拆解结果为空,请重试")
|
||||
subject = (subject_name or "").strip()
|
||||
source = int(round(float(source_seconds or 0)))
|
||||
output = int(output_seconds or 0)
|
||||
@@ -400,7 +403,9 @@ def build_product_replace_prompt(
|
||||
extra.append(PRODUCT_TRIVIEW_NOTE)
|
||||
if subject:
|
||||
extra.append(f"口播里的旧商品名改说「{subject}」。")
|
||||
return enforce_no_embedded_captions("\n".join(part for part in (PRODUCT_PROMPT, *extra, condense) if part))
|
||||
return enforce_no_embedded_captions(
|
||||
"\n".join(part for part in (PRODUCT_PROMPT, *extra, _digest_for_seedance(digest_text), condense) if part)
|
||||
)
|
||||
|
||||
|
||||
def is_video_replace_task(task) -> bool:
|
||||
@@ -508,7 +513,7 @@ def submit_video_replace(*, team, user, params: dict):
|
||||
}
|
||||
base_params = {
|
||||
"mode": "universal",
|
||||
# 商品和角色都固定走 Seedance 2.5:只有它支持 30 秒单次出片,不接受前端指定别的档
|
||||
# 固定 Seedance 2.5,不接受前端指定别的档
|
||||
"model": REPLACE_MODEL,
|
||||
"aspect_ratio": str(params.get("aspect_ratio") or "9:16"),
|
||||
"resolution": str(params.get("resolution") or "720p"),
|
||||
@@ -518,8 +523,7 @@ def submit_video_replace(*, team, user, params: dict):
|
||||
"feature": FEATURE,
|
||||
}
|
||||
|
||||
# 分镜仍用「提炼提示词」同一套拆。参考视频先不进审核列表(原片不是要登记的人物素材),
|
||||
# 出片时再带上 @参考视频 锁切镜 —— 这是 2.0 效果好的原因,2.5 同样需要。
|
||||
# 分镜用「提炼提示词」同一套拆。原片只当拆解源,不进审核列表、也不传火山。
|
||||
from .video_digest import resolve_digest_model_config
|
||||
|
||||
if resolve_digest_model_config() is None:
|
||||
@@ -533,7 +537,7 @@ def submit_video_replace(*, team, user, params: dict):
|
||||
"digest_source_asset_id": str(video.id),
|
||||
"digest_source_name": video.name or "参考视频",
|
||||
"digest_source_duration": round(video_seconds, 2),
|
||||
# 审核只盯商品图/角色图。原片快照留给出片时锁切镜,也给历史卡「原视频」用。
|
||||
# 原片快照只给历史卡「原视频」和重新生成用,不进 Seedance references。
|
||||
"digest_source_ref": _owned_ref(video, kind="video", role="reference_video", label="参考视频"),
|
||||
"review_pending": review_state != "ready",
|
||||
})
|
||||
@@ -551,15 +555,10 @@ def submit_video_replace(*, team, user, params: dict):
|
||||
|
||||
|
||||
def advance_video_replace(task):
|
||||
"""推进复刻:审核中 → 提交;逐镜生成中 → 下一段 / 拼接。"""
|
||||
"""推进复刻:审核中 → 整份分镜稿一次出片。旧逐镜任务也按单次收尾,不再拼接。"""
|
||||
if not is_video_replace_task(task):
|
||||
return task
|
||||
if task.status != AITask.Status.CREATED:
|
||||
payload = task.request_payload or {}
|
||||
if payload.get("shot_plan") and task.status in (
|
||||
AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING,
|
||||
):
|
||||
return advance_replace_shots(task)
|
||||
from .free_video import finalize_free_video
|
||||
|
||||
return finalize_free_video(task=task)
|
||||
@@ -569,7 +568,7 @@ def advance_video_replace(task):
|
||||
# worker 还在拆参考视频,提示词都没生成,别当成「审核中」反复送审。
|
||||
# worker 挂了也不会永远卡着:CREATED 超 16 分钟由 _reap_stale_free_video_tasks 回收退费。
|
||||
return task
|
||||
references = list(payload.get("references") or [])
|
||||
references = _image_refs(payload.get("references") or [])
|
||||
try:
|
||||
state = _ensure_replace_refs_reviewed(task.team, references)
|
||||
except ValueError as exc:
|
||||
@@ -598,21 +597,18 @@ def advance_video_replace(task):
|
||||
|
||||
|
||||
def start_replace_generation(task):
|
||||
"""审核过了:有逐镜计划就一镜一次,否则回落整片一次生成(仍带 @参考视频)。"""
|
||||
payload = task.request_payload or {}
|
||||
if payload.get("shot_plan"):
|
||||
return start_pending_replace_shots(task)
|
||||
refs = _seedance_references(payload)
|
||||
if refs:
|
||||
next_payload = dict(payload)
|
||||
next_payload["references"] = refs
|
||||
task.request_payload = next_payload
|
||||
task.save(update_fields=["request_payload", "updated_at"])
|
||||
"""审核过了:整份分镜稿一次交给 Seedance 2.5。只带商品图/角色图,不传用户原片。"""
|
||||
payload = dict(task.request_payload or {})
|
||||
payload["references"] = _image_refs(payload.get("references") or [])
|
||||
payload.pop("shot_plan", None)
|
||||
payload.pop("shot_total", None)
|
||||
task.request_payload = payload
|
||||
task.save(update_fields=["request_payload", "updated_at"])
|
||||
return start_pending_free_video(task)
|
||||
|
||||
|
||||
def run_replace_digest(task) -> None:
|
||||
"""Worker:参考视频 → 分镜稿 → 逐镜计划 → 走审核提交火山。
|
||||
"""Worker:参考视频 → 提炼页同款分镜稿 → 一次交给 Seedance。不传原片。
|
||||
|
||||
商品和角色共用。这一步失败 = 整条复刻失败。此时还没预留视频积分,不扣费。
|
||||
"""
|
||||
@@ -642,13 +638,7 @@ def run_replace_digest(task) -> None:
|
||||
|
||||
try:
|
||||
digest, meta = digest_asset_video(asset=asset, task=task)
|
||||
plan = build_shot_plan(
|
||||
digest, subject, has_triview=has_triview,
|
||||
replace_mode=replace_mode, output_seconds=output_seconds,
|
||||
)
|
||||
if plan:
|
||||
prompt = plan[0]["prompt"]
|
||||
elif replace_mode == "character":
|
||||
if replace_mode == "character":
|
||||
prompt = build_character_replace_prompt(
|
||||
digest, source_seconds=source_seconds, output_seconds=output_seconds,
|
||||
)
|
||||
@@ -674,21 +664,9 @@ def run_replace_digest(task) -> None:
|
||||
next_payload["digest_pending"] = False
|
||||
next_payload["digest_text"] = digest[:32000]
|
||||
next_payload["prompt"] = prompt
|
||||
if plan:
|
||||
next_payload["shot_plan"] = [
|
||||
{
|
||||
"index": item["index"],
|
||||
"seconds": item["seconds"],
|
||||
"prompt": item["prompt"],
|
||||
"status": "queued",
|
||||
"provider_task_id": "",
|
||||
"media": "",
|
||||
"tokens": 0,
|
||||
}
|
||||
for item in plan
|
||||
]
|
||||
next_payload["shot_total"] = len(plan)
|
||||
next_payload["duration"] = sum(item["seconds"] for item in plan)
|
||||
next_payload["references"] = _image_refs(next_payload.get("references") or [])
|
||||
next_payload.pop("shot_plan", None)
|
||||
next_payload.pop("shot_total", None)
|
||||
locked.request_payload = next_payload
|
||||
locked.save(update_fields=["request_payload", "updated_at"])
|
||||
task = locked
|
||||
@@ -704,6 +682,11 @@ def run_replace_digest(task) -> None:
|
||||
|
||||
|
||||
def start_pending_replace_shots(task):
|
||||
"""旧逐镜路径已下线。一律整片一次出,且不传用户原片。"""
|
||||
return start_replace_generation(task)
|
||||
|
||||
|
||||
def _legacy_start_pending_replace_shots(task):
|
||||
"""CREATED → 按全部镜头总时长预留积分 → 提交第一镜。"""
|
||||
from apps.billing.services.ledger import reserve_credit
|
||||
|
||||
@@ -1545,33 +1528,18 @@ def _owned_ref(asset: Asset, *, kind: str, role: str, label: str) -> dict:
|
||||
return ref
|
||||
|
||||
|
||||
def _seedance_references(payload: dict) -> list:
|
||||
"""出片用的引用:原片 + 已过审的商品图/角色图。
|
||||
|
||||
审核列表里只有图。原片走直链(source=upload),不当三库人物素材再送一遍审。
|
||||
2.0 就是靠这条视频锁切镜,2.5 同样必须带上。
|
||||
"""
|
||||
images = [
|
||||
def _image_refs(references) -> list:
|
||||
"""出片/审核只用图。用户原片哪怕还躺在旧 payload 里,也一律丢掉。"""
|
||||
return [
|
||||
dict(item)
|
||||
for item in (payload.get("references") or [])
|
||||
for item in (references or [])
|
||||
if (item or {}).get("type") != "video"
|
||||
]
|
||||
video = payload.get("digest_source_ref")
|
||||
if not isinstance(video, dict) or not (video.get("url") or video.get("resolved_url")):
|
||||
video = next(
|
||||
(item for item in (payload.get("references") or []) if (item or {}).get("type") == "video"),
|
||||
None,
|
||||
)
|
||||
if not isinstance(video, dict):
|
||||
return images
|
||||
copy_ref = dict(video)
|
||||
copy_ref.update({
|
||||
"type": "video",
|
||||
"role": "reference_video",
|
||||
"label": copy_ref.get("label") or "参考视频",
|
||||
"source": "upload",
|
||||
})
|
||||
return [copy_ref, *images]
|
||||
|
||||
|
||||
def _seedance_references(payload: dict) -> list:
|
||||
"""出片只带商品图/角色图。用户上传的原片不传 Seedance,避免素材审核报错。"""
|
||||
return _image_refs(payload.get("references") or [])
|
||||
|
||||
|
||||
def _library_image_ref(asset: Asset, *, team, label: str) -> dict:
|
||||
|
||||
@@ -1082,10 +1082,7 @@ _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 秒校验兜底,越不过去。
|
||||
# 视频复刻上传:文件可以到 200MB,时长按 Seedance 2.5 的 30 秒收口。原片只用来提炼。
|
||||
_REPLACE_SOURCE_PURPOSE = "video_replace_product"
|
||||
_REPLACE_SOURCE_VIDEO_MAX = 200 * 1024 * 1024
|
||||
_REPLACE_SOURCE_DURATION_MAX = 30.5
|
||||
|
||||
Reference in New Issue
Block a user