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:
co-authored by
Claude Opus 4.8
parent
304a083184
commit
9c2a3bc365
@@ -3,7 +3,7 @@ from decimal import Decimal
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider, QualityWord
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
|
||||
from apps.assets.models import Asset
|
||||
from apps.billing.models import CreditLedger, QuotaPolicy
|
||||
from apps.projects.models import Project
|
||||
@@ -25,6 +25,30 @@ class QualityWordSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ["id", "created_at"]
|
||||
|
||||
|
||||
# 每条提示词可用的 {占位符}(给 admin 提示;运行时由对应 builder 注入,改错会原样保留不致命)
|
||||
PROMPT_PLACEHOLDERS = {
|
||||
"person_portrait": ["描述"],
|
||||
"person_triview": [],
|
||||
"product_triview": ["商品", "补充"],
|
||||
"scene": ["场景描述"],
|
||||
"storyboard_frame": ["设定", "场景上下文", "时长", "脚本", "补充"],
|
||||
"video_segment": ["设定", "分镜", "脚本", "时长"],
|
||||
}
|
||||
|
||||
|
||||
class PromptTemplateSerializer(serializers.ModelSerializer):
|
||||
label = serializers.CharField(source="get_key_display", read_only=True)
|
||||
placeholders = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = PromptTemplate
|
||||
fields = ["id", "key", "label", "template", "ratio", "enabled", "placeholders", "updated_at"]
|
||||
read_only_fields = ["id", "key", "label", "placeholders", "updated_at"] # key 固定,只改正文/比例/启用
|
||||
|
||||
def get_placeholders(self, obj) -> list:
|
||||
return PROMPT_PLACEHOLDERS.get(obj.key, [])
|
||||
|
||||
|
||||
class AdminReviewAssetSerializer(serializers.ModelSerializer):
|
||||
"""跨团队人像审核队列行:复用 AssetFileSerializer 的首图 preview_url。"""
|
||||
|
||||
|
||||
@@ -210,21 +210,24 @@ class AdminQualityWordTests(TestCase):
|
||||
r = self.ac.post("/api/admin/quality-words/", {"stage": "bogus", "text": "x"}, format="json")
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_generation_reads_config_with_fallback(self):
|
||||
from apps.ai.models import QualityWord
|
||||
def test_person_prompt_reads_template_with_fallback(self):
|
||||
"""人物立绘提示词改由「提示词模板」(person_portrait)驱动,取代旧质量词:
|
||||
种子模板 = 原默认正文(描述填进 {描述});改模板 → 生成立即用新正文。"""
|
||||
from apps.ai.models import PromptTemplate
|
||||
from apps.ai.services import build_person_frontal_prompt
|
||||
|
||||
# 无配置 → 回落写死值
|
||||
self.assertIn("纯色背景", build_person_frontal_prompt("小姐姐"))
|
||||
|
||||
# 配置后 → 生成用新词,描述仍保留
|
||||
QualityWord.objects.create(stage="person", text="赛博朋克风", sort=0)
|
||||
QualityWord.objects.create(stage="person", text="霓虹光", sort=1)
|
||||
# 种子模板 → 含默认尾串,{描述} 被填入
|
||||
out = build_person_frontal_prompt("小姐姐")
|
||||
self.assertIn("赛博朋克风", out)
|
||||
self.assertIn("霓虹光", out)
|
||||
self.assertIn("纯色背景", out)
|
||||
self.assertIn("小姐姐", out)
|
||||
self.assertNotIn("纯色背景", out)
|
||||
|
||||
# 后台改模板 → 生成用新正文,{描述} 仍替换
|
||||
PromptTemplate.objects.filter(key="person_portrait").update(template="赛博朋克风{描述}霓虹光")
|
||||
out2 = build_person_frontal_prompt("小姐姐")
|
||||
self.assertIn("赛博朋克风", out2)
|
||||
self.assertIn("霓虹光", out2)
|
||||
self.assertIn("小姐姐", out2)
|
||||
self.assertNotIn("纯色背景", out2)
|
||||
|
||||
def test_disabled_word_not_used(self):
|
||||
from apps.ai.models import QualityWord
|
||||
@@ -236,6 +239,32 @@ class AdminQualityWordTests(TestCase):
|
||||
self.assertIn("启用词", words)
|
||||
self.assertNotIn("停用词", words)
|
||||
|
||||
def test_prompt_templates_list_update_permission(self):
|
||||
"""提示词模板:列表(6 条,带 label/placeholders)+ 改正文比例 + 权限 + 审计 + 生成侧立即生效。"""
|
||||
from apps.ai.services import build_product_triview_prompt_refs
|
||||
|
||||
self.assertEqual(self.nc.get("/api/admin/prompt-templates/").status_code, 403)
|
||||
lst = self.ac.get("/api/admin/prompt-templates/")
|
||||
self.assertEqual(lst.status_code, 200)
|
||||
self.assertEqual(len(lst.data), 6) # 视频线 6 条
|
||||
row = next(r for r in lst.data if r["key"] == "product_triview")
|
||||
self.assertEqual(row["label"], "商品三视图")
|
||||
self.assertIn("商品", row["placeholders"])
|
||||
|
||||
up = self.ac.patch(
|
||||
f"/api/admin/prompt-templates/{row['id']}/",
|
||||
{"template": "参考图1是「{商品}」是商品。生成三视图。", "ratio": "portrait"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(up.status_code, 200)
|
||||
self.assertTrue(AdminAuditLog.objects.filter(action="prompt_template.update").exists())
|
||||
|
||||
class P:
|
||||
title = "杂鱼煲"
|
||||
|
||||
out = build_product_triview_prompt_refs(P(), "")
|
||||
self.assertEqual(out, "参考图1是「杂鱼煲」是商品。生成三视图。") # 生成侧立即用新模板,且 {商品} 已替换
|
||||
|
||||
|
||||
class AdminAssetReviewTests(TestCase):
|
||||
"""Phase 5:跨团队人像审核队列(列/筛/批量送审/轮询)+ 权限。火山调用全 mock。"""
|
||||
|
||||
@@ -19,6 +19,8 @@ from .views import (
|
||||
admin_task_detail,
|
||||
admin_task_retry,
|
||||
admin_tasks,
|
||||
admin_prompt_template_detail,
|
||||
admin_prompt_templates,
|
||||
admin_quality_word_detail,
|
||||
admin_quality_words,
|
||||
admin_revoke_invitation,
|
||||
@@ -42,6 +44,8 @@ urlpatterns = [
|
||||
path("users/<uuid:user_id>/reset-password/", admin_user_reset_password, name="admin-user-reset-password"),
|
||||
path("quality-words/", admin_quality_words, name="admin-quality-words"),
|
||||
path("quality-words/<uuid:word_id>/", admin_quality_word_detail, name="admin-quality-word-detail"),
|
||||
path("prompt-templates/", admin_prompt_templates, name="admin-prompt-templates"),
|
||||
path("prompt-templates/<uuid:template_id>/", admin_prompt_template_detail, name="admin-prompt-template-detail"),
|
||||
path("asset-reviews/", admin_asset_reviews, name="admin-asset-reviews"),
|
||||
path("asset-reviews/submit/", admin_asset_reviews_submit, name="admin-asset-reviews-submit"),
|
||||
path("asset-reviews/poll/", admin_asset_reviews_poll, name="admin-asset-reviews-poll"),
|
||||
|
||||
@@ -12,7 +12,7 @@ from apps.accounts.audit import log_admin_action
|
||||
from apps.accounts.models import Invitation, Team, User
|
||||
from apps.accounts.permissions import IsPlatformAdmin
|
||||
from apps.accounts.serializers import InvitationSerializer
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider, QualityWord
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
|
||||
from apps.assets.models import Asset
|
||||
from apps.assets.review import poll_asset_review, submit_asset_for_review
|
||||
from apps.billing.models import CreditLedger, QuotaPolicy
|
||||
@@ -34,6 +34,7 @@ from .serializers import (
|
||||
AdminTeamMemberSerializer,
|
||||
AdminTeamSerializer,
|
||||
AdminUserSerializer,
|
||||
PromptTemplateSerializer,
|
||||
QualityWordSerializer,
|
||||
)
|
||||
|
||||
@@ -281,6 +282,40 @@ def admin_quality_word_detail(request, word_id):
|
||||
return Response(QualityWordSerializer(obj).data)
|
||||
|
||||
|
||||
# ─────────────────────────── 生图/视频 提示词模板 ───────────────────────────
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_prompt_templates(request):
|
||||
"""列视频线 6 条提示词模板(固定 key,数量小不分页,按 key 排序)。"""
|
||||
qs = PromptTemplate.objects.all().order_by("key")
|
||||
return Response(PromptTemplateSerializer(qs, many=True).data)
|
||||
|
||||
|
||||
@api_view(["PATCH"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_prompt_template_detail(request, template_id):
|
||||
"""改一条提示词模板的正文 / 比例 / 启用(key 固定不可改,不增不删)。"""
|
||||
obj = PromptTemplate.objects.filter(id=template_id).first()
|
||||
if obj is None:
|
||||
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
before = {"template": obj.template, "ratio": obj.ratio, "enabled": obj.enabled}
|
||||
serializer = PromptTemplateSerializer(obj, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
log_admin_action(
|
||||
request,
|
||||
"prompt_template.update",
|
||||
target_type="prompt_template",
|
||||
target_id=obj.id,
|
||||
target_name=obj.key,
|
||||
before=before,
|
||||
after={"template": obj.template, "ratio": obj.ratio, "enabled": obj.enabled},
|
||||
)
|
||||
return Response(PromptTemplateSerializer(obj).data)
|
||||
|
||||
|
||||
# ─────────────────────────── 火山人像审核队列 ───────────────────────────
|
||||
|
||||
_REVIEW_STATUSES = {"", "processing", "active", "failed"}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.1.15 on 2026-06-22 18:53
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai', '0011_aitask_entity_extraction_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PromptTemplate',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('key', models.CharField(choices=[('person_portrait', '人物立绘'), ('person_triview', '人物三视图'), ('product_triview', '商品三视图'), ('scene', '场景图'), ('storyboard_frame', '分镜图'), ('video_segment', '视频')], max_length=32, unique=True)),
|
||||
('template', models.TextField(blank=True, default='')),
|
||||
('ratio', models.CharField(blank=True, default='', max_length=12)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['key'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
# 种入视频线 6 条提示词模板的默认值(= 各 builder 现在的写死文本,零回归)。
|
||||
# 后台超管在 admin「提示词」页改正文/比例;留空或停用 → builder 回落写死默认。
|
||||
from django.db import migrations
|
||||
|
||||
# key, 默认正文(含 {占位符}), 默认比例("portrait" 竖 / "landscape" 横)
|
||||
SEED = [
|
||||
(
|
||||
"person_portrait",
|
||||
"电商真人模特,氛围正面全身照,{描述},自然妆容,柔和影棚光,真实质感,单人,纯色背景",
|
||||
"portrait",
|
||||
),
|
||||
(
|
||||
"person_triview",
|
||||
"参考图1角色,生成角色三视图,从左往右依次为:胸像特写,全身正面,全身侧面,全身背面,白色背景",
|
||||
"landscape",
|
||||
),
|
||||
(
|
||||
"product_triview",
|
||||
"参考图1是「{商品}」的真实商品主图。请严格参照该图的包装外形、品牌文字、配色、Logo 与材质,"
|
||||
"生成同一件商品的三视图:从左到右依次为正面、侧面、背面,统一光照,纯白背景,16:9 构图。"
|
||||
"三个视图必须是同一件商品,品牌字样/配色/外形高度一致,不要改动或重新设计包装。{补充}",
|
||||
"landscape",
|
||||
),
|
||||
(
|
||||
"scene",
|
||||
"{场景描述}",
|
||||
"landscape",
|
||||
),
|
||||
(
|
||||
"storyboard_frame",
|
||||
"{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n"
|
||||
"【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n"
|
||||
"请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;"
|
||||
"电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。{补充}",
|
||||
"portrait",
|
||||
),
|
||||
(
|
||||
"video_segment",
|
||||
"{设定}{分镜}【脚本】{脚本}\n{时长}s · 9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感。",
|
||||
"portrait",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
PromptTemplate = apps.get_model("ai", "PromptTemplate")
|
||||
for key, template, ratio in SEED:
|
||||
PromptTemplate.objects.update_or_create(
|
||||
key=key, defaults={"template": template, "ratio": ratio, "enabled": True}
|
||||
)
|
||||
|
||||
|
||||
def unseed(apps, schema_editor):
|
||||
PromptTemplate = apps.get_model("ai", "PromptTemplate")
|
||||
PromptTemplate.objects.filter(key__in=[k for k, _, _ in SEED]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("ai", "0012_prompttemplate")]
|
||||
operations = [migrations.RunPython(seed, unseed)]
|
||||
@@ -135,3 +135,29 @@ class QualityWord(TimeStampedModel):
|
||||
def __str__(self) -> str:
|
||||
return f"{self.stage}/{self.slot}/{self.text}"
|
||||
|
||||
|
||||
class PromptTemplate(TimeStampedModel):
|
||||
"""平台可编辑的「生图/视频提示词模板」(视频项目生产线 6 条)。后台超管在 admin 改正文,
|
||||
生成侧据 key 读取本表;留空/停用 → 回落各 builder 的写死默认(零回归)。
|
||||
正文里 {占位符}(如 {商品}/{描述}/{脚本})运行时替换;未知占位符原样保留,改错不致命。
|
||||
ratio:成品比例(空=按写死默认),gpt-image 仅 1:1 / portrait(竖) / landscape(横)三种。"""
|
||||
|
||||
class Key(models.TextChoices):
|
||||
PERSON_PORTRAIT = "person_portrait", "人物立绘"
|
||||
PERSON_TRIVIEW = "person_triview", "人物三视图"
|
||||
PRODUCT_TRIVIEW = "product_triview", "商品三视图"
|
||||
SCENE = "scene", "场景图"
|
||||
STORYBOARD_FRAME = "storyboard_frame", "分镜图"
|
||||
VIDEO_SEGMENT = "video_segment", "视频"
|
||||
|
||||
key = models.CharField(max_length=32, choices=Key.choices, unique=True)
|
||||
template = models.TextField(blank=True, default="")
|
||||
ratio = models.CharField(max_length=12, blank=True, default="") # "" / "1:1" / "portrait" / "landscape"
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["key"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"prompt:{self.key}"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user