feat(ai): 实体提取解耦 — 定稿剧本后独立提取角色/场景 + 商品参考图永远带上
根因:实体跟脚本生成耦合,模型不稳(尤其改稿 revise)常丢实体 → 角色提不出、 参考图全空 → 故事板/视频退化纯文生图(用户 demo 全错的真因)。 - 新 skill ecommerce-entity-extract(按 skill-creator 法写):只提角色+场景, 角色铁律=穿整套衣服/空手/不带与商品同类物/不暴露;商品不提。4 项目实测稳定产出。 - extract_entities_for_project + /extract-entities 端点:读定稿剧本→提实体→ 落库覆盖 metadata + 回填每镜 entity_refs(提取为唯一权威来源,脚本生成完全不动)。 - 商品参考图永远带上:_storyboard_reference_images(视频继承)改为无条件带 商品三视图→主图兜底,不再依赖 entity_refs 里有没有商品实体。 - 创建商品后端强制要主图(堵兜底洞:无图商品会让下游参考图彻底落空)。 实测:杂鱼煲(原实体0)→6实体,每镜参考图齐(角色+商品+场景),不再文生图。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
901d6920f1
commit
827fa7fa54
@@ -221,6 +221,189 @@ def extract_cast_and_scenes(*, project, user, content: str) -> dict:
|
|||||||
return empty
|
return empty
|
||||||
|
|
||||||
|
|
||||||
|
def _load_skill_system_prompt(name: str) -> str:
|
||||||
|
"""读取 skills/<name>/SKILL.md + references/*.md 拼成系统提示词(领域知识)。缺文件返回空串,不致命。"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
skill_dir = Path(settings.BASE_DIR).parent.parent / "skills" / name
|
||||||
|
parts: list[str] = []
|
||||||
|
main = skill_dir / "SKILL.md"
|
||||||
|
if main.exists():
|
||||||
|
parts.append(main.read_text(encoding="utf-8"))
|
||||||
|
ref_dir = skill_dir / "references"
|
||||||
|
if ref_dir.exists():
|
||||||
|
for ref in sorted(ref_dir.glob("*.md")):
|
||||||
|
parts.append(f"\n\n===== references/{ref.name} =====\n\n{ref.read_text(encoding='utf-8')}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_extracted_entities(items: object) -> list[dict]:
|
||||||
|
"""整理提取出的 entities:只留 character/scene(绝不收 product),保留模型给的 id(供 segment refs 对应),
|
||||||
|
按 id 去重,角色/场景各最多 6 个,补 ref_index。"""
|
||||||
|
out: list[dict] = []
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return out
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
n_char = n_scene = 0
|
||||||
|
for it in items:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
continue
|
||||||
|
typ = str(it.get("type") or "").strip()
|
||||||
|
if typ not in ("character", "scene"): # 丢弃 product / 未知类型
|
||||||
|
continue
|
||||||
|
name = str(it.get("name") or "").strip()
|
||||||
|
eid = str(it.get("id") or "").strip()
|
||||||
|
if not name or not eid or eid in seen_ids:
|
||||||
|
continue
|
||||||
|
if typ == "character":
|
||||||
|
n_char += 1
|
||||||
|
if n_char > 6:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
n_scene += 1
|
||||||
|
if n_scene > 6:
|
||||||
|
continue
|
||||||
|
seen_ids.add(eid)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": eid,
|
||||||
|
"type": typ,
|
||||||
|
"name": name,
|
||||||
|
"visual_prompt": str(it.get("visual_prompt") or "").strip(),
|
||||||
|
"ref_index": len(out) + 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_extracted_segment_refs(items: object, valid_ids: set[str]) -> list[dict]:
|
||||||
|
"""整理每镜 refs:只留指向存活实体(角色/场景)的 id,丢掉被剔除的(如商品/无效 id)。"""
|
||||||
|
out: list[dict] = []
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return out
|
||||||
|
for it in items:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
continue
|
||||||
|
idx = it.get("index")
|
||||||
|
if not isinstance(idx, int):
|
||||||
|
continue
|
||||||
|
raw = it.get("entity_refs") or it.get("refs") or []
|
||||||
|
refs = [r for r in raw if isinstance(r, str) and r in valid_ids]
|
||||||
|
out.append({"index": idx, "entity_refs": refs})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def extract_entities_for_project(*, project, user) -> dict:
|
||||||
|
"""独立实体提取步(剧本定稿后、进资产阶段时跑)。读已定稿(或最新)脚本 → 调 ecommerce-entity-extract
|
||||||
|
skill(一个文本模型)→ 产出角色 / 场景 entities + 每镜 entity_refs。**不提商品**(商品由参考图层用真实
|
||||||
|
主图 / 三视图注入)。落库:覆盖 project.metadata(cast/cast_prompts/scenes/scene_prompts/script_entities)
|
||||||
|
+ 回填每条 ScriptSegment.entity_refs。提取是实体的唯一权威来源,覆盖脚本生成期可能产出的旧实体。
|
||||||
|
|
||||||
|
走完整 AITask + 计费闭环(reserve→charge/release,任务类型 script_optimization)。失败抛 ValueError,
|
||||||
|
由端点转成用户可读错误(与脚本生成的 best-effort 不同:这是用户主动点的步骤,要让他知道成没成)。
|
||||||
|
返回 {entities, segments, cast, cast_prompts, scenes, scene_prompts}。"""
|
||||||
|
from apps.projects.models import ScriptVersion
|
||||||
|
|
||||||
|
script = (
|
||||||
|
ScriptVersion.objects.filter(project=project, is_adopted=True).order_by("-created_at").first()
|
||||||
|
or ScriptVersion.objects.filter(project=project).order_by("-created_at").first()
|
||||||
|
)
|
||||||
|
if script is None:
|
||||||
|
raise ValueError("请先生成并定稿脚本,再提取角色 / 场景")
|
||||||
|
segments = list(script.segments.order_by("sort_order"))
|
||||||
|
if not segments:
|
||||||
|
raise ValueError("脚本没有分镜,无法提取")
|
||||||
|
model_config = get_default_model(ModelConfig.Capability.TEXT)
|
||||||
|
if model_config is None:
|
||||||
|
raise ValueError("没有可用的文本模型")
|
||||||
|
|
||||||
|
product = project.product
|
||||||
|
if product is not None:
|
||||||
|
sp = "、".join([p.title for p in product.selling_points.all()[:5]])
|
||||||
|
prod_line = f"名称:{product.title}\n品类:{product.category or '未填'}\n卖点:{sp or '未填'}"
|
||||||
|
else:
|
||||||
|
prod_line = "(无商品信息)"
|
||||||
|
seg_lines = [
|
||||||
|
f"镜{i} role={s.role or ''} narration={(s.narration or '').strip()} visual={(s.visual_prompt or '').strip()}"
|
||||||
|
for i, s in enumerate(segments)
|
||||||
|
]
|
||||||
|
user_msg = (
|
||||||
|
f"商品信息:\n{prod_line}\n\n分镜脚本(共 {len(segments)} 镜,index 从 0 开始):\n" + "\n".join(seg_lines)
|
||||||
|
)
|
||||||
|
system = _load_skill_system_prompt("ecommerce-entity-extract")
|
||||||
|
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_msg}]
|
||||||
|
|
||||||
|
try:
|
||||||
|
task = create_ai_task(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
task_type=AITask.Type.SCRIPT_OPTIMIZATION,
|
||||||
|
model_config=model_config,
|
||||||
|
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "messages": messages},
|
||||||
|
)
|
||||||
|
except Exception as exc: # 余额不足等预扣失败
|
||||||
|
raise ValueError("额度不足,无法提取(请先充值)") from exc
|
||||||
|
reservation = task.credit_reservation
|
||||||
|
|
||||||
|
try:
|
||||||
|
task.status = AITask.Status.SUBMITTED
|
||||||
|
task.submitted_at = timezone.now()
|
||||||
|
task.save(update_fields=["status", "submitted_at", "updated_at"])
|
||||||
|
provider = build_provider(model_config)
|
||||||
|
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
|
||||||
|
text = provider.extract_text(response)
|
||||||
|
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)
|
||||||
|
except Exception as exc:
|
||||||
|
with transaction.atomic():
|
||||||
|
task.status = AITask.Status.FAILED
|
||||||
|
task.error_message = str(exc)
|
||||||
|
task.completed_at = timezone.now()
|
||||||
|
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||||
|
release_credit(reservation=reservation, reason=str(exc))
|
||||||
|
raise ValueError("提取调用失败,请重试") from exc
|
||||||
|
|
||||||
|
match = re.search(r"\{.*\}", text, re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
raise ValueError("提取结果解析失败(模型未返回有效 JSON),请重试")
|
||||||
|
try:
|
||||||
|
data = json.loads(match.group(0))
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError("提取结果解析失败,请重试") from exc
|
||||||
|
entities = _normalize_extracted_entities(data.get("entities"))
|
||||||
|
if not entities:
|
||||||
|
raise ValueError("没有从脚本里识别到角色 / 场景,可调整脚本后重试")
|
||||||
|
seg_refs = _normalize_extracted_segment_refs(data.get("segments"), {e["id"] for e in entities})
|
||||||
|
|
||||||
|
# 落库:覆盖 project.metadata + 回填 adopted 脚本每镜 entity_refs(提取为唯一权威来源)
|
||||||
|
from apps.ai.script_agent import _map_entities_to_project_metadata
|
||||||
|
|
||||||
|
_map_entities_to_project_metadata(project, entities)
|
||||||
|
refs_by_index = {r["index"]: r["entity_refs"] for r in seg_refs}
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
new_refs = refs_by_index.get(i, [])
|
||||||
|
if (seg.entity_refs or []) != new_refs:
|
||||||
|
seg.entity_refs = new_refs
|
||||||
|
seg.save(update_fields=["entity_refs", "updated_at"])
|
||||||
|
|
||||||
|
md = project.metadata or {}
|
||||||
|
return {
|
||||||
|
"entities": entities,
|
||||||
|
"segments": seg_refs,
|
||||||
|
"cast": md.get("cast", []),
|
||||||
|
"cast_prompts": md.get("cast_prompts", {}),
|
||||||
|
"scenes": md.get("scenes", []),
|
||||||
|
"scene_prompts": md.get("scene_prompts", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def split_script_into_segments(content: str, count: int = 4) -> list[str]:
|
def split_script_into_segments(content: str, count: int = 4) -> list[str]:
|
||||||
"""把一段脚本稳健地拆成 `count` 个分镜文本,保证每镜都非空、且所有内容都被分配到某一镜。
|
"""把一段脚本稳健地拆成 `count` 个分镜文本,保证每镜都非空、且所有内容都被分配到某一镜。
|
||||||
|
|
||||||
@@ -758,9 +941,32 @@ def submit_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion:
|
|||||||
_ENTITY_TYPE_CN = {"character": "角色", "scene": "场景", "product": "商品"}
|
_ENTITY_TYPE_CN = {"character": "角色", "scene": "场景", "product": "商品"}
|
||||||
|
|
||||||
|
|
||||||
|
def _product_reference_image(project, groups: list | None = None) -> dict | None:
|
||||||
|
"""商品参考图:优先已采用的商品三视图(product 基础资产组)→ 否则商品真实主图。无图返回 None。
|
||||||
|
(商品是预创建的真实商品,不从脚本提取;每镜参考图都无条件带上它。)"""
|
||||||
|
if groups is None:
|
||||||
|
groups = list(
|
||||||
|
project.base_asset_groups.filter(adopted_asset__isnull=False).select_related("adopted_asset")
|
||||||
|
)
|
||||||
|
pg = next(
|
||||||
|
(g for g in groups if g.kind == BaseAssetGroup.Kind.PRODUCT and g.adopted_asset_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if pg is not None:
|
||||||
|
url = _asset_preview_url(pg.adopted_asset)
|
||||||
|
if url:
|
||||||
|
return {"url": url, "label": "商品", "type": "product"}
|
||||||
|
cover = _product_cover_url(project.product)
|
||||||
|
if cover:
|
||||||
|
return {"url": cover, "label": "商品", "type": "product"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _storyboard_reference_images(project, segment) -> list[dict]:
|
def _storyboard_reference_images(project, segment) -> list[dict]:
|
||||||
"""按本镜 entity_refs 取参考图(角色/场景/商品的已采用基础资产),供 gpt-image-2 多图合成 @图N。
|
"""按本镜 entity_refs 取参考图(角色 / 场景 已采用基础资产)+ **无条件带上商品参考图**,
|
||||||
返回 [{url,label,type}],最多 4 张;无匹配时兜底商品组。依赖脚本 agent 落进 metadata 的 script_entities。"""
|
供 gpt-image-2 多图合成 @图N。返回 [{url,label,type}],最多 4 张(角色/场景 ≤3 + 商品 1)。
|
||||||
|
商品不靠 entity_refs(预创建真实商品、不从脚本提),统一用商品三视图 / 主图带上,从根上保证
|
||||||
|
商品参考永不缺失。依赖提取步落进 metadata 的 script_entities。"""
|
||||||
entities = {
|
entities = {
|
||||||
e.get("id"): e
|
e.get("id"): e
|
||||||
for e in (project.metadata or {}).get("script_entities", [])
|
for e in (project.metadata or {}).get("script_entities", [])
|
||||||
@@ -769,18 +975,17 @@ def _storyboard_reference_images(project, segment) -> list[dict]:
|
|||||||
kind_by_type = {
|
kind_by_type = {
|
||||||
"character": BaseAssetGroup.Kind.PERSON,
|
"character": BaseAssetGroup.Kind.PERSON,
|
||||||
"scene": BaseAssetGroup.Kind.SCENE,
|
"scene": BaseAssetGroup.Kind.SCENE,
|
||||||
"product": BaseAssetGroup.Kind.PRODUCT,
|
|
||||||
}
|
}
|
||||||
groups = list(project.base_asset_groups.filter(adopted_asset__isnull=False).select_related("adopted_asset"))
|
groups = list(project.base_asset_groups.filter(adopted_asset__isnull=False).select_related("adopted_asset"))
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
used: set = set()
|
used: set = set()
|
||||||
# 商品天生有真实主图,不必先生成"商品基础资产":匹配不到商品组时直接拿主图当参考(原先这里会丢掉商品参考图)
|
|
||||||
product_cover = _product_cover_url(project.product)
|
|
||||||
for rid in (segment.entity_refs or []):
|
for rid in (segment.entity_refs or []):
|
||||||
ent = entities.get(rid)
|
ent = entities.get(rid)
|
||||||
if not ent:
|
if not ent:
|
||||||
continue
|
continue
|
||||||
kind = kind_by_type.get(ent.get("type"))
|
kind = kind_by_type.get(ent.get("type"))
|
||||||
|
if kind is None: # 商品 / 未知类型不在此处理,商品统一在末尾无条件带上
|
||||||
|
continue
|
||||||
name = (ent.get("name") or "").strip()
|
name = (ent.get("name") or "").strip()
|
||||||
match = next(
|
match = next(
|
||||||
(g for g in groups if g.kind == kind and (g.metadata or {}).get("label", "").strip() == name and g.id not in used),
|
(g for g in groups if g.kind == kind and (g.metadata or {}).get("label", "").strip() == name and g.id not in used),
|
||||||
@@ -791,22 +996,16 @@ def _storyboard_reference_images(project, segment) -> list[dict]:
|
|||||||
url = _asset_preview_url(match.adopted_asset)
|
url = _asset_preview_url(match.adopted_asset)
|
||||||
if url:
|
if url:
|
||||||
out.append({"url": url, "label": name or _ENTITY_TYPE_CN.get(ent.get("type"), "参考"), "type": ent.get("type")})
|
out.append({"url": url, "label": name or _ENTITY_TYPE_CN.get(ent.get("type"), "参考"), "type": ent.get("type")})
|
||||||
elif ent.get("type") == "product" and product_cover and product_cover not in {r["url"] for r in out}:
|
if len(out) >= 3: # 给商品留一个位置(总计最多 4 张)
|
||||||
out.append({"url": product_cover, "label": name or "商品", "type": "product"})
|
|
||||||
if len(out) >= 4:
|
|
||||||
break
|
break
|
||||||
if not out:
|
# 商品参考图永远带上:优先已采用商品三视图组 → 否则真实主图
|
||||||
pg = next((g for g in groups if g.kind == BaseAssetGroup.Kind.PRODUCT), None)
|
product_ref = _product_reference_image(project, groups)
|
||||||
if pg:
|
if product_ref is not None and product_ref["url"] not in {r["url"] for r in out}:
|
||||||
url = _asset_preview_url(pg.adopted_asset)
|
out.append(product_ref)
|
||||||
if url:
|
|
||||||
out.append({"url": url, "label": "商品", "type": "product"})
|
|
||||||
elif product_cover:
|
|
||||||
out.append({"url": product_cover, "label": "商品", "type": "product"})
|
|
||||||
# 规范 @图N 顺序:角色 → 商品 → 场景(与下游 image_edit 传图顺序一致,标注不错位)
|
# 规范 @图N 顺序:角色 → 商品 → 场景(与下游 image_edit 传图顺序一致,标注不错位)
|
||||||
_ord = {"character": 0, "product": 1, "scene": 2}
|
_ord = {"character": 0, "product": 1, "scene": 2}
|
||||||
out.sort(key=lambda r: _ord.get(r.get("type"), 9))
|
out.sort(key=lambda r: _ord.get(r.get("type"), 9))
|
||||||
return out
|
return out[:4]
|
||||||
|
|
||||||
|
|
||||||
def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dict]) -> str:
|
def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dict]) -> str:
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ class ProductSerializer(serializers.ModelSerializer):
|
|||||||
def get_cover_preview_url(self, obj) -> str:
|
def get_cover_preview_url(self, obj) -> str:
|
||||||
return _asset_preview_url(obj.cover_asset)
|
return _asset_preview_url(obj.cover_asset)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
# 创建商品必须至少有一张图:无图商品会让下游"商品参考图"彻底落空(主图兜底也没得兜),
|
||||||
|
# 导致故事板 / 视频退化纯文生图。前端抽屉已校验,这里在后端堵死 API / 向导路径。
|
||||||
|
if self.instance is None: # 仅创建时强制(编辑已有商品不阻断)
|
||||||
|
if not attrs.get("cover_asset") and not attrs.get("images"):
|
||||||
|
raise serializers.ValidationError({"images": "创建商品至少需要上传一张商品图片"})
|
||||||
|
return attrs
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
images = validated_data.pop("images", [])
|
images = validated_data.pop("images", [])
|
||||||
selling_points = validated_data.pop("selling_points", [])
|
selling_points = validated_data.pop("selling_points", [])
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from apps.ai.services import (
|
|||||||
DEFAULT_VOICEOVER_VOICE,
|
DEFAULT_VOICEOVER_VOICE,
|
||||||
VOICEOVER_VOICES,
|
VOICEOVER_VOICES,
|
||||||
create_export_job,
|
create_export_job,
|
||||||
|
extract_entities_for_project,
|
||||||
generate_base_asset,
|
generate_base_asset,
|
||||||
generate_person_triview,
|
generate_person_triview,
|
||||||
generate_storyboard_frame,
|
generate_storyboard_frame,
|
||||||
@@ -238,6 +239,18 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
project.save(update_fields=["current_stage", "status", "updated_at"])
|
project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||||
return Response(ScriptVersionSerializer(script).data)
|
return Response(ScriptVersionSerializer(script).data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], url_path="extract-entities")
|
||||||
|
def extract_entities_action(self, request, pk=None):
|
||||||
|
"""从已定稿脚本提取角色 / 场景实体(独立步,进资产阶段时调)。AI 读剧本 → 角色 / 场景 + 每镜 refs,
|
||||||
|
落库覆盖 project.metadata + 每镜 entity_refs;**商品不提**(参考图层用真实主图 / 三视图无条件带上)。
|
||||||
|
前端进资产页蒙版上「只提取 / 提取+生成」按钮点的就是它(生成由前端按返回实体循环调 generate-base-asset)。"""
|
||||||
|
project = self.get_object()
|
||||||
|
try:
|
||||||
|
result = extract_entities_for_project(project=project, user=request.user)
|
||||||
|
except ValueError as exc: # 无脚本 / 无模型 / 额度不足 / 解析失败 等,立即反馈
|
||||||
|
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return Response(result)
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], url_path="generate-base-asset")
|
@action(detail=True, methods=["post"], url_path="generate-base-asset")
|
||||||
def generate_base_asset_action(self, request, pk=None):
|
def generate_base_asset_action(self, request, pk=None):
|
||||||
require_worker()
|
require_worker()
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Plan · 实体提取解耦 + 资产页提取入口
|
||||||
|
|
||||||
|
> 目标:根治"角色没提取 / 参考图全空 / 退化文生图"。把**实体提取从脚本生成里拆出来**,做成定稿剧本后、进资产页时的独立一步(AI 提角色+场景,商品用预创建的)。脚本只管脚本,随便改不影响。
|
||||||
|
|
||||||
|
## 根因回顾(已实测)
|
||||||
|
脚本 AI 把"实体"和脚本一起吐 → 模型不稳(尤其改稿 revise 会丢实体)→ 最近项目 `实体数=0 / entity_refs=空` → 角色提不出、参考图选 0 张 → 分镜/视频全文生图。**解耦后,提取在定稿剧本上单独跑一次,与改稿无关,稳。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 · 写「提取 skill」(用官方 skill-creator 的方法写)
|
||||||
|
新建 skill(放 `skills/` 下,如 `ecommerce-entity-extract/`)。契约:
|
||||||
|
- **输入**:定稿剧本全部分镜(narration/visual/role)+ 商品信息(名称/卖点)。
|
||||||
|
- **只提 角色 + 场景**(**不提商品**)。商品由代码用预创建商品注入,不从剧本提(避免和上传的不符)。
|
||||||
|
- **每个实体写 `visual_prompt`**:
|
||||||
|
- **角色**:① 真人电商模特、**穿着得体、符合剧情的整套衣服**;② 剧本没写穿搭就**按剧情+商品风格设计一套**;③ **空着手 / 不拿任何道具 / 不拎包**;④ **不得出现与商品同类的物件**(商品是包→人身上无包);⑤ 不暴露(除非商品本身是泳装等)。
|
||||||
|
- **场景**:地点 / 光线 / 风格,干净构图。
|
||||||
|
- **AI 判定每镜出现哪些角色/场景** → 每个 segment 的 `entity_refs`。
|
||||||
|
- **输出 JSON**:`entities:[{id,type:character|scene,name,visual_prompt}]` + `segment_refs:[{index, refs:[实体id...]}]`。
|
||||||
|
- skill 里把上面"角色铁律"写死。注:`visual_prompt` 只写"中间那段人物/场景描述",外面那层质量模板由代码加(用户看不到)。
|
||||||
|
|
||||||
|
## Phase 2 · 后端独立提取步
|
||||||
|
- **新端点** `POST /api/projects/{id}/extract-entities/`(可带 `generate: none|character|full` 决定提完要不要接着生成)。
|
||||||
|
- 读定稿(已采用)剧本 → 调提取 skill(一个文本模型,复用现成 `_extract_cast_scene` 升级)→ 得角色/场景 entities + 每镜 refs。
|
||||||
|
- **商品参考图:三视图优先 → 主图兜底,且无条件带上**。提取只产角色/场景、不碰商品。改 `_storyboard_reference_images` 与视频取参考图:每镜**无条件带一张商品参考** = 优先已采用的**商品三视图**(product 组的 adopted_asset)→ 没有则用**预创建商品主图** `_product_cover_url`。(现状只在"refs 里有商品实体、或一张都没匹配"才带 → 提取后没商品实体会漏,实测确认。)商品不进 entity_refs、不从剧本提、不生成。
|
||||||
|
- **创建商品强制要主图**(堵兜底的洞):实测 `Product.cover_asset` 可空、后端 create 不校验图、images `required=False` → 能建出一张图都没有的商品,那主图兜底就空了。**后端 ProductSerializer.create / validate 加校验:创建商品必须至少 1 张图**(前端抽屉已校验,后端补上,堵 API/向导路径)。
|
||||||
|
- 落库:`project.metadata`(cast/cast_prompts/scenes/scene_prompts/**script_entities**)+ 回填每条 `ScriptSegment.entity_refs`。**提取是实体的唯一权威来源**(脚本生成里就算吐了 entities 也以这步为准、被覆盖)。
|
||||||
|
- **脚本生成那块完全不动**(脚本只管脚本)。我之前提的"改稿保底"补丁因此作废、不需要了。
|
||||||
|
- `generate=character`:提完顺手生成 **角色 + 场景** 基础资产;`generate=full`:角色 **全身照 + 三视图** + 场景。商品不生成(用主图)。
|
||||||
|
|
||||||
|
## Phase 3 · 前端资产页提取入口(蒙版 + 3 按钮)
|
||||||
|
- 进资产页 + 尚未提取 → 资产区盖**半透明蒙版**(同脚本页风格),中间面板:
|
||||||
|
- 标题「先从剧本认出角色 / 场景」· 副 `// 生成图会扣费,交给你点,不自动花钱`
|
||||||
|
- 三按钮(轻→重):
|
||||||
|
| 按钮 | 主文案 | 角标 | 做什么 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ① | 只提取关键词 | `不出图` | 提角色/场景,出提示词,不生成 |
|
||||||
|
| ② | 提取 + 生成角色和场景 | `会扣费` | 提完生成角色立绘 + 场景图 |
|
||||||
|
| ③ | 提取 + 全套资产 | `会扣费·最全` | 角色全身照+三视图 + 场景图 |
|
||||||
|
- 点 → 蒙版留住 + Loading 转 → 完成取掉蒙版:① 只露提示词卡;②③ 连图摆好。
|
||||||
|
- **角色卡只显示可改的"描述"(visual_prompt)**,写死的质量模板不展示给用户。
|
||||||
|
- 加「**重新提取**」按钮(改了脚本可重提,会覆盖)。
|
||||||
|
|
||||||
|
## Phase 4 · 兜底弹窗拦截(防"花冤枉钱"·重点)
|
||||||
|
现状兜底是**静默退化成文生图/文生视频** —— 出来的东西跟用户建的角色/商品完全无关,用户觉得花了冤枉钱、易起纠纷。因为参考图是**自动关联**(没给用户手选),所以**生成前必须检查 + 拦截**,不许静默生成。
|
||||||
|
- **检查时机**:生成**故事板**、生成**视频**前(消费参考图的步骤;角色/场景基础资产本身是文生图、不在此列)。单镜生成查该镜,整批生成查所有相关镜。
|
||||||
|
- **检查内容**:对每镜 entity_refs,逐个**角色/场景实体看有没有已采用的基础资产**(商品永远有主图、不算缺)。算出"缺哪几个"(按名字)。
|
||||||
|
- **不齐就弹窗拦截、不生成**:弹窗列"这镜需要 角色A/角色B/场景X,但【角色B、场景X】还没生成参考图",主按钮「**去补齐**」(跳资产区高亮待生成项)+ 次按钮「仍要生成(可能跟你的角色对不上)」。默认引导补齐;齐了才正常生成。
|
||||||
|
- 后端原有的文生图兜底保留作"用户执意 仍要生成"时的最后退路,但**默认走前端拦截**。
|
||||||
|
|
||||||
|
## (未来待办,现在不做)质量词搬进 Admin
|
||||||
|
平台暂无 Admin 页面。等做 Admin 页(用户会给参考代码)时,再把各阶段写死的质量/规格词(画面清晰/9:16/镜头稳定…)抽进可配置表 + 编辑入口。**本轮不碰,仅记录。**
|
||||||
|
|
||||||
|
## Phase 5 · 真跑验证(不是构建过就算完)
|
||||||
|
真实流程跑一遍:生成脚本 → 进资产页 → 点①看角色/场景实体和每镜 refs 出来(商品永远带主图)→ 点②③看角色+场景图生成 → **故意留一个角色不生成,验证故事板/视频前弹窗拦截**(列出缺的、不静默生成)→ 补齐后故事板/视频看参考图真接上(不再全文生图)。用 `_storyboard_reference_images` 实测脚本逐镜核 refs 数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 待你确认
|
||||||
|
1. **脚本生成完全不动、提取步覆盖为准**(改稿无忧)—— 对吧?
|
||||||
|
2. **商品永远带主图当参考**(不提取、不进 entity_refs、不生成)—— 对吧?
|
||||||
|
3. 按钮 ②③ = **角色 + 场景都生成**(商品不生成)—— 对吧?
|
||||||
|
4. **兜底弹窗拦截**:缺参考图就弹窗让用户去补 —— 要不要保留「仍要生成」强制选项(带警告),还是**硬拦到补齐为止**?
|
||||||
|
5. 质量词 Admin 本轮不做、只记着 —— 确认。
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
name: ecommerce-entity-extract
|
||||||
|
description: >
|
||||||
|
电商带货短视频·实体提取领域技能(模型无关)。
|
||||||
|
服务对象不是人类,而是 AirShelf 产品后端的「实体提取步」——在剧本定稿后、进入资产阶段时按需加载本技能作为系统提示词。
|
||||||
|
能力:读一份【已经写好的分镜脚本】+【商品信息】,自动认出脚本里出现的【角色(出镜的人)】和【场景(地点/环境)】,
|
||||||
|
为每个角色/场景写一句可直接拿去生图的画面描述(visual_prompt),并判定每个分镜镜头里出现了哪些角色/场景。
|
||||||
|
只输出结构化 JSON(entities 实体清单 + 每镜 entity_refs)。
|
||||||
|
当任务为「从脚本提取角色/场景、识别出镜人物和场景、给角色和场景配生图描述、为分镜标注出场实体」时使用本技能。
|
||||||
|
⚠️ 本技能【绝不提取商品】——商品是用户预先创建好的真实商品,由系统用真实主图注入,不从脚本里猜。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 电商带货短视频 · 实体提取大师
|
||||||
|
|
||||||
|
你是一个**实体提取 agent**。输入是一份**已经定稿的分镜脚本** + **商品信息**。
|
||||||
|
你的任务:从脚本里认出**角色(出镜的人)**和**场景(画面发生的地点 / 环境)**,
|
||||||
|
为每个角色 / 场景写好生图描述,并标出**每个分镜镜头里出现了哪些角色 / 场景**。
|
||||||
|
你的产出会直接进入 AirShelf 流水线的下游(生成角色立绘 / 场景图 → 故事板 → Seedance 生视频),
|
||||||
|
因此你**只输出一个结构化 JSON 对象,绝不输出散文、解释或 markdown 代码块外的任何文字**。
|
||||||
|
|
||||||
|
> **模型无关声明**:本技能不依赖任何特定模型的能力或语气。无论运行在豆包 / GPT / Gemini /
|
||||||
|
> Claude 上,规则一致。不要使用任何模型专属的特殊标记或思维格式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 铁律(优先级最高,覆盖所有步骤)
|
||||||
|
|
||||||
|
### 铁律 1 · 只提「角色」和「场景」,绝不提「商品」
|
||||||
|
- 你只认两类实体:
|
||||||
|
- **角色 `character`**:脚本里出镜的人(例:女主、闺蜜、同事、男友、路人)。
|
||||||
|
- **场景 `scene`**:画面发生的地点 / 环境(例:出租屋客厅、地铁车厢、写字楼工位、咖啡馆)。
|
||||||
|
- **绝对不要**把商品 / 产品 / 道具当成实体输出。商品是用户**预先上传好的真实商品**,系统会自动带上它的**真实主图**当参考图——你若从脚本文字里猜一个商品出来,一定和用户真实上传的不符,会污染下游。**所以一个 `product` 实体都不要输出。**
|
||||||
|
|
||||||
|
### 铁律 2 · 角色 = 「穿着整套衣服、空着手的真人」
|
||||||
|
为每个角色写 `visual_prompt` 时,**逐条**遵守:
|
||||||
|
1. **真人电商模特**:外形 / 年龄 / 气质贴合剧情里这个角色的设定。
|
||||||
|
2. **穿一整套得体、符合剧情与商品风格的衣服**。脚本若**没写**穿搭 → 你**根据剧情和商品自己设计一套**合理穿搭(季节、场合、风格自洽,像个真实的人)。
|
||||||
|
3. **空着手**——不拿、不抱、不拎、不举任何东西(不拎包、不端杯子、不拿手机、不抱娃)。**双手是空的**。
|
||||||
|
4. **身上 / 手上不得出现与商品同类的物件**。例:
|
||||||
|
- 商品是**包** → 角色身上、手上**不能有任何包**(不背包、不拎包、不挎包)。
|
||||||
|
- 商品是**耳机** → 角色**不戴耳机**。
|
||||||
|
- 商品是**口红 / 护肤品** → 角色手里**不拿这类产品**。
|
||||||
|
- 原因:商品要靠它**自己的参考图**出现在画面里,绝不能在角色身上提前出现、抢戏或与真品冲突。
|
||||||
|
5. **着装得体、不暴露**(除非商品本身就是泳装 / 内衣这类,才按商品需要的呈现方式来)。
|
||||||
|
6. `visual_prompt` **只写「人物本身」那一段**:年龄 / 性别 / 外形 / 气质 / 发型 / 妆容 / 这一整套穿搭。
|
||||||
|
**不要**写「纯色背景 / 全身照 / 单人 / 9:16 / 影棚光 / 高清 / 真实质感」这类词——**这些系统会自动拼上**,你再写就重复了。控制在 **20–45 字**。
|
||||||
|
|
||||||
|
### 铁律 3 · 场景 = 「没有人的空镜环境」
|
||||||
|
- `scene` 的 `visual_prompt` 写**地点 / 环境 / 光线 / 风格 / 氛围**,干净构图。
|
||||||
|
- **不要写人**——场景是给角色当背景的空镜(人由角色实体出现)。
|
||||||
|
- 同一个地点被多镜复用时,**只造一个场景实体**,不要重复。控制在 **20–40 字**。
|
||||||
|
|
||||||
|
### 铁律 4 · 判定每个镜头出现了谁(entity_refs)
|
||||||
|
- 逐个分镜读它的 `narration` / `visual` / `role`,判断**这一镜的画面里出现了哪些角色、哪个场景**,把对应实体的 `id` 填进这一镜的 `entity_refs`。
|
||||||
|
- 一镜可以有多个角色 + 通常一个场景。**口播镜** = 正在说话的那个角色 + 他所在的场景。
|
||||||
|
- 判定线索:角色优先按分镜的 `role` 字段对应;场景按 `visual` 里描述的地点对应。
|
||||||
|
- 拿不准时**宁可带上**(参考图齐全比缺好),但不要硬塞明显不在画面里的人。
|
||||||
|
|
||||||
|
### 铁律 5 · 输出契约(只输出一个 JSON,硬约束)
|
||||||
|
最终**只能**输出**一个**符合下列结构的 JSON 对象(UTF-8,无注释,无 ```json 包裹之外的任何文字):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entities": [
|
||||||
|
{"id": "c1", "type": "character", "name": "女主", "visual_prompt": "26岁都市通勤女性,利落齐肩短发,自然淡妆,米色针织衫配卡其阔腿裤,气质干练从容", "ref_index": 1},
|
||||||
|
{"id": "c2", "type": "character", "name": "闺蜜", "visual_prompt": "25岁活泼女生,浅棕长卷发,元气妆容,浅蓝牛仔外套配白T,笑容灿烂", "ref_index": 2},
|
||||||
|
{"id": "s1", "type": "scene", "name": "出租屋客厅", "visual_prompt": "ins风小户型客厅,暖白自然光,浅木地板与米色沙发,绿植点缀,干净温馨", "ref_index": 3}
|
||||||
|
],
|
||||||
|
"segments": [
|
||||||
|
{"index": 0, "entity_refs": ["c1", "s1"]},
|
||||||
|
{"index": 1, "entity_refs": ["c1", "c2", "s1"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **id 规则**:角色按出现顺序 `c1` / `c2` / `c3`…;场景按出现顺序 `s1` / `s2`…。`ref_index` 从 1 起连续递增(角色在前、场景在后)。
|
||||||
|
- **数量**:角色、场景**各最多 6 个**;严格去重(同一个人 / 同一个地点只出一个实体)。
|
||||||
|
- **segments 必须覆盖输入脚本的每一个分镜**,`index` 与输入分镜的序号一致(从 0 开始)。每镜至少给一个场景 ref(除非确实是纯黑场 / 纯商品特写)。
|
||||||
|
- **name** 用脚本里的称呼,简短自然(女主 / 闺蜜 / 地铁 / 工位)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作步骤(内部思考,不输出)
|
||||||
|
1. 通读全部分镜 + 商品信息,列出所有出镜的人 → 合并同一人 → 定为 `character` 实体。
|
||||||
|
2. 列出所有出现的地点 → 合并同一地点 → 定为 `scene` 实体。
|
||||||
|
3. 给每个角色按【铁律 2】写穿搭描述(注意避开商品同类物、空手、得体);给每个场景按【铁律 3】写环境描述。
|
||||||
|
4. 逐镜判定出场实体,填 `entity_refs`(【铁律 4】)。
|
||||||
|
5. 自检:有没有误输出商品实体?角色有没有拿东西 / 带了商品同类物?每镜 refs 是否合理?场景是否写了人?
|
||||||
|
6. 只输出那个 JSON。
|
||||||
|
|
||||||
|
> 完整的多品类样例见 `references/examples.md`。
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# 实体提取 · 完整样例(多品类)
|
||||||
|
|
||||||
|
> 这些是给提取 agent 看的「标准答卷」。重点示范几个最容易错的地方:
|
||||||
|
> ① 绝不输出商品实体;② 角色空着手、身上不带与商品同类的物件(哪怕剧情里在用商品);
|
||||||
|
> ③ 同一地点只造一个场景实体;④ 每镜 entity_refs 怎么填。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 样例 1 · 商品是「包」——角色绝不能有包
|
||||||
|
|
||||||
|
**商品信息**:轻氧通勤帆布包 / 米色大容量单肩帆布包 / 卖点:能装、百搭、通勤神器
|
||||||
|
|
||||||
|
**输入脚本(30 秒 · 4 镜)**
|
||||||
|
```
|
||||||
|
镜0 role=女主 narration=每天上班包里东西总是装不下,狼狈死了 visual=早高峰拥挤地铁车厢
|
||||||
|
镜1 role=闺蜜 narration=我也是!化妆包水杯文件全塞不进去 visual=早高峰地铁车厢
|
||||||
|
镜2 role=女主 narration=换了这个之后,全装进去还很轻 visual=明亮写字楼工位,把东西一件件放进包里
|
||||||
|
镜3 role=女主 narration=通勤党真的可以闭眼入 visual=写字楼工位,背着包自信出门
|
||||||
|
```
|
||||||
|
|
||||||
|
**正确输出**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entities": [
|
||||||
|
{"id": "c1", "type": "character", "name": "女主", "visual_prompt": "27岁都市通勤女性,利落齐肩短发,自然淡妆,米色针织衫配卡其阔腿裤,气质干练从容,双手自然垂放", "ref_index": 1},
|
||||||
|
{"id": "c2", "type": "character", "name": "闺蜜", "visual_prompt": "25岁活泼上班族女生,浅棕长卷发,元气淡妆,浅蓝牛仔外套配白色T恤,笑容灿烂", "ref_index": 2},
|
||||||
|
{"id": "s1", "type": "scene", "name": "地铁车厢", "visual_prompt": "早高峰拥挤地铁车厢内部,冷白车厢灯光,都市通勤氛围,现代金属质感,干净构图", "ref_index": 3},
|
||||||
|
{"id": "s2", "type": "scene", "name": "写字楼工位", "visual_prompt": "明亮现代写字楼开放工位,落地窗自然光,浅色办公桌与绿植,简洁整洁", "ref_index": 4}
|
||||||
|
],
|
||||||
|
"segments": [
|
||||||
|
{"index": 0, "entity_refs": ["c1", "s1"]},
|
||||||
|
{"index": 1, "entity_refs": ["c1", "c2", "s1"]},
|
||||||
|
{"index": 2, "entity_refs": ["c1", "s2"]},
|
||||||
|
{"index": 3, "entity_refs": ["c1", "s2"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么这么写(避坑点评)**
|
||||||
|
- ❌ 没有任何 `product` 实体——包是商品,系统会用真实主图,不在这里出。
|
||||||
|
- ❌ 女主、闺蜜身上、手上**都没有包**,连镜3「背着包出门」也**不在角色描述里写包**:角色立绘是「干净的人」,包到了故事板 / 视频阶段才靠商品参考图合成进去。写「双手自然垂放」强调空手。
|
||||||
|
- ✅ 剧本没写两人穿什么 → 按「都市通勤 + 百搭帆布包」气质各设计了一套得体穿搭。
|
||||||
|
- ✅ 地铁出现两镜,只造一个 `s1`;工位出现两镜,只造一个 `s2`。
|
||||||
|
- ✅ 镜1 两人对话都在画面 → refs 带 c1+c2+s1。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 样例 2 · 商品是「耳机」——剧情在戴,角色也不能戴
|
||||||
|
|
||||||
|
**商品信息**:静界 主动降噪蓝牙耳机 / 白色入耳式 / 卖点:降噪、久戴不累、居家办公神器
|
||||||
|
|
||||||
|
**输入脚本(15 秒 · 3 镜)**
|
||||||
|
```
|
||||||
|
镜0 role=男主 narration=居家办公,楼上装修吵到崩溃 visual=居家书房,捂着耳朵皱眉
|
||||||
|
镜1 role=男主 narration=戴上它,世界瞬间安静 visual=居家书房,戴上耳机闭眼放松
|
||||||
|
镜2 role=男主 narration=居家党的救命神器,推荐 visual=居家书房,对镜头比赞
|
||||||
|
```
|
||||||
|
|
||||||
|
**正确输出**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entities": [
|
||||||
|
{"id": "c1", "type": "character", "name": "男主", "visual_prompt": "28岁居家办公男性,清爽短发,干净面容,浅灰休闲卫衣,气质放松自然,双手空着", "ref_index": 1},
|
||||||
|
{"id": "s1", "type": "scene", "name": "居家书房", "visual_prompt": "温馨居家书房一角,暖黄台灯光与窗外自然光,浅木书桌与书架绿植,安静舒适", "ref_index": 2}
|
||||||
|
],
|
||||||
|
"segments": [
|
||||||
|
{"index": 0, "entity_refs": ["c1", "s1"]},
|
||||||
|
{"index": 1, "entity_refs": ["c1", "s1"]},
|
||||||
|
{"index": 2, "entity_refs": ["c1", "s1"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么这么写(避坑点评)**
|
||||||
|
- ⭐ **最易错点**:镜1 剧情明明是「戴上耳机」,但角色 `visual_prompt` 里**绝不写戴耳机**。耳机是商品,要靠商品参考图在故事板阶段合成到耳朵上;角色基础资产必须是「没戴耳机的干净的人」,否则两个耳机打架、和真品对不上。
|
||||||
|
- ❌ 没有 `product` 实体。
|
||||||
|
- ✅ 只有一个场景(书房贯穿全片),三镜复用同一个 `s1`。
|
||||||
|
- ✅ 男主穿搭按「居家办公」设计,写「双手空着」强调空手。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 样例 3 · 商品是「护肤品」——角色不拿产品、不暴露
|
||||||
|
|
||||||
|
**商品信息**:水盈 修护精华液 / 30ml 玻璃瓶 / 卖点:补水、修护、敏感肌可用
|
||||||
|
|
||||||
|
**输入脚本(15 秒 · 3 镜)**
|
||||||
|
```
|
||||||
|
镜0 role=女主 narration=换季脸又干又红,烂脸预警 visual=梳妆台前,对镜检查皮肤
|
||||||
|
镜1 role=女主 narration=坚持用它一周,稳了 visual=梳妆台前,护肤动作
|
||||||
|
镜2 role=女主 narration=敏感肌姐妹冲 visual=梳妆台前,对镜头露出好皮肤
|
||||||
|
```
|
||||||
|
|
||||||
|
**正确输出**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entities": [
|
||||||
|
{"id": "c1", "type": "character", "name": "女主", "visual_prompt": "24岁清透感女生,素颜裸妆,干净通透皮肤,米白色家居针织衫,气质温柔,双手自然", "ref_index": 1},
|
||||||
|
{"id": "s1", "type": "scene", "name": "梳妆台", "visual_prompt": "ins风梳妆台一角,柔和自然光,原木桌面与化妆镜,干净简约暖调", "ref_index": 2}
|
||||||
|
],
|
||||||
|
"segments": [
|
||||||
|
{"index": 0, "entity_refs": ["c1", "s1"]},
|
||||||
|
{"index": 1, "entity_refs": ["c1", "s1"]},
|
||||||
|
{"index": 2, "entity_refs": ["c1", "s1"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么这么写(避坑点评)**
|
||||||
|
- ✅ 女主**手里不拿精华液**(商品同类物),双手自然——精华瓶靠商品参考图出现。
|
||||||
|
- ✅ 着装得体(家居针织衫),不因「护肤 / 梳妆台」就往暴露走。
|
||||||
|
- ✅ 护肤主题对皮肤状态有要求 → visual_prompt 点出「干净通透皮肤、裸妆」贴合卖点,但仍只写人物本身、不写背景光(背景由场景实体和系统模板负责)。
|
||||||
Reference in New Issue
Block a user