优化脚本
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
"""故事板下线 + 视频提示词回归代码默认。
|
||||
|
||||
背景(踩过的坑):`render_prompt()` 里**库里的 PromptTemplate 行优先于代码里的写死默认**。
|
||||
`video_segment` 那行长期停留在旧版正文,导致代码默认一路加强(秒级分镜、物理常识…)线上却一直
|
||||
用着那条短提示词。故事板下线后正文又要大改,再往库里写一份全文只会重演同一出。
|
||||
|
||||
因此这里把 `video_segment` 的正文清空 —— 按 admin 页写明的语义「留空 → 回落写死默认」,
|
||||
让它此后永远跟着 `build_video_segment_prompt` 的默认走;超管仍可随时在后台填正文覆盖。
|
||||
同时删掉已下线的 `storyboard_frame` 行。
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
VIDEO_LEGACY_MARKER = "{分镜}" # 故事板时代的占位符;只有还带着它的行才算「没人改过的旧默认」
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
PromptTemplate = apps.get_model("ai", "PromptTemplate")
|
||||
for row in PromptTemplate.objects.filter(key="video_segment"):
|
||||
# 只清「故事板时代」的正文;超管自己写过的新正文(不含 {分镜})原样保留,不越权覆盖
|
||||
if VIDEO_LEGACY_MARKER in (row.template or ""):
|
||||
row.template = ""
|
||||
row.save(update_fields=["template"])
|
||||
PromptTemplate.objects.filter(key="storyboard_frame").delete()
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
# 回滚只补回分镜图那行的存在性(正文留空 = 回落当时的写死默认),不还原已清空的视频正文
|
||||
PromptTemplate = apps.get_model("ai", "PromptTemplate")
|
||||
PromptTemplate.objects.get_or_create(key="storyboard_frame", defaults={"template": "", "ratio": "portrait"})
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("ai", "0031_script_beat_prompt_templates")]
|
||||
operations = [migrations.RunPython(forwards, backwards)]
|
||||
@@ -137,15 +137,21 @@ _PERSONA_KEY_BY_LABEL = {label: key for key, label in PERSONA_LABELS.items()}
|
||||
NARRATION_CHARS_PER_SECOND = 5.2
|
||||
NARRATION_CHARS_PER_SECOND_MIN = 4.3
|
||||
NARRATION_CHARS_HARD_CAP = 78
|
||||
VISUAL_CHARS_MIN = 72
|
||||
# 故事板下线后,visual 是出片模型唯一的画面依据,门槛从「够写一句」抬到「够当导演说明书」
|
||||
VISUAL_CHARS_MIN = 110
|
||||
SHOT_BEATS_MIN = 3
|
||||
BEAT_SPAN_RE = re.compile(
|
||||
r"(?P<start>\d{1,2})\s*[-–—~到至]\s*(?P<end>\d{1,2})\s*(?:s|秒)?\s*[::]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# 景别(拍多大)与 机位/运镜(怎么拍)分开把关:出片模型两样都要,缺一样画面就会平。
|
||||
_SHOT_SIZE_MARKERS = (
|
||||
"特写", "近景", "中近景", "中景", "全景", "远景", "胸上", "过肩",
|
||||
"手持", "跟拍", "俯拍", "仰拍", "推近", "拉远", "摇",
|
||||
"大特写", "特写", "近景", "中近景", "中景", "全景", "远景", "胸上", "过肩",
|
||||
)
|
||||
_CAMERA_MOVE_MARKERS = (
|
||||
"手持", "跟拍", "跟随", "俯拍", "仰拍", "平视", "推近", "推进", "推镜",
|
||||
"拉远", "拉回", "拉镜", "摇镜", "横摇", "移镜", "平移", "环绕", "升降",
|
||||
"固定机位", "定机位", "固定镜头", "过肩", "第一人称", "越肩",
|
||||
)
|
||||
_MINOR_CHARACTER_RE = re.compile(
|
||||
r"(?:婴儿|宝宝|宝贝|幼儿|儿童|小孩|小朋友|未成年|男童|女童|baby|toddler|infant)",
|
||||
@@ -501,20 +507,47 @@ _CREATIVE_DIRECTION = """
|
||||
输出前自问:遮住商品名后,这是不是仍然像一个真实的人在讲自己刚遇到的一件事?
|
||||
如果像广告口号,重写得更具体、更有立场。
|
||||
|
||||
### visual 必须写成「导演说明书」,不是一句画面摘要
|
||||
每个 segment 的 `visual` 采用下面的层级;这不是给用户看的散文,而是会原样交给故事板和视频模型的执行指令:
|
||||
### visual 必须写成「导演说明书」,因为它是出片模型唯一的画面依据
|
||||
|
||||
**这条最重要:流程里没有分镜图了。** 你写的 `visual` 不会再经过一张关键帧图去"翻译",
|
||||
而是**连同角色/商品/场景参考图一起,原样交给视频生成模型**。你没写清楚的,模型就自己编;
|
||||
你写含糊的,画面就含糊。所以 `visual` 要写到「一个不认识这个商品的摄影师,照着就能拍」的程度。
|
||||
|
||||
每个 segment 的 `visual` 采用下面的层级,逐栏写满:
|
||||
```
|
||||
【本镜任务】这一段要让观众看懂的变化或悬念。
|
||||
【光线氛围】光源方向与性质(窗光/顶光/台灯/逆光);色温冷暖;明暗对比;整体色调。全片各镜保持一致。
|
||||
【声音】台词/旁白状态;音效;背景音乐;字幕。没有就写「无」;没有配乐写「无配乐,仅同期声」。
|
||||
【画面内容】
|
||||
0-3s:景别;机位;运镜;人物/商品动作;情绪或信息变化。
|
||||
3-8s:景别;机位;运镜;手与商品的空间关系;可见细节。
|
||||
8-12s:景别;机位;运镜;动作结果或卖点证据。
|
||||
0-3s:景别;机位高度与角度;运镜;主体在画面中的位置;具体动作;情绪或信息变化。
|
||||
3-8s:景别;机位;运镜;手与商品的空间关系(哪只手、握哪里、从哪个方向入画);可见细节。
|
||||
8-12s:景别;机位;运镜;动作结果或卖点证据(看得见的变化:颜色/质地/形态/前后差别)。
|
||||
12-15s:景别;机位;运镜;反应/悬念/自然收束。
|
||||
```
|
||||
每条秒级分镜都要明确写出 **景别、机位、运镜、动作、信息变化** 五项中的至少四项。
|
||||
输入如果是 `【镜头 01】` 导演分镜稿,把原稿的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进对应栏,不要压成一句画面摘要。
|
||||
同一角色、商品、场景的外观一律引用 entities 里的既定设定,不在每一镜随意换发型、服装、包装、光线或地点。
|
||||
|
||||
每条秒级分镜必须同时写出这五项,不能省:
|
||||
1. **景别** —— 大特写/特写/近景/中近景/中景/全景(15 秒内至少切两次,别一个景别拍到底)
|
||||
2. **机位** —— 高度与角度:平视/俯拍/仰拍/过肩/桌面视角
|
||||
3. **运镜** —— 手持跟拍/推近/拉远/横摇/环绕/固定机位(**每镜至少出现一个运镜词**,否则画面会死)
|
||||
4. **动作** —— 具体到肢体:谁、用哪只手、对什么、做了什么动作,动作要连贯可拍
|
||||
5. **信息变化** —— 这 3–5 秒里画面上多了什么、变了什么
|
||||
|
||||
### 只写拍得出来的东西
|
||||
- **禁止抽象词**:「展示商品」「呈现质感」「体现高级感」「氛围到位」「传递温暖」——
|
||||
这些不是画面,是评价。要写成「杯壁上的水珠顺着往下滑」「她眉头松开,肩膀塌下来」。
|
||||
- **禁止心理描写**:模型拍不出「她内心很纠结」,要写「她拿起又放下,手指在包装边缘停了两秒」。
|
||||
- **禁止画面里出现文字**:不要写字幕、花字、标题、价格贴片、UI、弹窗、对比图表;
|
||||
商品包装上原有的真实文字除外。
|
||||
- **禁止一镜内跨场景/跨时间蒙太奇**:15 秒是**一个连续的动作链**,同一地点、同一光线、
|
||||
同一套衣服。要换环境就换到下一镜,并在 entity_refs 里换成另一个 scene。
|
||||
|
||||
### 一致性靠文字锁死
|
||||
同一角色、商品、场景的外观一律引用 entities 里的既定设定;不在每一镜随意换发型、服装、
|
||||
包装、光线、地点或色调。每一镜的【光线氛围】要和相邻镜衔接得上(同一场戏不能上一镜暖黄台灯、
|
||||
下一镜冷白日光)。商品的用法要符合物理常识:热饮有蒸汽、液体会流动、包装要先打开才能取出内容物。
|
||||
|
||||
输入如果是 `【镜头 01】` 导演分镜稿,把原稿的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注
|
||||
折进对应栏,不要压成一句画面摘要。
|
||||
"""
|
||||
|
||||
# 这些句式几乎总会让首屏像模板广告。只作为生成后的最后一道门,不替代模型的创作判断。
|
||||
@@ -698,12 +731,16 @@ def build_agent_messages(
|
||||
speech_floor = narration_floor(SEGMENT_DURATION_MAX)
|
||||
speech_cap = narration_limit(SEGMENT_DURATION_MAX)
|
||||
beats_line = (
|
||||
f"【秒级分镜】每个 {SEGMENT_DURATION_MAX} 秒场必须拆成 3–5 个分镜,visual 必须按「导演说明书」写成多行:"
|
||||
"先写【本镜任务】、【声音】、【画面内容】,再写秒级分镜;"
|
||||
f"每条格式`0-3s:景别;机位;运镜;谁在做什么;信息变化`,最后一条接到 {SEGMENT_DURATION_MAX}s。"
|
||||
"每条写清手/商品/容器的空间关系和真实用法"
|
||||
"(茶=热水+蒸汽+茶汤变色,禁止茶包丢进冷白开;手从真实方向入画,禁止悬浮肢体)。"
|
||||
"禁止一句空画面撑满 15 秒。允许另给 beats 数组,后端会折进 visual。\n"
|
||||
f"【秒级分镜】流程里没有分镜图了,visual 会**原样交给出片模型**,写多细画面就有多准。"
|
||||
f"每个 {SEGMENT_DURATION_MAX} 秒场必须拆成 3–5 个分镜,visual 按「导演说明书」写成多行:"
|
||||
"先写【本镜任务】、【光线氛围】(光源方向/色温/明暗/色调,各镜保持一致)、【声音】、【画面内容】,再写秒级分镜;"
|
||||
f"每条格式`0-3s:景别;机位;运镜;主体位置与具体动作;信息变化`,最后一条接到 {SEGMENT_DURATION_MAX}s。"
|
||||
"每条必须带景别(15 秒内至少切两次)+ 机位 + 运镜词(手持/跟拍/推近/拉远/环绕/固定机位,缺了画面会死),"
|
||||
"并写清哪只手、从哪个方向入画、握商品哪里,以及真实用法"
|
||||
"(茶=热水+蒸汽+茶汤变色,禁止茶包丢进冷白开;禁止悬浮肢体、商品凭空出现)。"
|
||||
"只写拍得出来的东西:禁止「展示商品/呈现质感/体现高级感」这类评价词,禁止心理描写,"
|
||||
"禁止画面里出现字幕花字标题价签,禁止一镜内跨场景跨时间。"
|
||||
f"禁止一句空画面撑满 {SEGMENT_DURATION_MAX} 秒。允许另给 beats 数组,后端会折进 visual。\n"
|
||||
)
|
||||
if fmt == "oral":
|
||||
writing_line = (
|
||||
@@ -1025,6 +1062,11 @@ def assert_intra_shot_beats(seg: dict, index: int, duration: int) -> None:
|
||||
markers = [mark for mark in _SHOT_SIZE_MARKERS if mark in visual]
|
||||
if len(set(markers)) < 2:
|
||||
raise ValueError(f"第 {index + 1} 镜 15 秒内至少要切两次景别,并写进秒级分镜")
|
||||
# 故事板下线后没有关键帧兜底,机位/运镜只能靠这段文字告诉出片模型
|
||||
if not any(mark in visual for mark in _CAMERA_MOVE_MARKERS):
|
||||
raise ValueError(
|
||||
f"第 {index + 1} 镜必须写清机位与运镜(手持/跟拍/推近/拉远/环绕/固定机位…),否则出片只会拍成呆板静止画面"
|
||||
)
|
||||
|
||||
|
||||
# 模型每次生成都可能换字段名(scene/screenDescription/visual…、dialogue/lines/caption…),
|
||||
|
||||
@@ -41,10 +41,6 @@ from apps.projects.models import (
|
||||
ProjectStage,
|
||||
ScriptSegment,
|
||||
ScriptVersion,
|
||||
StoryboardFrame,
|
||||
StoryboardShot,
|
||||
StoryboardShotVersion,
|
||||
StoryboardVersion,
|
||||
Timeline,
|
||||
VideoSegment,
|
||||
VideoSegmentVersion,
|
||||
@@ -67,26 +63,6 @@ def get_default_model(capability: str) -> ModelConfig:
|
||||
return qs.filter(is_default=True).order_by("created_at").first() or qs.order_by("created_at").first()
|
||||
|
||||
|
||||
def get_storyboard_image_model() -> ModelConfig | None:
|
||||
"""故事板出图只走 GPT 图像模型(gpt-image / gpt-image-2)。
|
||||
|
||||
不回落默认图像模型,也不走火山 Seedream:用户明确要求故事板无论怎样都不改成其他生图模型。
|
||||
"""
|
||||
qs = (
|
||||
ModelConfig.objects.select_related("provider")
|
||||
.filter(
|
||||
capability=ModelConfig.Capability.IMAGE,
|
||||
status=ModelConfig.Status.ACTIVE,
|
||||
provider__status="active",
|
||||
name__icontains="gpt-image",
|
||||
)
|
||||
)
|
||||
return (
|
||||
qs.filter(name="gpt-image-2").order_by("created_at").first()
|
||||
or qs.order_by("created_at").first()
|
||||
)
|
||||
|
||||
|
||||
def resolve_image_model(key: str | None) -> "ModelConfig | None":
|
||||
"""前端「生图模型选择」→ ModelConfig。用户显式选的可以是 disabled 模型(故不按 status 过滤)。
|
||||
· "volcano" → 火山官方 Seedream(取最新一版)
|
||||
@@ -901,20 +877,6 @@ NO_EMBEDDED_CAPTIONS_REQUIREMENT = (
|
||||
"只保留商品包装上参考图中原有的真实物理文字与标识。旁白和环境音可以保留,但不要把声音转成画面文字。"
|
||||
)
|
||||
|
||||
# 故事板是创作者确认镜头节奏的导演稿,因此必须由 image-2 直接生成完整排版图;
|
||||
# 视频提示词会明确要求只提取其中的角色、商品、场景、动作和节奏,忽略版式文字。
|
||||
STORYBOARD_DIRECTOR_LAYOUT_REQUIREMENT = (
|
||||
"【导演故事板成图硬性要求】输出必须是一张完整、正式、可交付的电商短视频导演故事板图片,而不是无说明的拼图或单张海报。"
|
||||
"整体为竖版 9:16,采用清爽专业的影视分镜排版:顶部 1 行标题区,标出“导演故事板”、本段时长和画幅;"
|
||||
"主体严格按照本段脚本的秒级分镜,优先整理为 3 个连续时间段(例如 0–5s、5–10s、10–15s;若脚本时长不同则按实际时间划分)。"
|
||||
"每个时间段必须包含三部分:左侧约 22% 宽的浅色信息栏,清晰写时间、景别、机位/运镜和简短动作;右侧约 78% 的写实主画面;"
|
||||
"画面底部横条使用扬声器图标与“口播/旁白:”呈现该时间段对应旁白。各段按时间自上而下排列,边框、间距、对齐统一。"
|
||||
"右侧画面要真实展现该秒级分镜的不同关键动作,人物脸、服装、商品外观、配色和场景连续一致;商品用法必须正确,包装真实文字和 Logo 不得改造。"
|
||||
"故事板中的中文标注必须工整、简洁、可读,只写脚本已有的时间、机位、动作与旁白,不虚构价格、功效、活动或品牌信息。"
|
||||
"禁止只给一张静态人物图、禁止无文字的四格拼图、禁止把三段画面重复成同一姿势。"
|
||||
)
|
||||
|
||||
|
||||
def enforce_no_embedded_captions(prompt: str) -> str:
|
||||
"""所有视频入口最终汇入这里,避免某个入口漏传“无字幕”导致模型自行加花字。"""
|
||||
base = (prompt or "").strip()
|
||||
@@ -1470,20 +1432,6 @@ def project_output_spec(project) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _storyboard_canvas_phrase(ratio: str) -> str:
|
||||
r = (ratio or "9:16").strip() or "9:16"
|
||||
if r in {"9:16", "3:4"}:
|
||||
return f"电商竖屏 {r}"
|
||||
if r == "1:1":
|
||||
return f"电商方形 {r}"
|
||||
return f"电商横屏 {r}"
|
||||
|
||||
|
||||
def _apply_storyboard_output_ratio(text: str, project) -> str:
|
||||
phrase = _storyboard_canvas_phrase(project_output_spec(project)["aspect_ratio"])
|
||||
return (text or "").replace("电商竖屏 9:16", phrase)
|
||||
|
||||
|
||||
def _sync_timeline_output_spec(project, *, aspect_ratio: str, resolution: str) -> None:
|
||||
from apps.ai.video_pricing import get_resolution
|
||||
|
||||
@@ -2702,125 +2650,49 @@ def _segment_script_text(segment, entities=None, *, with_dialogue: bool = False,
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def build_storyboard_frame_prompt(project, segment, extra_prompt: str = "") -> str:
|
||||
"""单镜导演故事板提示词(无参考图时的文本版)。与 refs 版共用 admin「提示词·分镜图」模板(设定为空)。
|
||||
extra_prompt = 整张风格提示词(原 StoryboardVersion.prompt,现存项目级)。"""
|
||||
dur = segment.duration_seconds or 15
|
||||
default = (
|
||||
"{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n"
|
||||
"【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n"
|
||||
"请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;"
|
||||
"电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。"
|
||||
"脚本里的 visual 是本段秒级分镜清单;请画商品用法正确、最能看清商品的那一拍作为关键帧。"
|
||||
"禁止画出违背常识的用法(例如把茶包丢进冷白开、悬浮的手)。{补充}"
|
||||
)
|
||||
rendered = render_prompt(
|
||||
"storyboard_frame", default,
|
||||
设定="",
|
||||
场景上下文=_scene_context(project),
|
||||
时长=dur,
|
||||
脚本=(_segment_script_text(segment) or f"第 {segment.sort_order + 1} 镜"),
|
||||
补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""),
|
||||
)
|
||||
cleaned = "\n".join(line for line in rendered.split("\n") if line.strip())
|
||||
return _apply_storyboard_output_ratio(
|
||||
f"{cleaned}\n{STORYBOARD_DIRECTOR_LAYOUT_REQUIREMENT}", project
|
||||
)
|
||||
|
||||
|
||||
def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: str = "") -> str:
|
||||
"""单段视频提示词(用户钦定 @图N 格式):
|
||||
【设定】@图N 点名 角色/场景/商品;【分镜】根据 @图(分镜图) 生成;【脚本】本镜脚本。
|
||||
【设定】@图N 点名 角色/场景/商品;【风格】全片统一的风格锚点;【脚本】本镜导演说明书(秒级分镜 + 台词)。
|
||||
|
||||
★ 去掉故事板中间层后,导演信息不再经由一张分镜图传递,而是**全靠这份提示词直达出片模型**,
|
||||
因此这里必须自足:参考图一致性约束 + 秒级执行纪律 + 物理常识 + 成片硬性规则,一条都不能少。
|
||||
refs 顺序与传给 seedance 的 reference_images 一致(@图N 对齐不错位)。"""
|
||||
product_name = (getattr(project.product, "title", "") or "商品").strip()
|
||||
setup_parts = []
|
||||
storyboard_idx = None
|
||||
for i, r in enumerate(refs or []):
|
||||
n = i + 1
|
||||
if r.get("type") == "storyboard":
|
||||
storyboard_idx = n
|
||||
else:
|
||||
setup_parts.append(f"@图{n}是{r.get('label') or ''}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})")
|
||||
setup_parts.append(f"@图{n}是{r.get('label') or ''}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})")
|
||||
# 视频脚本:用带说话人的台词(原样照脚本),不带「商品露出」那行(露出靠模板尾句统一要求)
|
||||
entities = (project.metadata or {}).get("script_entities", [])
|
||||
script_text = _segment_script_text(scene, entities, with_dialogue=True, with_exposure=False) if scene is not None else ""
|
||||
extra = (user_prompt or "").strip()
|
||||
if extra:
|
||||
script_text = (script_text + "\n" + extra).strip() if script_text else extra
|
||||
# 正文可在 admin「提示词·视频」页改。占位符:{设定}=@图N点名、{分镜}=分镜图引用、{脚本}=本段脚本、{时长}。
|
||||
# 正文可在 admin「提示词·视频」页改。占位符:{设定}=@图N点名、{风格}=全片风格锚点、{脚本}=本段脚本、{时长}。
|
||||
# 时长/比例靠 API 参数传(duration/ratio),不写进正文;尾句给风格 + 音效/字幕要求。
|
||||
default = (
|
||||
"{设定}{分镜}【脚本】{脚本}\n"
|
||||
"严格按照脚本里的秒级分镜切换景别和动作,商品用法必须真实,不要诡异姿势或错误容器。"
|
||||
"如果参考图是带时间、机位、旁白标注的导演故事板,只提取其中的角色、商品、场景、动作和时间顺序;"
|
||||
"不要把边框、标题、时间码、扬声器图标或任何故事板文字生成进视频。"
|
||||
"电商带货短视频,商品露出清晰,节奏有转化感。不要字幕,不要背景音乐,但是要有音效,逼真的音效。"
|
||||
"{设定}{风格}【脚本】{脚本}\n"
|
||||
"【执行纪律】严格按脚本里的秒级分镜拍:每一个时间段都要拍到,景别、机位、运镜、动作按写的来;"
|
||||
"不要把整段并成一个静止长镜头,也不要加脚本里没有的转场、人物或道具。"
|
||||
"\n【一致性】参考图是本片唯一的视觉依据:角色保持同一张脸、同一发型、同一套服装;"
|
||||
"商品保持参考图的外形、配色、材质、比例与包装上的真实文字标识,不得改造、换色、换包装或凭空加配件;"
|
||||
"场景保持同一空间、同一陈设、同一光线方向与色温。多镜之间人物与商品必须看起来是同一次拍摄。"
|
||||
"\n【物理常识】手从画面内自然入画,禁止悬浮肢体、反关节、商品凭空出现或消失;"
|
||||
"商品用法必须是真人会做的(茶/咖啡用热水、有蒸汽与茶汤渐染;护肤品挤出并涂抹;食品打开并入口),"
|
||||
"禁止诡异姿势、错误容器或违背常识的操作。"
|
||||
"\n【画质】真人实拍质感,自然光影,肤色与材质真实,焦点始终落在脚本指定的主体上,画面稳定不糊。"
|
||||
"\n电商带货短视频,商品露出清晰,节奏有转化感。不要字幕,不要背景音乐,但是要有音效,逼真的音效。"
|
||||
)
|
||||
rendered = render_prompt(
|
||||
"video_segment", default,
|
||||
设定=("【设定】" + ",".join(setup_parts) + "。\n" if setup_parts else ""),
|
||||
分镜=(f"【分镜】根据@图{storyboard_idx}分镜图生成「{product_name}」短视频。\n" if storyboard_idx is not None else ""),
|
||||
风格=(f"【风格】{_scene_context(project)}。\n" if _scene_context(project) else ""),
|
||||
分镜="", # 兼容:旧模板行里可能还留着 {分镜}(故事板时代的占位符),渲染成空串而不是原样漏出
|
||||
脚本=(script_text or f"第 {video_segment.sort_order + 1} 段"),
|
||||
时长=video_segment.target_duration_seconds,
|
||||
)
|
||||
return enforce_no_embedded_captions("\n".join(line for line in rendered.split("\n") if line.strip()))
|
||||
|
||||
|
||||
def ensure_storyboard_shots(project) -> list:
|
||||
"""确保「采用版分镜数」= StoryboardShot 数:每镜一个 shot(对标视频段)。
|
||||
缺则按 sort_order 建、补绑 script_segment;多出且从没出过图的尾 shot 裁掉(已出图的不动)。"""
|
||||
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
|
||||
if adopted_script is None:
|
||||
return []
|
||||
segs = list(adopted_script.segments.order_by("sort_order"))
|
||||
shots = {s.sort_order: s for s in project.storyboard_shots.all()}
|
||||
for seg in segs:
|
||||
shot = shots.get(seg.sort_order)
|
||||
if shot is None:
|
||||
shots[seg.sort_order] = StoryboardShot.objects.create(
|
||||
project=project, script_segment=seg, sort_order=seg.sort_order, prompt=seg.visual_prompt or "")
|
||||
elif shot.script_segment_id != seg.id:
|
||||
shot.script_segment = seg
|
||||
shot.save(update_fields=["script_segment", "updated_at"])
|
||||
target = len(segs)
|
||||
for order, shot in list(shots.items()):
|
||||
if order >= target and not shot.versions.exists():
|
||||
shot.delete()
|
||||
del shots[order]
|
||||
return [shots[k] for k in sorted(shots)]
|
||||
|
||||
|
||||
def submit_storyboard(*, project, user, prompt: str = "", shot_ids: list | None = None) -> list:
|
||||
"""故事板·提交(分镜制,对标视频「开始生成/单段重跑」):确保每镜一个 shot,把目标场标 QUEUED,
|
||||
真正出图由 poll_storyboard 起后台线程。返回受影响的 shots。
|
||||
- shot_ids 给定 → 只(重)生成这些场(单场重跑);
|
||||
- 不给 → 还没出片的场;若全部已出片 → 视为整批重跑,全部重出。
|
||||
prompt = 整张风格提示词,存项目级 metadata,供每场出图带上(原 StoryboardVersion.prompt 的去处)。"""
|
||||
adopted_script = project.script_versions.filter(is_adopted=True).first()
|
||||
if adopted_script is None:
|
||||
raise ValueError("script must be adopted before generating storyboard")
|
||||
if get_storyboard_image_model() is None:
|
||||
raise ValueError("故事板只使用 GPT 图像模型,当前没有启用 gpt-image-2")
|
||||
if prompt:
|
||||
meta = dict(project.metadata or {})
|
||||
if meta.get("storyboard_prompt") != prompt:
|
||||
meta["storyboard_prompt"] = prompt
|
||||
project.metadata = meta
|
||||
project.save(update_fields=["metadata", "updated_at"])
|
||||
shots = ensure_storyboard_shots(project)
|
||||
if shot_ids is not None:
|
||||
want = {str(x) for x in shot_ids}
|
||||
targets = [s for s in shots if str(s.id) in want]
|
||||
else:
|
||||
pending = [s for s in shots if s.status != StoryboardShot.Status.SUCCEEDED or s.adopted_version_id is None]
|
||||
targets = pending if pending else shots # 全部已出片 = 整批重跑
|
||||
for s in targets:
|
||||
s.status = StoryboardShot.Status.QUEUED
|
||||
s.error_message = ""
|
||||
s.save(update_fields=["status", "error_message", "updated_at"])
|
||||
return targets
|
||||
|
||||
|
||||
_ENTITY_TYPE_CN = {"character": "角色", "scene": "场景", "product": "商品"}
|
||||
|
||||
|
||||
@@ -2846,9 +2718,9 @@ def _product_reference_image(project, groups: list | None = None) -> dict | None
|
||||
return None
|
||||
|
||||
|
||||
def _storyboard_reference_images(project, segment) -> list[dict]:
|
||||
def _segment_reference_images(project, segment) -> list[dict]:
|
||||
"""按本镜 entity_refs 取参考图(角色 / 场景 已采用基础资产)+ **无条件带上商品参考图**,
|
||||
供 gpt-image-2 多图合成 @图N。返回 [{url,label,type}],最多 4 张(角色/场景 ≤3 + 商品 1)。
|
||||
供出片模型 @图N 锁脸 / 锁商品 / 锁场景。返回 [{url,label,type}],最多 4 张(角色/场景 ≤3 + 商品 1)。
|
||||
商品不靠 entity_refs(预创建真实商品、不从脚本提),统一用商品三视图 / 主图带上,从根上保证
|
||||
商品参考永不缺失。依赖提取步落进 metadata 的 script_entities。"""
|
||||
entities = {
|
||||
@@ -2911,65 +2783,6 @@ def _storyboard_reference_images(project, segment) -> list[dict]:
|
||||
return out[:4]
|
||||
|
||||
|
||||
def build_storyboard_frame_prompt_refs(project, segment, refs: list[dict], extra_prompt: str = "") -> str:
|
||||
"""参考图合成版(用户钦定格式):顶部 @图N 点名每张参考图(角色/商品/场景),再给导演故事板指令 + 分镜脚本。
|
||||
@图N 顺序与传给 gpt-image-2 的参考图顺序严格一致(refs 即 image_edit 的 images 顺序)。"""
|
||||
if not refs:
|
||||
return build_storyboard_frame_prompt(project, segment, extra_prompt)
|
||||
setup = ",".join(
|
||||
f"@图{i + 1}是{r['label']}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})" for i, r in enumerate(refs)
|
||||
)
|
||||
dur = segment.duration_seconds or 15
|
||||
# 正文可在 admin「提示词」页改。占位符:{设定}=@图N点名、{场景上下文}、{时长}、{脚本}=本镜脚本、{补充}=本版附加。
|
||||
default = (
|
||||
"{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n"
|
||||
"【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n"
|
||||
"请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;"
|
||||
"电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。"
|
||||
"脚本里的 visual 是本段秒级分镜清单;请画商品用法正确、最能看清商品的那一拍作为关键帧。"
|
||||
"禁止画出违背常识的用法(例如把茶包丢进冷白开、悬浮的手)。{补充}"
|
||||
)
|
||||
rendered = render_prompt(
|
||||
"storyboard_frame", default,
|
||||
设定=(f"【设定】{setup}。\n" if setup else ""),
|
||||
场景上下文=_scene_context(project),
|
||||
时长=dur,
|
||||
脚本=(_segment_script_text(segment) or f"第 {segment.sort_order + 1} 镜"),
|
||||
补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""),
|
||||
)
|
||||
cleaned = "\n".join(line for line in rendered.split("\n") if line.strip())
|
||||
return _apply_storyboard_output_ratio(
|
||||
f"{cleaned}\n{STORYBOARD_DIRECTOR_LAYOUT_REQUIREMENT}", project
|
||||
)
|
||||
|
||||
|
||||
def _is_transient_error(exc: Exception) -> bool:
|
||||
"""网络抖动/超时类瞬时错误(可重试),区别于内容审核拦截、参数非法等确定性失败。
|
||||
中转站(tokenssr 等)偶发 Read timeout / 连接重置会无谓掐掉单帧,这类才重试。
|
||||
★ 不重试 400:实测故事板的 400 主因是 gpt-image-2 内容审核拦截(moderation_blocked,
|
||||
safety_violations=[sexual],如脚本写到「蕾丝胸罩/抚摸/贴身」)—— 同一画面/文案重试还是被拦,
|
||||
重试只会拖延报错、空耗调用。这类应「快速失败 + 友好提示」让用户改提示词,而非闷头重试。"""
|
||||
msg = str(exc).lower()
|
||||
return any(
|
||||
k in msg
|
||||
for k in ("timed out", "timeout", "connection", "reset by peer", "temporarily",
|
||||
"bad gateway", "502", "503", "504", "remotedisconnected", "max retries")
|
||||
)
|
||||
|
||||
|
||||
# 审核类别英文 → 给用户的中文说明(摘自第三方 safety_violations 字段)。命不中则原样保留英文。
|
||||
_MODERATION_CATEGORY_CN = {
|
||||
"sexual": "性暗示 / 露骨",
|
||||
"sexual/minors": "涉及未成年的性内容",
|
||||
"violence": "暴力",
|
||||
"violence/graphic": "血腥暴力",
|
||||
"self-harm": "自残",
|
||||
"hate": "仇恨",
|
||||
"harassment": "骚扰",
|
||||
"illicit": "违禁",
|
||||
}
|
||||
|
||||
|
||||
def _extract_moderation_categories(raw: str) -> list[str]:
|
||||
"""从原始报错里抽出被审核命中的类别(如 safety_violations=[sexual] / "categories":["sexual"]),
|
||||
译成中文标签。抽不到返回 []。供友好提示点名真实类别,而非泛化的「疑似敏感内容」。"""
|
||||
@@ -3061,192 +2874,6 @@ def notify_generation_failure(
|
||||
logger.exception("notify_generation_failure failed for task %s", getattr(task, "id", "?"))
|
||||
|
||||
|
||||
def _call_image_with_retry(fn, *, attempts: int = 2, base_delay: float = 2.0):
|
||||
"""对一次出图网络调用做有界重试:仅瞬时错误重试(指数退避),确定性失败立即抛出。
|
||||
出图无副作用(失败=没拿到图),重试安全;成功一次即返回。
|
||||
attempts 默认 2(一次重试):单次 HTTP 超时上限 300s,2 次≈10min,须 < poll 的「在途锁过期窗口」
|
||||
(STORYBOARD_INFLIGHT_STALE_MINUTES)否则 worker 重试期间任务被判僵尸 → 重复起线程 + 重复扣费。"""
|
||||
import time
|
||||
|
||||
last: Exception | None = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return fn()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last = exc
|
||||
if i == attempts - 1 or not _is_transient_error(exc):
|
||||
raise
|
||||
time.sleep(base_delay * (i + 1))
|
||||
raise last # 理论不可达(循环内已 return/raise)
|
||||
|
||||
|
||||
def _storyboard_shot_worker(task_id, shot_id, user_id) -> None:
|
||||
"""后台线程:为一个 StoryboardShot 出一张图 → 落成一条 StoryboardShotVersion 并采用。HTTP 永远秒回。"""
|
||||
from django.db import connections
|
||||
|
||||
from apps.accounts.models import User
|
||||
|
||||
try:
|
||||
task = AITask.objects.select_related("model_config__provider").get(id=task_id)
|
||||
shot = StoryboardShot.objects.select_related("project__team", "script_segment").get(id=shot_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
project = shot.project
|
||||
segment = shot.script_segment
|
||||
reservation = task.credit_reservation
|
||||
extra_prompt = (project.metadata or {}).get("storyboard_prompt", "") or ""
|
||||
spec = project_output_spec(project)
|
||||
frame_ratio = spec["aspect_ratio"]
|
||||
frame_size = _ratio_to_image_size(frame_ratio)
|
||||
task.status = AITask.Status.SUBMITTED
|
||||
task.save(update_fields=["status", "updated_at"])
|
||||
try:
|
||||
# 故事板无论任务上挂了什么模型、是否开了路由,都只走 GPT 图像;失败也不换 Seedream。
|
||||
model_config = get_storyboard_image_model()
|
||||
if model_config is None:
|
||||
raise ValueError("故事板只使用 GPT 图像模型,当前没有启用 gpt-image-2")
|
||||
provider = get_image_provider(model_config)
|
||||
refs = _storyboard_reference_images(project, segment) if segment is not None else []
|
||||
ref_urls = [r["url"] for r in refs]
|
||||
if ref_urls and hasattr(provider, "image_edit"):
|
||||
# gpt-image-2 多图参考:必须用 refs 版提示词(点名「参考图N=角色/场景/商品」+锁脸锁商品)
|
||||
frame_prompt = build_storyboard_frame_prompt_refs(project, segment, refs, extra_prompt)
|
||||
else:
|
||||
frame_prompt = (
|
||||
build_storyboard_frame_prompt(project, segment, extra_prompt) if segment is not None
|
||||
else (task.request_payload.get("prompt") or "")
|
||||
)
|
||||
|
||||
if ref_urls and hasattr(provider, "image_edit"):
|
||||
response = _call_image_with_retry(
|
||||
lambda: provider.image_edit(
|
||||
model=model_config.name,
|
||||
prompt=frame_prompt,
|
||||
images=ref_urls,
|
||||
size=frame_size,
|
||||
)
|
||||
)
|
||||
else:
|
||||
response = _call_image_with_retry(
|
||||
lambda: provider.image_generation(
|
||||
model=model_config.name,
|
||||
endpoint=model_config.endpoint,
|
||||
prompt=frame_prompt,
|
||||
)
|
||||
)
|
||||
media = provider.extract_first_media_url(response)
|
||||
asset = _store_generated_media(
|
||||
team=project.team, user=user, project=project, task=task, media=media,
|
||||
name=f"{project.name}-storyboard-{shot.sort_order + 1}",
|
||||
category=Asset.Category.STORYBOARD, asset_type=Asset.Type.IMAGE,
|
||||
)
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
task.response_payload = response
|
||||
task.actual_cost = task.estimated_cost
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
|
||||
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
|
||||
# 落一条新版本并采用(反采用同 shot 其余版本)= 该场历史 +1,采用最新
|
||||
version = StoryboardShotVersion.objects.create(
|
||||
shot=shot, task=task, asset=asset,
|
||||
prompt=(segment.visual_prompt if segment is not None else ""), is_adopted=True,
|
||||
)
|
||||
shot.versions.exclude(id=version.id).update(is_adopted=False)
|
||||
StoryboardShot.objects.filter(id=shot.id).update(
|
||||
adopted_version=version, status=StoryboardShot.Status.SUCCEEDED, error_message="", updated_at=timezone.now())
|
||||
# 合规:分镜图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort)
|
||||
from apps.assets.review import submit_asset_for_review
|
||||
|
||||
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
|
||||
except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务+shot 失败供 poll 上报
|
||||
raw = str(exc)
|
||||
public_error = classify_generation_error(
|
||||
exc, operation="storyboard_generate", reference_id=str(task.id)
|
||||
)
|
||||
task.status = AITask.Status.FAILED
|
||||
task.error_message = raw[:2000]
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||
release_credit(reservation=reservation, reason=raw[:200])
|
||||
# 重跑失败时保留旧 adopted_version(画面不丢),只把状态标 FAILED + 友好提示供前端显示
|
||||
StoryboardShot.objects.filter(id=shot.id).update(
|
||||
status=StoryboardShot.Status.FAILED, error_message=public_error.fallback_message, updated_at=timezone.now())
|
||||
# 落一条失败通知,正文带上第三方服务商 API 的原始报错(真因),供用户/排查直接查看
|
||||
notify_generation_failure(
|
||||
task=task, project=project, recipient=user,
|
||||
stage_label=f"故事板·场 {shot.sort_order + 1}", raw=raw, hint=public_error.fallback_message,
|
||||
)
|
||||
finally:
|
||||
connections.close_all() # 释放该线程的 DB 连接
|
||||
|
||||
|
||||
def poll_storyboard(*, project, user) -> dict:
|
||||
"""异步故事板·轮询(秒回):为 QUEUED/在制 shot 起线程出图;报告进度。永不阻塞在 ARK 调用上。
|
||||
返回 {status: generating|succeeded|failed, done, total}。done = 已出片(有采用版且 SUCCEEDED)的场数。"""
|
||||
import threading
|
||||
|
||||
from django.conf import settings as dj_settings
|
||||
|
||||
shots = list(project.storyboard_shots.select_related("script_segment").order_by("sort_order"))
|
||||
if not shots:
|
||||
return {"status": "succeeded", "done": 0, "total": 0}
|
||||
total = len(shots)
|
||||
done = sum(1 for s in shots if s.adopted_version_id is not None and s.status == StoryboardShot.Status.SUCCEEDED)
|
||||
active = [s for s in shots if s.status in (StoryboardShot.Status.QUEUED, StoryboardShot.Status.RUNNING)]
|
||||
if not active:
|
||||
failed = [s for s in shots if s.status == StoryboardShot.Status.FAILED]
|
||||
if failed:
|
||||
return {"status": "failed", "done": done, "total": total, "error": failed[0].error_message or "storyboard shot failed"}
|
||||
return {"status": "succeeded", "done": done, "total": total}
|
||||
|
||||
# 每镜独立「占位锁」:近 N 分钟内有 CREATED/RESERVED/SUBMITTED 任务的 shot = 在生成中(僵尸超时后释放)。
|
||||
STORYBOARD_MAX_PARALLEL = int(getattr(dj_settings, "STORYBOARD_MAX_PARALLEL", 4))
|
||||
stale_minutes = int(getattr(dj_settings, "STORYBOARD_INFLIGHT_STALE_MINUTES", 12))
|
||||
stale_cutoff = timezone.now() - timedelta(minutes=stale_minutes)
|
||||
inflight_shot_ids = {
|
||||
str(v)
|
||||
for v in AITask.objects.filter(
|
||||
project=project, task_type=AITask.Type.STORYBOARD,
|
||||
status__in=[AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED],
|
||||
created_at__gte=stale_cutoff,
|
||||
).values_list("request_payload__storyboard_shot", flat=True)
|
||||
if v
|
||||
}
|
||||
model_config = get_storyboard_image_model()
|
||||
if model_config is None:
|
||||
return {"status": "failed", "done": done, "total": total, "error": "故事板只使用 GPT 图像模型,当前没有启用 gpt-image-2"}
|
||||
extra_prompt = (project.metadata or {}).get("storyboard_prompt", "") or ""
|
||||
spawnable = [s for s in active if str(s.id) not in inflight_shot_ids]
|
||||
slots = max(0, STORYBOARD_MAX_PARALLEL - len(inflight_shot_ids))
|
||||
for shot in spawnable[:slots]:
|
||||
segment = shot.script_segment
|
||||
task = create_ai_task(
|
||||
project=project, user=user, task_type=AITask.Type.STORYBOARD, model_config=model_config,
|
||||
request_payload={
|
||||
"model": model_config.name, "endpoint": model_config.endpoint,
|
||||
"prompt": build_storyboard_frame_prompt(project, segment, extra_prompt) if segment is not None else "",
|
||||
"storyboard_shot": str(shot.id),
|
||||
},
|
||||
)
|
||||
StoryboardShot.objects.filter(id=shot.id).update(status=StoryboardShot.Status.RUNNING, updated_at=timezone.now())
|
||||
threading.Thread(
|
||||
target=_storyboard_shot_worker, args=(str(task.id), str(shot.id), str(user.id)), daemon=True
|
||||
).start()
|
||||
return {"status": "generating", "done": done, "total": total}
|
||||
|
||||
|
||||
def adopt_storyboard_shot_version(*, shot: StoryboardShot, version: StoryboardShotVersion) -> None:
|
||||
"""采用某场的某个历史版本(对标 adopt-video-version):反采用同场其余版本,置该场为采用版+SUCCEEDED。"""
|
||||
shot.versions.exclude(id=version.id).update(is_adopted=False)
|
||||
if not version.is_adopted:
|
||||
version.is_adopted = True
|
||||
version.save(update_fields=["is_adopted", "updated_at"])
|
||||
shot.adopted_version = version
|
||||
shot.status = StoryboardShot.Status.SUCCEEDED
|
||||
shot.error_message = ""
|
||||
shot.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
|
||||
|
||||
|
||||
def _asset_preview_url(asset) -> str:
|
||||
"""资产主文件的可公开访问 URL(已写绝对 URL 优先,否则实时签 TOS GET)。"""
|
||||
if asset is None:
|
||||
@@ -3274,8 +2901,10 @@ def _seedance_ref_url(raw_url: str, review_status: str = "", review_remote_id: s
|
||||
|
||||
|
||||
def _video_reference_images(project, video_segment) -> list[dict]:
|
||||
"""视频参考图(带类型,供 @图N):角色/场景/商品 基础资产 + 本镜故事板帧。
|
||||
顺序:角色 → 场景 → 商品 → 分镜图(与用户钦定 @图1角色@图2场景@图3商品@图4分镜图 一致)。
|
||||
"""视频参考图(带类型,供 @图N):角色 / 场景 / 商品 基础资产。
|
||||
顺序:角色 → 场景 → 商品(与提示词里 @图1角色@图2场景@图3商品 一致)。
|
||||
★ 故事板已从流程中去掉,不再有「分镜图」这一张:导演信息全部走提示词
|
||||
(build_video_segment_prompt),参考图只负责锁脸 / 锁商品 / 锁场景。
|
||||
返回 [{url,label,type}];url 对过审人脸资产为 asset:// 素材库引用。都取不到时兜底商品图。"""
|
||||
out: list[dict] = []
|
||||
scene = None
|
||||
@@ -3283,21 +2912,10 @@ def _video_reference_images(project, video_segment) -> list[dict]:
|
||||
if adopted_script is not None:
|
||||
scene = adopted_script.segments.filter(sort_order=video_segment.sort_order).first()
|
||||
if scene is not None:
|
||||
refs = list(_storyboard_reference_images(project, scene)) # 角色/商品/场景 实体图
|
||||
refs = list(_segment_reference_images(project, scene)) # 角色/商品/场景 实体图
|
||||
_vord = {"character": 0, "scene": 1, "product": 2}
|
||||
refs.sort(key=lambda r: _vord.get(r.get("type"), 9))
|
||||
out = refs
|
||||
# 末位追加本镜故事板分镜图(@图N 末位 = 分镜图):取该 sort_order 的 shot 的采用版资产
|
||||
shot = (
|
||||
project.storyboard_shots.filter(sort_order=video_segment.sort_order, adopted_version__isnull=False).select_related("adopted_version__asset").first()
|
||||
or project.storyboard_shots.filter(adopted_version__isnull=False).select_related("adopted_version__asset").order_by("sort_order").first()
|
||||
)
|
||||
if shot is not None and shot.adopted_version_id:
|
||||
frame_asset = shot.adopted_version.asset
|
||||
url = _asset_preview_url(frame_asset)
|
||||
if url:
|
||||
out.append({"url": url, "label": "分镜图", "type": "storyboard",
|
||||
"review_status": frame_asset.review_status, "review_remote_id": frame_asset.review_remote_id})
|
||||
if not out:
|
||||
product_group = (
|
||||
project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PRODUCT, adopted_asset__isnull=False)
|
||||
@@ -3314,19 +2932,17 @@ def _video_reference_images(project, video_segment) -> list[dict]:
|
||||
|
||||
|
||||
def collect_video_review_blockers(project, only_segment: "VideoSegment | None" = None) -> list[dict]:
|
||||
"""点「生成视频」前的过审闸:列出本次将生成的段里,含真人脸的参考图(人物立绘 + 该镜故事板分镜)
|
||||
中**尚未过审(review_status != 'active')** 的项。火山视频对含真人脸的图,只接受素材库已过审的引用,
|
||||
否则报 InputImageSensitiveContentDetected。这里在调火山前先拦,弹窗指明是哪一镜的哪个人物/分镜未过审。
|
||||
"""点「生成视频」前的过审闸:列出本次将生成的段里,含真人脸的参考图(人物立绘)中
|
||||
**尚未过审(review_status != 'active')** 的项。火山视频对含真人脸的图,只接受素材库已过审的引用,
|
||||
否则报 InputImageSensitiveContentDetected。这里在调火山前先拦,弹窗指明是哪一镜的哪个人物未过审。
|
||||
|
||||
★ 故事板去掉后不再有「分镜图」这一类待审资产,闸口只剩人物立绘。
|
||||
|
||||
only_segment 给定 → 只校验该段(单段重跑);为 None → 校验全部「未出片」段(整批生成)。
|
||||
返回 [{video_segment_id, sort_order, scene_no, kind:'person'|'storyboard', name, asset_id, review_status}];
|
||||
返回 [{video_segment_id, sort_order, scene_no, kind:'person', name, asset_id, review_status}];
|
||||
空列表 = 全部已过审,可放行。"""
|
||||
segs = [only_segment] if only_segment is not None else list(project.video_segments.order_by("sort_order"))
|
||||
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
|
||||
shots_by_order = {
|
||||
s.sort_order: s
|
||||
for s in project.storyboard_shots.filter(adopted_version__isnull=False).select_related("adopted_version__asset")
|
||||
}
|
||||
blockers: list[dict] = []
|
||||
for seg in segs:
|
||||
# 整批生成只校验还没出片的段;单段重跑则无论状态都校验(用户主动要重出这一段)
|
||||
@@ -3336,7 +2952,7 @@ def collect_video_review_blockers(project, only_segment: "VideoSegment | None" =
|
||||
scene = adopted_script.segments.filter(sort_order=seg.sort_order).first() if adopted_script else None
|
||||
# 人物立绘(与视频实际取图同一套逻辑,保证「拦的」就是「会传给火山的」)
|
||||
if scene is not None:
|
||||
for ref in _storyboard_reference_images(project, scene):
|
||||
for ref in _segment_reference_images(project, scene):
|
||||
if ref.get("type") != "character":
|
||||
continue
|
||||
if (ref.get("review_status") or "") != "active":
|
||||
@@ -3345,16 +2961,6 @@ def collect_video_review_blockers(project, only_segment: "VideoSegment | None" =
|
||||
"kind": "person", "name": ref.get("label") or "人物",
|
||||
"asset_id": str(ref.get("asset_id") or ""), "review_status": ref.get("review_status") or "",
|
||||
})
|
||||
# 该镜故事板分镜图(分镜图里也有同一张脸,是另一个需过审的资产)= 对应 shot 的采用版资产
|
||||
shot = shots_by_order.get(seg.sort_order)
|
||||
if shot is not None and shot.adopted_version_id:
|
||||
frame_asset = shot.adopted_version.asset
|
||||
if (frame_asset.review_status or "") != "active":
|
||||
blockers.append({
|
||||
"video_segment_id": str(seg.id), "sort_order": seg.sort_order, "scene_no": scene_no,
|
||||
"kind": "storyboard", "name": f"场{scene_no} 分镜图",
|
||||
"asset_id": str(frame_asset.id), "review_status": frame_asset.review_status or "",
|
||||
})
|
||||
return blockers
|
||||
|
||||
|
||||
|
||||
@@ -437,10 +437,10 @@ class NarrationLimitTests(SimpleTestCase):
|
||||
|
||||
class ShotDensityTests(SimpleTestCase):
|
||||
_VISUAL = (
|
||||
"0-3s:近景,女主对镜头抬眼,工位键盘还亮着,窗外是下午的光\n"
|
||||
"3-8s:手持跟到桌面,右手把茶包放进盛了热水的玻璃杯,水面起蒸汽\n"
|
||||
"8-12s:杯壁特写,茶汤从浅金慢慢变深,标签贴在杯沿\n"
|
||||
"12-15s:拉回中近景,女主喝一口,眼神松下来"
|
||||
"0-3s:近景;平视;固定机位;女主在画面右侧对镜头抬眼,工位键盘还亮着,窗外是下午的光\n"
|
||||
"3-8s:中近景;侧后方过肩;手持跟拍到桌面;右手从画面右侧入画,把茶包放进盛了热水的玻璃杯,水面起蒸汽\n"
|
||||
"8-12s:特写;俯拍杯口;缓慢推近;茶汤从浅金慢慢变深,标签贴在杯沿\n"
|
||||
"12-15s:中近景;平视;拉回;女主双手捧杯喝一口,眉头松开肩膀塌下来"
|
||||
)
|
||||
|
||||
def test_short_oral_narration_is_rejected(self):
|
||||
@@ -493,8 +493,9 @@ class ShotDensityTests(SimpleTestCase):
|
||||
def test_paragraph_visual_without_timestamps_is_rejected(self):
|
||||
narration = "下午三点工位犯困,键盘都敲不利索。我倒了杯茶,第一口是回甘不是苦,整个人才慢慢醒过来。"
|
||||
long_visual = (
|
||||
"近景对镜头,女主从键盘抬眼,端起杯子喝一口,再切杯壁特写,蒸汽从杯口升起,"
|
||||
"茶汤慢慢变深,窗外下午的光打在桌面上,手没有离开杯柄,整段都不写起止秒。"
|
||||
"近景对镜头,平视固定机位,女主从键盘抬眼,端起杯子喝一口,再切杯壁特写,蒸汽从杯口升起,"
|
||||
"茶汤慢慢变深,窗外下午的光打在桌面上,右手始终没有离开杯柄,手持跟拍到桌沿,"
|
||||
"杯垫上还压着一张便签,整段都不写起止秒,也不拆成任何时间段。"
|
||||
)
|
||||
self.assertGreaterEqual(len(long_visual.replace(" ", "")), VISUAL_CHARS_MIN)
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
from decimal import Decimal
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import requests
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider
|
||||
from apps.ai.services import _storyboard_shot_worker, poll_storyboard
|
||||
from apps.assets.models import Asset
|
||||
from apps.billing.models import CreditAccount, CreditLedger
|
||||
from apps.products.models import Product
|
||||
from apps.projects.models import (
|
||||
Project,
|
||||
ScriptSegment,
|
||||
ScriptVersion,
|
||||
StoryboardShot,
|
||||
StoryboardShotVersion,
|
||||
)
|
||||
|
||||
|
||||
def _metadata(*, outbound=True, base_cost="0.50", max_refs=9):
|
||||
return {
|
||||
"routing": {"fallback_on_failure": outbound, "fallback_candidate": True},
|
||||
"capabilities": {
|
||||
"operations": ["image_generate", "image_edit"],
|
||||
"features": [],
|
||||
"reference_modes": ["none", "single", "multiple"],
|
||||
"max_reference_images": max_refs,
|
||||
"aspect_ratios": ["1:1", "3:4", "4:5", "9:16", "16:9"],
|
||||
},
|
||||
"pricing": {"base_cost_yuan": base_cost},
|
||||
}
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORYBOARD_MAX_PARALLEL=4,
|
||||
CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}},
|
||||
)
|
||||
class StoryboardRoutingTests(TestCase):
|
||||
def setUp(self):
|
||||
ModelConfig.objects.filter(capability=ModelConfig.Capability.IMAGE).update(
|
||||
status=ModelConfig.Status.DISABLED
|
||||
)
|
||||
self.user = User.objects.create_user(username="storyboard-routing", password="x")
|
||||
self.team = Team.objects.create(name="Storyboard Routing", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance=Decimal("1000"))
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="测试商品")
|
||||
self.project = Project.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=product,
|
||||
name="故事板路由项目",
|
||||
)
|
||||
script = ScriptVersion.objects.create(
|
||||
project=self.project,
|
||||
title="脚本",
|
||||
content="结构化脚本",
|
||||
is_adopted=True,
|
||||
)
|
||||
self.segment = ScriptSegment.objects.create(
|
||||
script_version=script,
|
||||
sort_order=0,
|
||||
visual_prompt="人物在商品旁展示",
|
||||
)
|
||||
self.shot = StoryboardShot.objects.create(
|
||||
project=self.project,
|
||||
script_segment=self.segment,
|
||||
sort_order=0,
|
||||
status=StoryboardShot.Status.QUEUED,
|
||||
)
|
||||
self.refs = [
|
||||
{"url": "http://example.test/person.png", "label": "女主", "type": "person"},
|
||||
{"url": "http://example.test/product.png", "label": "商品", "type": "product"},
|
||||
]
|
||||
self.provider_mocks = {}
|
||||
patch("apps.ai.services.get_image_provider", side_effect=self._provider_for).start()
|
||||
patch(
|
||||
"apps.ai.services._storyboard_reference_images",
|
||||
side_effect=lambda project, segment: list(self.refs),
|
||||
).start()
|
||||
patch(
|
||||
"apps.ai.services.build_storyboard_frame_prompt_refs",
|
||||
return_value="带参考图编号与锁定约束的故事板提示词",
|
||||
).start()
|
||||
patch(
|
||||
"apps.ai.services.build_storyboard_frame_prompt",
|
||||
return_value="无参考图故事板提示词",
|
||||
).start()
|
||||
patch("apps.ai.services._store_generated_media", side_effect=self._store_media).start()
|
||||
patch("apps.assets.review.submit_asset_for_review").start()
|
||||
patch("apps.ai.services.notify_generation_failure").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def provider(self, name, priority):
|
||||
return ModelProvider.objects.create(
|
||||
name=name,
|
||||
display_name=name,
|
||||
status=ModelProvider.Status.ACTIVE,
|
||||
metadata={"routing": {"fallback_priority": priority}},
|
||||
)
|
||||
|
||||
def model(self, provider, name, *, outbound=True, base_cost="0.50", max_refs=9):
|
||||
return ModelConfig.objects.create(
|
||||
provider=provider,
|
||||
name=name,
|
||||
display_name=name,
|
||||
capability=ModelConfig.Capability.IMAGE,
|
||||
endpoint="images/generations",
|
||||
unit_price=Decimal("20"),
|
||||
status=ModelConfig.Status.ACTIVE,
|
||||
metadata=_metadata(outbound=outbound, base_cost=base_cost, max_refs=max_refs),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _new_provider_mock():
|
||||
provider = Mock()
|
||||
response = {"data": [{"url": "http://example.test/storyboard.png"}]}
|
||||
provider.image_edit.return_value = response
|
||||
provider.image_generation.return_value = response
|
||||
provider.extract_first_media_url.side_effect = lambda value: value["data"][0]["url"]
|
||||
return provider
|
||||
|
||||
@staticmethod
|
||||
def _new_generation_only_provider_mock():
|
||||
provider = Mock(spec=["image_generation", "extract_first_media_url"])
|
||||
provider.image_generation.return_value = {
|
||||
"data": [{"url": "http://example.test/storyboard.png"}]
|
||||
}
|
||||
provider.extract_first_media_url.side_effect = lambda value: value["data"][0]["url"]
|
||||
return provider
|
||||
|
||||
def _provider_for(self, model):
|
||||
return self.provider_mocks.setdefault(model.id, self._new_provider_mock())
|
||||
|
||||
def _store_media(self, **kwargs):
|
||||
return Asset.objects.create(
|
||||
team=kwargs["team"],
|
||||
created_by=kwargs["user"],
|
||||
name=kwargs["name"],
|
||||
asset_type=kwargs["asset_type"],
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=kwargs["category"],
|
||||
origin_task=kwargs["task"],
|
||||
)
|
||||
|
||||
def enqueue(self, *, run=True):
|
||||
with patch("threading.Thread"):
|
||||
result = poll_storyboard(project=self.project, user=self.user)
|
||||
self.assertEqual(result["status"], "generating")
|
||||
task = AITask.objects.filter(project=self.project, task_type=AITask.Type.STORYBOARD).latest(
|
||||
"created_at"
|
||||
)
|
||||
if run:
|
||||
_storyboard_shot_worker(str(task.id), str(self.shot.id), str(self.user.id))
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
def ledger_count(task, ledger_type):
|
||||
return CreditLedger.objects.filter(task=task, ledger_type=ledger_type).count()
|
||||
|
||||
def test_multi_reference_success_records_9_16_and_adopts_one_shot_version(self):
|
||||
primary = self.model(self.provider("storyboard-primary", 20), "gpt-image-2")
|
||||
task = self.enqueue()
|
||||
|
||||
task.refresh_from_db()
|
||||
self.shot.refresh_from_db()
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
self.assertNotIn("model_routing_v1", task.request_payload)
|
||||
self.assertEqual(task.model_config_id, primary.id)
|
||||
self.assertEqual(task.model_config.name, "gpt-image-2")
|
||||
call = self.provider_mocks[primary.id].image_edit.call_args
|
||||
self.assertEqual(
|
||||
call.kwargs["images"],
|
||||
["http://example.test/person.png", "http://example.test/product.png"],
|
||||
)
|
||||
self.assertEqual(call.kwargs["prompt"], "带参考图编号与锁定约束的故事板提示词")
|
||||
self.assertEqual(call.kwargs["size"], "1024x1536")
|
||||
self.assertEqual(call.kwargs["model"], "gpt-image-2")
|
||||
self.assertIsNotNone(self.shot.adopted_version_id)
|
||||
self.assertEqual(self.shot.adopted_version.task_id, task.id)
|
||||
self.assertEqual(self.shot.versions.count(), 1)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 1)
|
||||
|
||||
def test_wizard_aspect_ratio_drives_storyboard_size(self):
|
||||
self.project.metadata = {"wizard": {"aspect_ratio": "16:9", "resolution": "480p"}}
|
||||
self.project.save(update_fields=["metadata"])
|
||||
primary = self.model(self.provider("storyboard-wide", 20), "gpt-image-2")
|
||||
task = self.enqueue()
|
||||
call = self.provider_mocks[primary.id].image_edit.call_args
|
||||
self.assertEqual(call.kwargs["size"], "1536x864")
|
||||
|
||||
def test_gpt_failure_does_not_fallback_to_seedream(self):
|
||||
primary = self.model(
|
||||
self.provider("storyboard-gpt", 100),
|
||||
"gpt-image-2",
|
||||
base_cost="0.25",
|
||||
)
|
||||
seedream = self.model(
|
||||
self.provider("storyboard-volcano-fallback", 10),
|
||||
"seedream-4-5-251128",
|
||||
outbound=False,
|
||||
base_cost="0.75",
|
||||
)
|
||||
self.provider_mocks[primary.id] = self._new_provider_mock()
|
||||
self.provider_mocks[primary.id].image_edit.side_effect = requests.ConnectionError("offline")
|
||||
self.provider_mocks[seedream.id] = self._new_provider_mock()
|
||||
task = self.enqueue()
|
||||
|
||||
task.refresh_from_db()
|
||||
self.assertEqual(task.status, AITask.Status.FAILED)
|
||||
self.assertEqual(task.model_config_id, primary.id)
|
||||
self.provider_mocks[seedream.id].image_edit.assert_not_called()
|
||||
self.provider_mocks[seedream.id].image_generation.assert_not_called()
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 0)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 1)
|
||||
|
||||
def test_all_candidates_fail_releases_and_keeps_old_adopted_version(self):
|
||||
primary = self.model(self.provider("storyboard-all-primary", 100), "gpt-image-2")
|
||||
self.model(self.provider("storyboard-volcano-keep", 10), "seedream-4-5-251128", outbound=False)
|
||||
old_asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="旧故事板",
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.STORYBOARD,
|
||||
)
|
||||
old_version = StoryboardShotVersion.objects.create(
|
||||
shot=self.shot,
|
||||
asset=old_asset,
|
||||
prompt="旧画面",
|
||||
is_adopted=True,
|
||||
)
|
||||
self.shot.adopted_version = old_version
|
||||
self.shot.save(update_fields=["adopted_version", "updated_at"])
|
||||
self.provider_mocks[primary.id] = self._new_provider_mock()
|
||||
self.provider_mocks[primary.id].image_edit.side_effect = requests.ConnectionError("offline")
|
||||
|
||||
task = self.enqueue()
|
||||
|
||||
task.refresh_from_db()
|
||||
self.shot.refresh_from_db()
|
||||
self.assertEqual(task.status, AITask.Status.FAILED)
|
||||
self.assertEqual(self.shot.adopted_version_id, old_version.id)
|
||||
self.assertEqual(self.shot.versions.count(), 1)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 0)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 1)
|
||||
self.assertEqual(CreditAccount.objects.get(team=self.team).reserved_balance, Decimal("0"))
|
||||
|
||||
def test_no_reference_uses_image_generation(self):
|
||||
primary = self.model(self.provider("storyboard-no-ref", 20), "gpt-image-2")
|
||||
self.refs = []
|
||||
task = self.enqueue()
|
||||
|
||||
task.refresh_from_db()
|
||||
self.provider_mocks[primary.id].image_edit.assert_not_called()
|
||||
call = self.provider_mocks[primary.id].image_generation.call_args
|
||||
self.assertEqual(call.kwargs["prompt"], "无参考图故事板提示词")
|
||||
self.assertEqual(call.kwargs["model"], "gpt-image-2")
|
||||
|
||||
def test_seedream_is_ignored_even_if_it_is_the_default_image_model(self):
|
||||
seedream = self.model(
|
||||
self.provider("storyboard-volcano-default", 10), "seedream-4-5-251128", outbound=False
|
||||
)
|
||||
gpt = self.model(self.provider("storyboard-gpt-default", 20), "gpt-image-2")
|
||||
seedream.is_default = True
|
||||
seedream.save(update_fields=["is_default"])
|
||||
self.provider_mocks[seedream.id] = self._new_generation_only_provider_mock()
|
||||
task = self.enqueue()
|
||||
|
||||
task.refresh_from_db()
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
self.assertEqual(task.model_config_id, gpt.id)
|
||||
self.provider_mocks[seedream.id].image_generation.assert_not_called()
|
||||
self.provider_mocks[gpt.id].image_edit.assert_called_once()
|
||||
|
||||
def test_poll_creates_one_independent_task_and_reservation_per_shot(self):
|
||||
gpt = self.model(self.provider("storyboard-batch", 20), "gpt-image-2")
|
||||
second_segment = ScriptSegment.objects.create(
|
||||
script_version=self.segment.script_version,
|
||||
sort_order=1,
|
||||
visual_prompt="第二镜",
|
||||
)
|
||||
second_shot = StoryboardShot.objects.create(
|
||||
project=self.project,
|
||||
script_segment=second_segment,
|
||||
sort_order=1,
|
||||
status=StoryboardShot.Status.QUEUED,
|
||||
)
|
||||
|
||||
with patch("threading.Thread"):
|
||||
result = poll_storyboard(project=self.project, user=self.user)
|
||||
|
||||
self.assertEqual(result, {"status": "generating", "done": 0, "total": 2})
|
||||
tasks = list(
|
||||
AITask.objects.filter(project=self.project, task_type=AITask.Type.STORYBOARD).order_by(
|
||||
"created_at"
|
||||
)
|
||||
)
|
||||
self.assertEqual(len(tasks), 2)
|
||||
self.assertEqual(
|
||||
{task.request_payload["storyboard_shot"] for task in tasks},
|
||||
{str(self.shot.id), str(second_shot.id)},
|
||||
)
|
||||
self.assertTrue(all("model_routing_v1" not in task.request_payload for task in tasks))
|
||||
self.assertTrue(all(task.model_config_id == gpt.id for task in tasks))
|
||||
self.assertTrue(all(self.ledger_count(task, CreditLedger.Type.RESERVE) == 1 for task in tasks))
|
||||
Reference in New Issue
Block a user