优化脚本
This commit is contained in:
@@ -620,8 +620,9 @@ def start_pending_free_video(task: AITask) -> AITask:
|
||||
except (TypeError, ValueError):
|
||||
seed = -1
|
||||
references = list(payload.get("references") or [])
|
||||
if feature == "video_replace":
|
||||
# 复刻:用户原片只用来提炼,绝不能进 Seedance,否则真人素材直接被火山拒。
|
||||
if feature == "video_replace" and payload.get("replace_mode") == "product":
|
||||
# 商品复刻:原片只用来提炼,绝不能进 Seedance,否则真人素材直接被火山拒。
|
||||
# 角色复刻走另一条已入火山素材库的 video-reference 链路,必须保留视频。
|
||||
references = [item for item in references if (item or {}).get("type") != "video"]
|
||||
try:
|
||||
built = build_content_items(
|
||||
|
||||
@@ -415,6 +415,8 @@ def classify_generation_error(
|
||||
"画面描写太短",
|
||||
"按秒拆分镜",
|
||||
"切两次景别",
|
||||
"机位与运镜",
|
||||
"平台指令腔",
|
||||
):
|
||||
return _build_error("processing_failed", operation, reference_id=reference_id)
|
||||
return _build_error("unknown", operation, reference_id=reference_id)
|
||||
|
||||
@@ -494,7 +494,11 @@ _CREATIVE_DIRECTION = """
|
||||
- 痛点镜:拍得到的细节,而非抽象感受。例如「开会前刘海粘成一绺」而不是「头发很油」。
|
||||
- 卖点镜:只证明一个卖点,必须写清「原本卡在哪里 → 手怎么使用商品 → 眼前有什么变化」。
|
||||
不准只报参数、堆形容词或重复商品名。
|
||||
- CTA 镜:回到前面那个具体情境,给一个自然的选择/动作;不要硬喊「赶紧下单」。
|
||||
- CTA 镜:回到前面那个具体情境,说清**谁适合、为什么值得试**,用一句像跟朋友说话的口语收尾。
|
||||
**禁止平台指令腔**:小黄车、购物车、点下方、点击购买、立即购买、马上/赶紧下单、抢购、秒杀、
|
||||
手慢无、别犹豫、买它、闭眼入、上车 —— 这些一出现整条就掉回广告,观众直接划走。
|
||||
合格例子:「你要是也天天下午三点犯困,可以试试这个」「链接放这儿了,先看看合不合适」。
|
||||
紧迫感只能来自商品资料里的真实事实,没有就不制造;画面里也不许出现购买浮层或价格贴片。
|
||||
|
||||
### 画面与台词必须互相提供新信息
|
||||
- 每个 15 秒场只安排**一个连续的核心动作链**,例如「拆开 → 倒入 → 颜色变化」,
|
||||
@@ -517,7 +521,7 @@ _CREATIVE_DIRECTION = """
|
||||
```
|
||||
【本镜任务】这一段要让观众看懂的变化或悬念。
|
||||
【光线氛围】光源方向与性质(窗光/顶光/台灯/逆光);色温冷暖;明暗对比;整体色调。全片各镜保持一致。
|
||||
【声音】台词/旁白状态;音效;背景音乐;字幕。没有就写「无」;没有配乐写「无配乐,仅同期声」。
|
||||
【声音】台词/旁白状态;音效;背景音乐。没有就写「无」;没有配乐写「无配乐,仅同期声」。**不要写字幕这一栏** —— 本系统出的视频一律无字幕。
|
||||
【画面内容】
|
||||
0-3s:景别;机位高度与角度;运镜;主体在画面中的位置;具体动作;情绪或信息变化。
|
||||
3-8s:景别;机位;运镜;手与商品的空间关系(哪只手、握哪里、从哪个方向入画);可见细节。
|
||||
@@ -536,8 +540,9 @@ _CREATIVE_DIRECTION = """
|
||||
- **禁止抽象词**:「展示商品」「呈现质感」「体现高级感」「氛围到位」「传递温暖」——
|
||||
这些不是画面,是评价。要写成「杯壁上的水珠顺着往下滑」「她眉头松开,肩膀塌下来」。
|
||||
- **禁止心理描写**:模型拍不出「她内心很纠结」,要写「她拿起又放下,手指在包装边缘停了两秒」。
|
||||
- **禁止画面里出现文字**:不要写字幕、花字、标题、价格贴片、UI、弹窗、对比图表;
|
||||
商品包装上原有的真实文字除外。
|
||||
- **禁止画面里出现文字**:本系统出的**任何视频都不带字幕**。不要写字幕、花字、标题、片头片尾、
|
||||
水印、角标、价格贴片、优惠标签、弹幕、对话气泡、贴纸、参数表、对比图表、UI、按钮、购物浮层;
|
||||
口播和台词只以声音存在。商品包装上原有的真实文字除外。
|
||||
- **禁止一镜内跨场景/跨时间蒙太奇**:15 秒是**一个连续的动作链**,同一地点、同一光线、
|
||||
同一套衣服。要换环境就换到下一镜,并在 entity_refs 里换成另一个 scene。
|
||||
|
||||
@@ -546,10 +551,20 @@ _CREATIVE_DIRECTION = """
|
||||
包装、光线、地点或色调。每一镜的【光线氛围】要和相邻镜衔接得上(同一场戏不能上一镜暖黄台灯、
|
||||
下一镜冷白日光)。商品的用法要符合物理常识:热饮有蒸汽、液体会流动、包装要先打开才能取出内容物。
|
||||
|
||||
输入如果是 `【镜头 01】` 导演分镜稿,把原稿的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注
|
||||
折进对应栏,不要压成一句画面摘要。
|
||||
输入如果是 `【镜头 01】` 导演分镜稿,把原稿的景别、机位、运镜、动作、表情、音效、背景音乐、备注
|
||||
折进对应栏,不要压成一句画面摘要。**原稿里的「字幕 / 花字」一栏一律丢弃**,绝不搬进新脚本 ——
|
||||
参考视频有字幕不代表我们的成片要有。
|
||||
"""
|
||||
|
||||
# CTA 平台指令腔:一出现整条就从「真人分享」掉回「电视购物」,且换平台就穿帮。
|
||||
# 用户明确要求全系统去掉「小黄车」那套话术,这里做成硬闸,模型跑偏就打回重写。
|
||||
_CTA_BANNED_PHRASES = (
|
||||
"小黄车", "购物车", "点下方", "点击下方", "点下面", "点击购买", "立即购买", "立刻购买",
|
||||
"马上下单", "赶紧下单", "立即下单", "抓紧下单", "抢购", "秒杀", "手慢无", "别犹豫",
|
||||
"买它", "闭眼入", "上车", "冲鸭",
|
||||
)
|
||||
|
||||
|
||||
# 这些句式几乎总会让首屏像模板广告。只作为生成后的最后一道门,不替代模型的创作判断。
|
||||
_GENERIC_HOOK_PHRASES = (
|
||||
"大家好", "今天", "给大家", "给你们", "给姐妹", "分享一下", "推荐一下",
|
||||
@@ -739,7 +754,8 @@ def build_agent_messages(
|
||||
"并写清哪只手、从哪个方向入画、握商品哪里,以及真实用法"
|
||||
"(茶=热水+蒸汽+茶汤变色,禁止茶包丢进冷白开;禁止悬浮肢体、商品凭空出现)。"
|
||||
"只写拍得出来的东西:禁止「展示商品/呈现质感/体现高级感」这类评价词,禁止心理描写,"
|
||||
"禁止画面里出现字幕花字标题价签,禁止一镜内跨场景跨时间。"
|
||||
"禁止画面里出现任何文字叠加(字幕/花字/标题/水印/角标/价签/购物浮层),口播只以声音存在;"
|
||||
"禁止一镜内跨场景跨时间。"
|
||||
f"禁止一句空画面撑满 {SEGMENT_DURATION_MAX} 秒。允许另给 beats 数组,后端会折进 visual。\n"
|
||||
)
|
||||
if fmt == "oral":
|
||||
@@ -817,8 +833,9 @@ def build_agent_messages(
|
||||
"【任务】参考视频改写:用户给了一份逐镜拆解稿。"
|
||||
"照搬它的镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,"
|
||||
"把人物、商品、品牌、台词全部换成当前商品。"
|
||||
"写 visual 时把拆解稿里的景别/机位/运镜/人物动作/表情/音效/背景音乐/字幕/备注"
|
||||
"写 visual 时把拆解稿里的景别/机位/运镜/人物动作/表情/音效/背景音乐/备注"
|
||||
"折进【声音】和【画面内容】的秒级分镜,不要压成一句画面摘要。"
|
||||
"**拆解稿里的「字幕」栏一律丢弃**,不要搬进新脚本 —— 我们的成片不带字幕。"
|
||||
"原稿写「无 / 不可见 / 听不清」的栏不要编造。"
|
||||
"钩子、已选结构的核心步骤和商品事实必须能对上。"
|
||||
"每镜旁白要能撑满指定时长;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n"
|
||||
@@ -1044,6 +1061,24 @@ def assert_script_has_a_hook(draft: dict) -> None:
|
||||
raise ValueError("开场像泛泛推荐,请直接从具体场景、反差或问题切入")
|
||||
|
||||
|
||||
def assert_cta_is_not_shouty(draft: dict) -> None:
|
||||
"""拦住「点下方小黄车」那套平台指令腔:CTA 要回到情境、点名谁适合,不是喊口号。
|
||||
扫全片口播/对白(不只 CTA 镜),因为这类话经常顺手塞进末镜旁白的最后一句。"""
|
||||
for index, seg in enumerate(draft.get("segments") or []):
|
||||
if not isinstance(seg, dict):
|
||||
continue
|
||||
speech = _speech_text(seg)
|
||||
hit = next((phrase for phrase in _CTA_BANNED_PHRASES if phrase in speech), None)
|
||||
if hit:
|
||||
raise ValueError(
|
||||
f"第 {index + 1} 镜出现了平台指令腔「{hit}」,请改成回到具体情境、点名谁适合的自然口语收尾"
|
||||
)
|
||||
hook = str(draft.get("hook") or "")
|
||||
hit = next((phrase for phrase in _CTA_BANNED_PHRASES if phrase in hook), None)
|
||||
if hit:
|
||||
raise ValueError(f"钩子里出现了平台指令腔「{hit}」,请换成具体场景或反差")
|
||||
|
||||
|
||||
def assert_intra_shot_beats(seg: dict, index: int, duration: int) -> None:
|
||||
"""15 秒场必须按秒拆出分镜,否则下游视频只能对着一句空描述乱编。"""
|
||||
need = min_beats_for_duration(duration)
|
||||
@@ -1727,6 +1762,7 @@ def stream_script_agent(
|
||||
selling_titles=selling_titles,
|
||||
)
|
||||
assert_shot_density(candidate, fmt)
|
||||
assert_cta_is_not_shouty(candidate)
|
||||
return candidate
|
||||
|
||||
routed_stream = stream_routed_text_request(
|
||||
|
||||
@@ -872,14 +872,42 @@ class VideoSubmissionStateUnknown(ProviderOutcomeUnknownError):
|
||||
"""视频提交可能已到达供应商但未拿到可靠任务 ID;禁止自动重提。"""
|
||||
|
||||
|
||||
# 本系统出的任何视频都不带字幕。这条是最高优先级的成片硬性禁令,写得穷尽一点 ——
|
||||
# 实测只写「不要字幕」时,模型仍会自作主张加花字 / 价格贴片 / 购物浮层。
|
||||
NO_EMBEDDED_CAPTIONS_REQUIREMENT = (
|
||||
"【成片硬性规则】画面不得出现字幕、台词字幕、花字、标题、价格贴片、UI 文字或其他后期文字叠加;"
|
||||
"只保留商品包装上参考图中原有的真实物理文字与标识。旁白和环境音可以保留,但不要把声音转成画面文字。"
|
||||
"【字幕硬性禁令 · 最高优先级】本片是**无字幕成片**。画面中绝对不能出现任何后期文字或图形叠加,包括但不限于:"
|
||||
"字幕、台词字幕、双语字幕、注音、标题、片头、片尾、花字、艺术字、水印、角标、台标、"
|
||||
"logo 贴片、价格贴片、优惠券、促销标签、倒计时、弹幕、对话气泡、贴纸、表情包文字、"
|
||||
"参数表、成分表、对比图表、评分条、UI 界面、按钮、购物车 / 购买浮层 / 商品卡、进度条、时间码。"
|
||||
"口播、台词、旁白、音效只以**声音**存在,绝不转写成画面上的文字。"
|
||||
"画面里唯一允许出现的文字,是参考图中商品包装 / 标签上原本就有的真实物理文字与品牌标识,"
|
||||
"且必须与参考图完全一致,不得放大、重排或新增。"
|
||||
"即使本提示词别处提到了「字幕」「花字」「标注」等字样,也一律以本条为准:不生成任何文字叠加。"
|
||||
)
|
||||
|
||||
# 提示词正文里残留的「字幕:…」「花字:…」会和上面的禁令打架 —— 模型看到具体字幕内容就会画出来。
|
||||
# 来源:脚本【声音】栏、视频提炼稿逐字照抄的画面花字、用户自己写的提示词。这里在送出前统一抹掉。
|
||||
_CAPTION_FIELD_LINE_RE = re.compile(r"^\s*[-•*]?\s*(?:字幕|花字|艺术字|贴片|水印|角标|标题栏)\s*[::].*$")
|
||||
_CAPTION_FIELD_INLINE_RE = re.compile(r"[,,;;、]?\s*(?:字幕|花字|艺术字|贴片|水印|角标)\s*[::][^;;。\n]*")
|
||||
|
||||
|
||||
def strip_caption_directives(prompt: str) -> str:
|
||||
"""抹掉提示词里「字幕:xxx / 花字:xxx」这类会诱使模型画文字的栏位(整行或行内片段)。
|
||||
只删这类字段,不动其余正文;删空的行直接丢掉。"""
|
||||
out: list[str] = []
|
||||
for line in (prompt or "").split("\n"):
|
||||
if _CAPTION_FIELD_LINE_RE.match(line):
|
||||
continue
|
||||
cleaned = _CAPTION_FIELD_INLINE_RE.sub("", line)
|
||||
if cleaned.strip() or not line.strip():
|
||||
out.append(cleaned)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def enforce_no_embedded_captions(prompt: str) -> str:
|
||||
"""所有视频入口最终汇入这里,避免某个入口漏传“无字幕”导致模型自行加花字。"""
|
||||
base = (prompt or "").strip()
|
||||
"""所有视频入口(专业创作 / 一键成片 / 自由创作 / 视频复刻 / 提炼改写)最终汇入这里:
|
||||
先洗掉正文里的字幕字段,再追加最高优先级的无字幕禁令。少一个入口都不行。"""
|
||||
base = strip_caption_directives(prompt or "").strip()
|
||||
if NO_EMBEDDED_CAPTIONS_REQUIREMENT in base:
|
||||
return base
|
||||
return f"{base}\n{NO_EMBEDDED_CAPTIONS_REQUIREMENT}".strip()
|
||||
|
||||
@@ -23,6 +23,7 @@ from apps.ai.script_agent import (
|
||||
allowed_structures,
|
||||
assert_product_facts_used,
|
||||
assert_script_has_a_hook,
|
||||
assert_cta_is_not_shouty,
|
||||
assert_shot_density,
|
||||
build_agent_messages,
|
||||
coerce_combo,
|
||||
@@ -638,3 +639,31 @@ class RecommendScriptSetupTests(SimpleTestCase):
|
||||
self.assertEqual(rec["format"], "oral")
|
||||
self.assertEqual(rec["structure"], "pain")
|
||||
self.assertEqual(rec["persona"], "urban")
|
||||
|
||||
|
||||
class CtaToneTests(SimpleTestCase):
|
||||
"""用户明确要求全系统去掉「小黄车」那套平台指令腔:CTA 要回到情境、点名谁适合。"""
|
||||
|
||||
_VISUAL = (
|
||||
"0-3s:近景;平视;固定机位;女主在画面右侧对镜头抬眼\n"
|
||||
"3-8s:中近景;侧后方过肩;手持跟拍;右手从画面右侧入画拧开瓶盖\n"
|
||||
"8-12s:特写;俯拍杯口;缓慢推近;液体注入冰块,颜色渐染成琥珀\n"
|
||||
"12-15s:中近景;平视;拉回;女主喝一口,眉头松开肩膀塌下来"
|
||||
)
|
||||
|
||||
def _draft(self, narration):
|
||||
return {"segments": [{"duration": 15, "role": "CTA", "narration": narration, "visual": self._VISUAL}]}
|
||||
|
||||
def test_platform_shout_is_rejected(self):
|
||||
for bad in ("现在点下方小黄车就能入手。", "别犹豫了,赶紧下单。", "想省事的直接买它。", "手慢无,冲。"):
|
||||
with self.assertRaises(ValueError, msg=bad) as ctx:
|
||||
assert_cta_is_not_shouty(self._draft(bad))
|
||||
self.assertIn("平台指令腔", str(ctx.exception))
|
||||
|
||||
def test_shout_in_hook_is_rejected_too(self):
|
||||
with self.assertRaises(ValueError):
|
||||
assert_cta_is_not_shouty({"hook": "闭眼入就完了", "segments": []})
|
||||
|
||||
def test_natural_contextual_cta_passes(self):
|
||||
assert_cta_is_not_shouty(self._draft("你要是也天天下午三点犯困,可以试试这个,我一盒能喝小半个月。"))
|
||||
assert_cta_is_not_shouty(self._draft("链接放这儿了,先看看合不合适再说,不着急。"))
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.ai.services import NO_EMBEDDED_CAPTIONS_REQUIREMENT, enforce_no_embedded_captions
|
||||
from apps.ai.services import (
|
||||
NO_EMBEDDED_CAPTIONS_REQUIREMENT,
|
||||
enforce_no_embedded_captions,
|
||||
strip_caption_directives,
|
||||
)
|
||||
|
||||
|
||||
class VideoCaptionPolicyTests(SimpleTestCase):
|
||||
"""全系统出的视频一律无字幕。所有入口(专业创作 / 一键成片 / 自由创作 / 视频复刻 / 提炼改写)
|
||||
都汇入 execute_routed_video_submit → enforce_no_embedded_captions,这里守住那一道。"""
|
||||
|
||||
def test_always_appends_no_embedded_captions_requirement(self):
|
||||
prompt = enforce_no_embedded_captions("一位用户在厨房展示商品")
|
||||
|
||||
@@ -14,3 +21,38 @@ class VideoCaptionPolicyTests(SimpleTestCase):
|
||||
prompt = enforce_no_embedded_captions(NO_EMBEDDED_CAPTIONS_REQUIREMENT)
|
||||
|
||||
self.assertEqual(prompt.count(NO_EMBEDDED_CAPTIONS_REQUIREMENT), 1)
|
||||
|
||||
def test_requirement_covers_the_overlays_seedance_likes_to_invent(self):
|
||||
"""只写「不要字幕」时实测仍会冒出花字 / 价格贴片 / 购物浮层,禁令必须逐个点名。"""
|
||||
for term in ("花字", "水印", "角标", "价格贴片", "弹幕", "购物车", "进度条", "片尾"):
|
||||
self.assertIn(term, NO_EMBEDDED_CAPTIONS_REQUIREMENT, msg=f"禁令漏了「{term}」")
|
||||
# 提示词别处若还残留「字幕」字样,以本条为准 —— 解决自相矛盾导致的偶发出字幕
|
||||
self.assertIn("以本条为准", NO_EMBEDDED_CAPTIONS_REQUIREMENT)
|
||||
|
||||
def test_strips_caption_field_lines(self):
|
||||
"""整行的「字幕:xxx」直接丢掉 —— 模型看到具体字幕内容就会把它画进画面。"""
|
||||
cleaned = strip_caption_directives("字幕:限时8折\n0-3s:近景;平视;女主抬眼")
|
||||
|
||||
self.assertNotIn("限时8折", cleaned)
|
||||
self.assertIn("0-3s:近景;平视;女主抬眼", cleaned)
|
||||
|
||||
def test_strips_inline_caption_field_inside_sound_row(self):
|
||||
"""脚本【声音】栏里的「字幕:…」片段要摘掉,同一行其它内容留着。"""
|
||||
cleaned = strip_caption_directives("【声音】口播;音效:倒水声;字幕:熬夜救星;背景音乐:无")
|
||||
|
||||
self.assertNotIn("熬夜救星", cleaned)
|
||||
self.assertIn("音效:倒水声", cleaned)
|
||||
self.assertIn("背景音乐:无", cleaned)
|
||||
|
||||
def test_does_not_touch_real_packaging_text(self):
|
||||
"""包装上的真实文字是允许出现的,别被误伤。"""
|
||||
line = "0-3s:近景,包装上的品牌字样清晰"
|
||||
|
||||
self.assertEqual(strip_caption_directives(line), line)
|
||||
|
||||
def test_scrubbing_runs_before_the_requirement_is_appended(self):
|
||||
prompt = enforce_no_embedded_captions("花字:手慢无\n0-3s:特写;推近;倒入")
|
||||
|
||||
self.assertNotIn("手慢无", prompt)
|
||||
self.assertIn("0-3s:特写;推近;倒入", prompt)
|
||||
self.assertIn(NO_EMBEDDED_CAPTIONS_REQUIREMENT, prompt)
|
||||
|
||||
@@ -585,10 +585,9 @@ class SubmitVideoReplaceTests(TestCase):
|
||||
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.assertIn("商品", prompt)
|
||||
self.assertNotIn("@目标商品", prompt)
|
||||
self.assertNotIn("@参考视频", prompt)
|
||||
|
||||
def test_digest_generates_once_without_source_video(self):
|
||||
"""拆完只提交一次 Seedance,不把用户原片传进去。"""
|
||||
@@ -628,9 +627,9 @@ class SubmitVideoReplaceTests(TestCase):
|
||||
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"], "薇薇")
|
||||
self.assertTrue(task.request_payload["digest_pending"])
|
||||
self.assertFalse(task.request_payload["digest_pending"])
|
||||
labels = [item["label"] for item in task.request_payload["references"]]
|
||||
self.assertNotIn("参考视频", labels)
|
||||
self.assertIn("参考视频", labels)
|
||||
self.assertIn("目标角色", labels)
|
||||
|
||||
def test_both_modes_use_seedance_25_and_cap_at_30s(self):
|
||||
@@ -877,13 +876,13 @@ class VideoReplaceReviewGateTests(TestCase):
|
||||
grp.return_value = MagicMock(remote_group_id="Group-1")
|
||||
task = self._submit()
|
||||
self.assertEqual(task.status, AITask.Status.CREATED)
|
||||
self.assertTrue((task.request_payload or {}).get("digest_pending"))
|
||||
self.assertFalse((task.request_payload or {}).get("digest_pending"))
|
||||
self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0)
|
||||
self.assertFalse(self.provider.create_video_task.called)
|
||||
passed_types = [call.kwargs.get("asset_type") for call in create.call_args_list]
|
||||
self.assertEqual(passed_types, ["Image"])
|
||||
self.assertEqual(passed_types, ["Video", "Image"])
|
||||
labels = [item["label"] for item in (task.request_payload or {}).get("references") or []]
|
||||
self.assertNotIn("参考视频", labels)
|
||||
self.assertIn("参考视频", labels)
|
||||
|
||||
def test_advance_starts_generation_after_review_passes(self):
|
||||
with patch("apps.assets.assets_client.is_enabled", return_value=True), patch(
|
||||
@@ -899,18 +898,20 @@ class VideoReplaceReviewGateTests(TestCase):
|
||||
self.image.save(update_fields=["review_status", "review_remote_id"])
|
||||
with patch("apps.assets.assets_client.is_enabled", return_value=True), patch(
|
||||
"apps.assets.assets_client.get_asset", return_value={"Status": "Active"}
|
||||
), patch(
|
||||
"apps.ai.video_digest.digest_asset_video",
|
||||
return_value=(DIGEST_SAMPLE, {"digest_shots": 2}),
|
||||
):
|
||||
run_replace_digest(task)
|
||||
task = advance_video_replace(task)
|
||||
task.refresh_from_db()
|
||||
self.assertEqual(task.status, AITask.Status.SUBMITTED)
|
||||
self.assertTrue(CreditReservation.objects.filter(task=task).exists())
|
||||
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.assertNotIn("video_url", types)
|
||||
self.assertIn("video_url", types)
|
||||
video_urls = [
|
||||
(item.get("video_url") or {}).get("url")
|
||||
for item in content if item.get("type") == "video_url"
|
||||
]
|
||||
self.assertTrue(all(str(url).startswith("asset://") for url in video_urls if url))
|
||||
image_urls = [
|
||||
(item.get("image_url") or {}).get("url")
|
||||
for item in content if item.get("type") == "image_url"
|
||||
|
||||
@@ -86,9 +86,9 @@ PRODUCT_PROMPT = (
|
||||
"严格遵守每镜的新商品动作、场景与口播;不要恢复参考片原商品、原动作、原卖点或原台词。"
|
||||
)
|
||||
CHARACTER_PROMPT = (
|
||||
"按下面这份分镜稿生成视频。镜头数量、每镜时长、景别、机位、运镜、商品、场景、口播全部按稿执行,"
|
||||
"不要增删镜头、不要改顺序。"
|
||||
"把稿里的原人物换成@目标角色,五官、发型、体态以参考图为准。"
|
||||
"以@参考视频的镜头、人物动作、场景、节奏、口播和剪辑为唯一基准生成视频。"
|
||||
"将画面中所有可见的原人物替换为@目标角色;五官、发型、体态和服装以角色参考图为准。"
|
||||
"保留原商品、场景、镜头运动、动作顺序和声音节奏,不添加字幕、花字或额外人物。"
|
||||
)
|
||||
|
||||
# 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。
|
||||
@@ -625,11 +625,18 @@ def submit_video_replace(*, team, user, params: dict):
|
||||
"feature": FEATURE,
|
||||
}
|
||||
|
||||
# 分镜用「提炼提示词」同一套拆。原片只当拆解源,不进审核列表、也不传火山。
|
||||
from .video_digest import resolve_digest_model_config
|
||||
# 商品复刻需要 Gemini 拆镜后重构商品语义;角色复刻直接交给 Seedance 2.5,
|
||||
# 原视频与角色图都先进入同一火山素材库审核,再以 asset:// 作为参考。
|
||||
if replace_mode == "product":
|
||||
from .video_digest import resolve_digest_model_config
|
||||
|
||||
if resolve_digest_model_config() is None:
|
||||
raise ValueError("视频提炼模型未配置,请联系管理员")
|
||||
if resolve_digest_model_config() is None:
|
||||
raise ValueError("视频提炼模型未配置,请联系管理员")
|
||||
else:
|
||||
image_refs = [
|
||||
_owned_ref(video, kind="video", role="reference_video", label="参考视频"),
|
||||
*image_refs,
|
||||
]
|
||||
# 商品图/角色图仍要过审才能当生成参考。提前送审 → 审核和提炼并行跑,明确不过审的直接 400。
|
||||
review_state = _ensure_replace_refs_reviewed(team, image_refs)
|
||||
if review_state == "failed":
|
||||
@@ -648,11 +655,11 @@ def submit_video_replace(*, team, user, params: dict):
|
||||
user=user,
|
||||
params={
|
||||
**base_params,
|
||||
"prompt": DIGEST_PENDING_PROMPT,
|
||||
"prompt": DIGEST_PENDING_PROMPT if replace_mode == "product" else CHARACTER_PROMPT,
|
||||
"references": image_refs,
|
||||
"extra_payload": extra,
|
||||
},
|
||||
digest_pending=True,
|
||||
digest_pending=replace_mode == "product",
|
||||
)
|
||||
|
||||
|
||||
@@ -670,7 +677,11 @@ def advance_video_replace(task):
|
||||
# worker 还在拆参考视频,提示词都没生成,别当成「审核中」反复送审。
|
||||
# worker 挂了也不会永远卡着:CREATED 超 16 分钟由 _reap_stale_free_video_tasks 回收退费。
|
||||
return task
|
||||
references = _image_refs(payload.get("references") or [])
|
||||
references = (
|
||||
_image_refs(payload.get("references") or [])
|
||||
if payload.get("replace_mode") == "product"
|
||||
else list(payload.get("references") or [])
|
||||
)
|
||||
try:
|
||||
state = _ensure_replace_refs_reviewed(task.team, references)
|
||||
except ValueError as exc:
|
||||
@@ -701,9 +712,10 @@ def advance_video_replace(task):
|
||||
def start_replace_generation(task):
|
||||
"""审核过了:整份分镜稿一次交给 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)
|
||||
if payload.get("replace_mode") == "product":
|
||||
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)
|
||||
@@ -719,6 +731,9 @@ def run_replace_digest(task) -> None:
|
||||
if not is_video_replace_task(task) or task.status != AITask.Status.CREATED:
|
||||
return
|
||||
payload = task.request_payload or {}
|
||||
# 角色复刻没有 Gemini 拆镜环节;审核通过后由 advance_video_replace 直接提交 Seedance。
|
||||
if payload.get("replace_mode") == "character":
|
||||
return
|
||||
if not payload.get("digest_pending"):
|
||||
return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user