feat(prompts): admin「提示词」页 —— 视频线 6 条生图/视频提示词正文+比例可在后台改,不再写死

诉求:生图/视频提示词全写死在代码里,产品方看不到也改不了(之前那版只做成「质量后缀」不是正文)。
现新增可编辑「提示词模板」体系,覆盖视频项目生产线 6 条:人物立绘 / 人物三视图 / 商品三视图 /
场景图 / 分镜图 / 视频。

后端:
- 新模型 PromptTemplate(key 固定 6 条 + template 正文 + ratio 比例 + enabled);迁移 0012/0013
  种入默认正文(= 各 builder 现写死文本,零回归)。
- render_prompt(key, default, **字段):读 DB 模板,留空/停用回落默认;安全替换 {占位符}(中文名),
  未知占位符原样保留(改错不致命)。prompt_ratio_size:比例→gpt-image 尺寸。
- 6 个 builder/调用点改走模板;5 条图比例可改(gpt-image 仅 1:1/竖/横),视频固定 Seedance 9:16。
  场景图新增可编辑「场景风格」包装(默认仅 {场景描述},零回归)。人物/分镜/视频原质量后缀并入模板。
- adminpanel CRUD:GET /prompt-templates、PATCH /<id>(改正文/比例/启用,key 不可改、不增删,带审计)。

前端:admin 新增「提示词」侧栏页(/admin/prompts):6 卡,每卡=占位符提示+正文文本框+比例按钮+
启用开关+保存。复用 .btn/.card-hard 共享类,仅 token 配色,单橙锚点。

验证:Django 套件 102 全绿(含新模型/API/生成侧立即生效 + 人物提示词改读模板);tsc+vite build 绿;
无头(独立 sqlite 栈)真开 /admin/prompts:6 卡齐、文本框/比例/占位符齐、0 控制台错误。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-23 03:19:07 +08:00
co-authored by Claude Opus 4.8
parent 304a083184
commit 9c2a3bc365
13 changed files with 485 additions and 64 deletions
+97 -51
View File
@@ -762,18 +762,46 @@ def quality_suffix(stage: str, fallback: str, slot: str = "quality", sep: str =
return sep.join(words) if words else fallback
_PROMPT_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}", re.UNICODE)
_RATIO_TO_SIZE = {"1:1": "1024x1024", "portrait": "1024x1536", "landscape": "1536x1024"}
def _prompt_template_row(key: str):
"""读 admin 可编辑的提示词模板行(启用的);缺表/缺行/异常一律返回 None(回落写死默认,零回归)。"""
try:
from apps.ai.models import PromptTemplate
return PromptTemplate.objects.filter(key=key, enabled=True).first()
except Exception: # noqa: BLE001 — 表还没迁移/查询异常都不该挡生成
return None
def render_prompt(key: str, default_text: str, **fields) -> str:
"""按 admin 可编辑模板渲染提示词。无模板/停用/正文空 → 回落 default_text(各 builder 的写死值)。
安全替换 {占位符}:已知字段换值,未知占位符**原样保留**(用户改错也不会让生成崩)。占位符名支持中文。"""
row = _prompt_template_row(key)
tpl = row.template if (row and (row.template or "").strip()) else default_text
return _PROMPT_PLACEHOLDER_RE.sub(lambda m: str(fields.get(m.group(1), m.group(0))), tpl)
def prompt_ratio_size(key: str, default_size: str) -> str:
"""模板配的比例 → gpt-image 尺寸(1024x1024 / 1024x1536 / 1536x1024);未配则用 default_size。"""
row = _prompt_template_row(key)
if row and row.ratio:
return _RATIO_TO_SIZE.get(row.ratio, default_size)
return default_size
def build_product_triview_prompt_refs(product, base_prompt: str = "") -> str:
"""商品三视图 image_edit 提示词(refs 版):参考图1=商品真实主图,锁包装一致性。"""
"""商品三视图 image_edit 提示词(refs 版):参考图1=商品真实主图。正文可在 admin「提示词」页改;
{商品}=主图商品名(写死字段,不可删),{补充}=调用方附加文本。"""
name = (getattr(product, "title", "") or "商品").strip()
lines = [
f"参考图1是「{name}」的真实商品主图。",
"请严格参照该图的包装外形、品牌文字、配色、Logo 与材质,生成同一件商品的三视图:",
"从左到右依次为正面、侧面、背面,统一光照,纯白背景,16:9 构图。",
"三个视图必须是同一件商品,品牌字样/配色/外形高度一致,不要改动或重新设计包装。",
]
if base_prompt and base_prompt.strip():
lines.append(base_prompt.strip())
return " ".join(lines)
default = (
"参考图1是「{商品}」的真实商品主图。请严格参照该图的包装外形、品牌文字、配色、Logo 与材质,"
"生成同一件商品的三视图:从左到右依次为正面、侧面、背面,统一光照,纯白背景,16:9 构图。"
"三个视图必须是同一件商品,品牌字样/配色/外形高度一致,不要改动或重新设计包装。{补充}"
)
return render_prompt("product_triview", default, 商品=name, 补充=(base_prompt or "").strip())
def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "") -> str:
@@ -824,9 +852,9 @@ def build_person_frontal_prompt(description: str = "") -> str:
"""人物正面氛围图提示词:把脚本提取(或用户输入)的人物描述包成统一模板。
用户钦定格式:电商真人模特,氛围正面全身照,<描述>,自然妆容,柔和影棚光,真实质感,单人,纯色背景。"""
desc = (description or "").strip()
inner = f"{desc}" if desc else ""
tail = quality_suffix("person", "自然妆容,柔和影棚光,真实质感,单人,纯色背景")
return f"电商真人模特,氛围正面全身照,{inner}{tail}"
# 正文可在 admin「提示词」页改;{描述}=脚本提取/用户输入的人物描述(写死字段)。默认含原质量尾串。
default = "电商真人模特,氛围正面全身照,{描述}自然妆容,柔和影棚光,真实质感,单人,纯色背景"
return render_prompt("person_portrait", default, 描述=desc)
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask:
@@ -850,7 +878,8 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "
elif kind == BaseAssetGroup.Kind.PERSON:
gen_prompt = build_person_frontal_prompt(prompt) # 人物立绘:包成「电商真人模特/正面全身/纯色背景」统一模板
else:
gen_prompt = prompt
# 场景图:默认模板只是 {场景描述} 本身(零回归);可在 admin「提示词」页加全局场景风格
gen_prompt = render_prompt("scene", "{场景描述}", 场景描述=(prompt or "").strip())
payload = {
"model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt,
"kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "",
@@ -891,10 +920,15 @@ def run_base_asset_task(*, task_id: str) -> None:
reservation = task.credit_reservation
try:
if use_edit and ref_url:
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size="1536x1024")
# 商品三视图:比例可在 admin 改(默认横)
size = prompt_ratio_size("product_triview", "1536x1024")
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=size)
else:
# 场景 = 16:9 横构图(环境/背景空镜);人物立绘 = 9:16 竖全身。默认竖。
gen_size = "1536x1024" if kind == BaseAssetGroup.Kind.SCENE else "1024x1536"
# 场景默认横、人物立绘默认竖;两者比例都可在 admin 改
if kind == BaseAssetGroup.Kind.SCENE:
gen_size = prompt_ratio_size("scene", "1536x1024")
else:
gen_size = prompt_ratio_size("person_portrait", "1024x1536")
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt, size=gen_size)
media = provider.extract_first_media_url(response)
with transaction.atomic():
@@ -959,7 +993,9 @@ def generate_person_triview(*, project, user, portrait_asset) -> AITask:
if not hasattr(provider, "image_edit"):
raise ValueError(f"当前图像模型 {model_config.provider.name}:{model_config.name} 不支持参考图三视图(image_edit)")
ref_url = _asset_preview_url(portrait_asset)
payload = {"model": model_config.name, "prompt": THREE_VIEW_PROMPT, "kind": "person", "triview_of": asset_key, "reference_image": ref_url}
# 人物三视图提示词:正文可在 admin「提示词」页改(无占位符)
tri_prompt = render_prompt("person_triview", THREE_VIEW_PROMPT)
payload = {"model": model_config.name, "prompt": tri_prompt, "kind": "person", "triview_of": asset_key, "reference_image": ref_url}
task = create_ai_task(project=project, user=user, task_type=AITask.Type.PERSON_IMAGE, model_config=model_config, request_payload=payload)
generate_triview_task.delay(str(task.id))
return task
@@ -983,7 +1019,8 @@ def run_triview_task(*, task_id: str) -> None:
provider = get_image_provider(model_config)
reservation = task.credit_reservation
try:
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size="1536x1024")
tri_size = prompt_ratio_size("person_triview", "1536x1024")
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=tri_size)
media = provider.extract_first_media_url(response)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
@@ -1067,18 +1104,23 @@ def _segment_script_text(segment) -> str:
def build_storyboard_frame_prompt(project, version, segment) -> str:
"""单镜导演故事板提示词(一镜 = 一张导演故事板,用户钦定结构)。无参考图时的文本版。"""
"""单镜导演故事板提示词(无参考图时的文本版)。与 refs 版共用 admin「提示词·分镜图」模板(设定为空)"""
dur = segment.duration_seconds or 15
lines = [
"根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。",
_scene_context(project),
f"【分镜脚本】(本段时长约 {dur} 秒)",
_segment_script_text(segment) or f"{segment.sort_order + 1}",
]
if version.prompt:
lines.append(version.prompt.strip())
lines.append(quality_suffix("storyboard", "电商竖屏 9:16 导演故事板,真实清新、生活化,画面清晰,可直接指导视频生成。"))
return "\n".join(line for line in lines if line)
default = (
"{设定}根据以下脚本生成一个导演故事板用于指导 seedance 的视频生成。\n{场景上下文}\n"
"【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n"
"请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;"
"电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。{补充}"
)
rendered = render_prompt(
"storyboard_frame", default,
设定="",
场景上下文=_scene_context(project),
时长=dur,
脚本=(_segment_script_text(segment) or f"{segment.sort_order + 1}"),
补充=(("\n" + version.prompt.strip()) if version.prompt else ""),
)
return "\n".join(line for line in rendered.split("\n") if line.strip())
def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: str = "") -> str:
@@ -1094,21 +1136,20 @@ def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt:
storyboard_idx = n
else:
setup_parts.append(f"@图{n}{r.get('label') or ''}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})")
lines = []
if setup_parts:
lines.append("【设定】" + "".join(setup_parts) + "")
if storyboard_idx is not None:
lines.append(f"【分镜】根据@图{storyboard_idx}分镜图生成「{product_name}」短视频。")
script_text = _segment_script_text(scene) 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
lines.append("【脚本】" + (script_text or f"{video_segment.sort_order + 1}"))
lines.append(
f"{video_segment.target_duration_seconds}s · "
+ quality_suffix("video", "9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感。")
# 正文可在 admin「提示词·视频」页改。占位符:{设定}=@图N点名、{分镜}=分镜图引用、{脚本}=本段脚本、{时长}。
default = "{设定}{分镜}【脚本】{脚本}\n{时长}s · 9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感。"
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 ""),
脚本=(script_text or f"{video_segment.sort_order + 1}"),
时长=video_segment.target_duration_seconds,
)
return "\n".join(line for line in lines if line)
return "\n".join(line for line in rendered.split("\n") if line.strip())
def submit_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion:
@@ -1223,17 +1264,22 @@ def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dic
f"@图{i + 1}{r['label']}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})" for i, r in enumerate(refs)
)
dur = segment.duration_seconds or 15
lines = [
f"【设定】{setup}",
"根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。",
_scene_context(project),
f"【分镜脚本】(本段时长约 {dur} 秒)",
_segment_script_text(segment) or f"{segment.sort_order + 1}",
"请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。",
]
if version.prompt:
lines.append(version.prompt.strip())
return "\n".join(line for line in lines if line)
# 正文可在 admin「提示词」页改。占位符:{设定}=@图N点名、{场景上下文}、{时长}、{脚本}=本镜脚本、{补充}=本版附加。
default = (
"{设定}根据以下脚本生成一个导演故事板用于指导 seedance 的视频生成。\n{场景上下文}\n"
"【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n"
"请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;"
"电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。{补充}"
)
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" + version.prompt.strip()) if version.prompt else ""),
)
return "\n".join(line for line in rendered.split("\n") if line.strip())
def _is_transient_error(exc: Exception) -> bool: