diff --git a/.gitignore b/.gitignore index 64e1176..012c783 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ core/qa/visual-parity/output/ *.tsbuildinfo next-env.d.ts.bak _design_src + +# 本地工具/venv,不入库 +core/.tools/ diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 746b48c..cba5728 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -752,6 +752,35 @@ def _product_cover_url(product) -> str: return "" +def _product_reference_urls(product, limit: int = 3) -> list[str]: + """模特上身图的商品参考图(可多张):**真实上传图优先,排除 AI 生成图**——避免拿生成图当真相 + 再喂回模型造成误差累积。按主图/排序取前 limit 张真实上传图;一张都没有时回落 cover(即便是 AI 图, + 至少保证能走 image_edit 而不是纯文生图)。""" + if product is None: + return [] + from apps.assets.models import Asset + + urls: list[str] = [] + seen: set[str] = set() + rels = list(product.images.select_related("asset").all()) + rels.sort(key=lambda im: (not im.is_primary, im.sort_order)) + for im in rels: + a = im.asset + if a is None or getattr(a, "source", "") == Asset.Source.AI_GENERATED: + continue + u = _asset_preview_url(a) + if u and u not in seen: + seen.add(u) + urls.append(u) + if len(urls) >= limit: + return urls + if not urls: # 无任何真实上传图 → 回落 cover(可能是 AI 图,但好过纯文生图) + cover = _product_cover_url(product) + if cover: + urls.append(cover) + return urls + + def quality_words(stage: str, slot: str = "quality") -> list[str]: """平台单层质量词配置(QualityWord)。无配置/表不存在 → 返回 [],调用方回落写死值,保证零回归。""" try: @@ -815,26 +844,69 @@ def build_product_triview_prompt_refs(product, base_prompt: str = "") -> str: 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: +# 模特上身图:按品类分「穿戴 / 非穿戴」注入不同的商品-模特关系;每张按序号变化动作/场景/镜头。 +_TRYON_WEARABLE_HINTS = ("服", "衣", "裤", "裙", "鞋", "帽", "袜", "围巾", "外套", "卫衣", "内衣", "文胸", "胸罩", "泳", "bra") +_TRYON_VARIATIONS = [ + {"action": "模特正面自然展示该商品", "scene": "干净的室内空间", "shot": "半身近景,商品清晰可见"}, + {"action": "模特正在穿着 / 使用该商品", "scene": "生活化的真实居家场景", "shot": "侧面角度,突出穿着或使用方式"}, + {"action": "模特手持或局部展示商品细节", "scene": "明亮的时尚生活场景", "shot": "中近景,商品占比较高"}, + {"action": "模特与商品自然互动", "scene": "温暖时尚的生活场景", "shot": "半身,强调使用情境"}, +] +_TRYON_NEGATIVE = ( + "不要换人,不要改商品设计,不要改商品颜色与结构,不要生成错误或乱码的 Logo 与文字," + "不要把商品改成相似款,不要多余的商品堆叠,不要多余文字,不要水印,不要边框," + "不要低清模糊,不要过度磨皮,不要畸变,不要扭曲身体,不要夸张滤镜" +) + + +def _is_wearable_product(product) -> bool: + """据类目/标题判断是否「穿戴类」(服饰鞋帽内衣) → 真实穿到身上;否则非穿戴 → 手持/佩戴/使用。""" + blob = f"{getattr(product, 'category', '') or ''} {getattr(product, 'title', '') or ''}".lower() + return any(h in blob for h in _TRYON_WEARABLE_HINTS) + + +def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "", index: int = 0, n_product: int = 1) -> str: """模特上身图 image_edit 提示词(refs 版): - 参考图1=商品真实主图(锁商品外形/品牌/配色),参考图2=选中模特(锁人脸/身形/气质)。 - 生成「该模特自然展示/使用该商品」的电商效果图。""" + 参考图1~N=商品真实图(多角度,锁外形/品牌/配色),参考图N+1=选中模特(锁人脸/身形/气质)。 + 按品类分穿戴/非穿戴(穿戴=真实穿身上、替换原衣;非穿戴=手持/佩戴/使用,不动原衣); + `index` 让每张图动作/场景/镜头不同;`n_product` 让参考图序号自适应。""" name = (getattr(product, "title", "") or "商品").strip() - lines = [f"参考图1是「{name}」的真实商品。"] - if has_model: - lines += [ - "参考图2是出镜模特。请生成参考图2中的这位模特自然地展示/佩戴/使用参考图1中商品的电商效果图。", - "模特的五官、发型、肤色、身形与气质必须与参考图2高度一致,不要换人;", - "商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。", - ] + n_product = max(1, int(n_product or 1)) + # 参考图序号自适应:N 张商品图 → 参考图1~N=商品, 参考图N+1=模特 + if n_product <= 1: + intro = f"参考图1是「{name}」的真实商品图,是该商品外观的唯一依据。" + prod_ref = "参考图1" + model_idx = 2 else: - lines += [ - "请生成一位真人模特自然地展示/佩戴/使用参考图1中商品的电商效果图。", - "商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。", - ] + rng = f"1-{n_product}" if n_product > 2 else "1、2" + intro = f"参考图{rng}是「{name}」同一件真实商品的不同角度图,是该商品外观的唯一依据,请综合这些角度还原商品。" + prod_ref = f"参考图{rng}" + model_idx = n_product + 1 + + if _is_wearable_product(product): + relation = ( + f"真实穿着{prod_ref}中的这件商品,替换掉模特原本的衣服,让商品自然合身地穿在身上," + "而不是放在一旁展示,保持商品的版型、领口、袖型、长度、纹样不变" + ) + else: + relation = ( + f"自然地手持 / 在合适位置佩戴 / 正在使用{prod_ref}中的这件商品(如为耳机则佩戴在耳朵上)," + "不要改动模特原本的服装,不要把商品强行穿到身上" + ) + + var = _TRYON_VARIATIONS[index % len(_TRYON_VARIATIONS)] + lines = [intro] + if has_model: + lines.append(f"参考图{model_idx}是出镜模特。请生成参考图{model_idx}中这位模特{relation}的电商详情页效果图。") + lines.append(f"模特的五官、发型、肤色、身形、年龄与气质必须与参考图{model_idx}(模特图)高度一致,不要换人,不要自行生成另一位模特。") + else: + lines.append(f"请生成一位真人模特{relation}的电商详情页效果图。") + lines.append(f"商品的外形、配色、材质、品牌文字与 Logo、图案必须与{prod_ref}(商品图)严格一致,不要重新设计、不要改样、不要生成相似款。") + lines.append(f"本张画面:{var['action']};场景:{var['scene']};镜头:{var['shot']}。") lines.append(quality_suffix("model_tryon", "自然光、真实质感、干净背景、电商主图构图,人物与商品比例真实协调。")) if base_prompt and base_prompt.strip(): lines.append(base_prompt.strip()) + lines.append("请规避:" + _TRYON_NEGATIVE) return " ".join(lines) @@ -1985,13 +2057,18 @@ def run_standalone_image_task(*, task_id: str) -> None: if model_asset is not None: model_url = _asset_preview_url(model_asset) product_url = _product_cover_url(product) if product is not None else "" + # 模特上身图:真实上传图优先、排除 AI 生成图,可多张(多角度更易锁外形/品牌) + product_urls = _product_reference_urls(product, limit=3) if product is not None else [] edit_images: list[str] = [] edit_prompt = "" - if mode == "model" and can_edit and product_url: - # 模特上身图:商品图必有,模特图可缺(缺则让模型自取真人模特) - edit_images = [product_url] + ([model_url] if model_url else []) - edit_prompt = build_model_tryon_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt) + if mode == "model" and can_edit and product_urls: + # 模特上身图:参考图1~N=商品真实图(多角度),参考图N+1=模特(模特图可缺则让模型自取真人模特) + edit_images = product_urls + ([model_url] if model_url else []) + edit_prompt = build_model_tryon_prompt_refs( + product, has_model=bool(model_url), base_prompt=prompt, + index=index, n_product=len(product_urls), + ) elif mode == "cover" and can_edit and product_url: # 平台套图:参考图1=商品真实主图(锁包装一致性),有模特则参考图2=模特(锁人脸/身形) edit_images = [product_url] + ([model_url] if model_url else []) diff --git a/core/backend/apps/products/views.py b/core/backend/apps/products/views.py index 8c34563..248e387 100644 --- a/core/backend/apps/products/views.py +++ b/core/backend/apps/products/views.py @@ -57,17 +57,24 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet): product = self.get_object() team = product.team - assets = list( + # 真因不是 OR,而是 select_related("origin_task__project") 把每个资产关联的 AITask 整行拉过来, + # 含 request_payload / response_payload 两个巨型 JSON 列(实测 114 行就要 ~66s 纯传输)。 + # 视图与序列化器只用到 origin_task.project(取项目名/id),从不读 payload → .defer() 掉这两列。 + base = ( Asset.objects.filter(team=team, is_deleted=False) - .filter( - Q(metadata__product_id=str(product.id)) - | Q(origin_task__project__product_id=product.id) - | Q(product_images__product_id=product.id) - ) .select_related("origin_task__project") + .defer("origin_task__request_payload", "origin_task__response_payload") .prefetch_related("files") - .distinct() ) + by_id = {} + for qs in ( + base.filter(metadata__product_id=str(product.id)), + base.filter(origin_task__project__product_id=product.id), + base.filter(product_images__product_id=product.id), + ): + for a in qs: + by_id[a.id] = a + assets = sorted(by_id.values(), key=lambda a: (a.created_at is not None, a.created_at), reverse=True) def ser(a): return AssetSerializer(a).data diff --git a/core/backend/probe_format_support.py b/core/backend/probe_format_support.py new file mode 100644 index 0000000..08e1cdb --- /dev/null +++ b/core/backend/probe_format_support.py @@ -0,0 +1,470 @@ +#!/usr/bin/env python +"""出格式能力探针 · 三家模型 × 三种策略 → 真·normalize_draft 验收。 + +测什么:在"按现在的代码"的前提下,豆包 / GPT-5.x / Gemini 各自能不能稳定吐出一份 +符合 ScriptDraft 契约的结构,以及三种约束策略各自的成功率与"原始输出干净度"。 + +三家(都走 OpenAI 兼容 chat/completions,凭证读 settings.PROVIDER_KEYS / PROVIDER_BASE_URLS): + - 豆包 doubao → provider "volcengine" 直连 + - GPT gpt → provider "tokenssr" 中转 + - Gemini gemini → provider "tokenssr" 中转(一把 key 通吃,见 settings.base:198) + +三种策略: + A. freeform —— 现状:skill 提示词 + 自由文本,完全靠后端 normalize_draft 兜底抽取 + B. structured —— response_format=json_schema(strict),解码期约束输出为契约 JSON + C. tool —— function-calling,tool_choice 强制调用 emit_script_draft(参数=契约) + +每发产出都做两层判定: + raw_clean : 原始输出本身就是合法且含 segments 的 JSON(= 约束真的生效,没靠 normalize 抢救) + normalized : 喂进真·apps.ai.script_agent.normalize_draft 后能产出非空分镜(= 最终下游能用) + +用法: + cd core/backend && python probe_format_support.py + # 改模型名(默认值见 DEFAULT_MODELS): + PROBE_GPT_MODEL=gpt-5.5 PROBE_GEMINI_MODEL=gemini-2.5-flash python probe_format_support.py + # 只测某几家 / 某几策略: + python probe_format_support.py --only doubao,gpt --strategies freeform,tool + +不写库、不计费、不依赖 DB:只调 normalize_draft / load_ecommerce_skill(纯函数 + 读文件)。 +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from pathlib import Path + +import django +import requests + +BASE_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(BASE_DIR)) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development") +django.setup() + +from django.conf import settings # noqa: E402 + +from types import SimpleNamespace # noqa: E402 + +from apps.ai.script_agent import ( # noqa: E402 + _SEGMENT_ARRAY_KEYS, + _extract_json, + _resolve_segments, + build_agent_messages, + normalize_draft, +) + +# --------------------------------------------------------------------------- # +# 目标模型:label → (provider.name, 默认模型名, env 覆盖键) +# --------------------------------------------------------------------------- # +DEFAULT_MODELS = { + "doubao": ("volcengine", "doubao-seed-2-0-pro-260215", "PROBE_DOUBAO_MODEL"), + "gpt": ("tokenssr", "gpt-5.5", "PROBE_GPT_MODEL"), + "gemini": ("tokenssr", "gemini-3.1-pro-preview", "PROBE_GEMINI_MODEL"), +} + +ASPECT_RATIO = "9:16" +TOTAL_DURATION = 60 # → 4 镜 + +# --------------------------------------------------------------------------- # +# 模拟数据:一款保温杯,塑成 Django 模型同形对象,喂进真·build_agent_messages +# (复现"全自动①"真实场景:系统提示词=skill+_OUTPUT_PROTOCOL,user=真 _product_context) +# --------------------------------------------------------------------------- # +class _FakeManager: + """仿 product.selling_points:支持 .all() / .filter(id__in=...),够 _product_context 用。""" + + def __init__(self, items): + self._items = list(items) + + def all(self): + return self._items + + def filter(self, id__in=None, **_): + if id__in is None: + return self._items + return [s for s in self._items if s.id in set(id__in)] + + +def _make_fake_project(): + selling = [ + SimpleNamespace(id="sp1", title="长效保温", detail="晚上灌开水,第二天早上还温热"), + SimpleNamespace(id="sp2", title="单手弹盖", detail="开会/带娃腾不出第二只手也能喝"), + SimpleNamespace(id="sp3", title="防漏密封", detail="塞进通勤包横放也不洒"), + ] + product = SimpleNamespace( + title="暖岚 316 不锈钢保温杯 500ml", + brand="暖岚", + category="水具/保温杯", + target_audience="久坐办公室的年轻白领、熬夜党", + description="24 小时长效保温,一键弹盖单手开,食品级 316 内胆,防漏防烫。", + selling_points=_FakeManager(selling), + ) + return SimpleNamespace(product=product, metadata={}) + +# --------------------------------------------------------------------------- # +# ScriptDraft JSON Schema(契约的可机读版本,供 structured / tool 两策略约束解码) +# 与 script_agent.normalize_draft 期望的字段对齐;故意保留 strict 友好形态。 +# --------------------------------------------------------------------------- # +SEGMENT_SCHEMA = { + "type": "object", + "properties": { + "index": {"type": "integer"}, + "duration": {"type": "integer"}, + "role": {"type": "string", "enum": ["钩子", "痛点", "卖点", "CTA"]}, + "narration": {"type": "string", "description": "这一镜口播/旁白,≤55字"}, + "visual": {"type": "string", "description": "画面:主体+动作+景别/运镜+变化,40-70字"}, + "product_exposure": {"type": "string"}, + "entity_refs": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["index", "duration", "role", "narration", "visual", "product_exposure", "entity_refs"], + "additionalProperties": False, +} +ENTITY_SCHEMA = { + "type": "object", + "properties": { + "id": {"type": "string"}, + "type": {"type": "string", "enum": ["character", "scene", "product"]}, + "name": {"type": "string"}, + "visual_prompt": {"type": "string"}, + "ref_index": {"type": "integer"}, + }, + "required": ["id", "type", "name", "visual_prompt", "ref_index"], + "additionalProperties": False, +} +DRAFT_SCHEMA = { + "type": "object", + "properties": { + "hook": {"type": "string"}, + "tone": {"type": "string", "enum": ["种草", "测评", "剧情", "痛点"]}, + "aspect_ratio": {"type": "string"}, + "total_duration": {"type": "integer", "description": "总时长,取 15/30/60/90 之一"}, + "segment_count": {"type": "integer"}, + "entities": {"type": "array", "items": ENTITY_SCHEMA}, + "segments": {"type": "array", "items": SEGMENT_SCHEMA}, + }, + "required": ["hook", "tone", "aspect_ratio", "total_duration", "segment_count", "entities", "segments"], + "additionalProperties": False, +} + +TOOL_DEF = { + "type": "function", + "function": { + "name": "emit_script_draft", + "description": "提交一份符合 AirShelf 契约的带货短视频脚本草稿", + "parameters": DRAFT_SCHEMA, + }, +} + + +def _creds(provider_name: str) -> tuple[str | None, str | None]: + base = settings.PROVIDER_BASE_URLS.get(provider_name) + key = settings.PROVIDER_KEYS.get(provider_name) + return (base or None), (key or None) + + +def _messages() -> list[dict]: + # 走真·build_agent_messages(全自动①):与线上发给模型的 system+user 一字不差 + # (system = skill + _OUTPUT_PROTOCOL;user = 真 _product_context 拼的商品上下文) + return build_agent_messages( + project=_make_fake_project(), + mode="auto", + user_prompt="", + selling_point_ids=None, + base_draft=None, + aspect_ratio=ASPECT_RATIO, + total_duration=TOTAL_DURATION, + ) + + +def _post(base_url: str, api_key: str, body: dict) -> dict: + resp = requests.post( + f"{base_url.rstrip('/')}/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json=body, + timeout=240, + ) + if not resp.ok: + # 抽真实报错(不支持 response_format / tool 等会在这里暴露) + detail = "" + try: + detail = json.dumps(resp.json().get("error") or resp.json(), ensure_ascii=False)[:300] + except Exception: + detail = (resp.text or "")[:300] + raise RuntimeError(f"HTTP {resp.status_code}: {detail}") + return resp.json() + + +def _build_body(model: str, strategy: str) -> dict: + body: dict = {"model": model, "messages": _messages(), "temperature": 0.85, "stream": False} + if strategy == "structured": + body["response_format"] = { + "type": "json_schema", + "json_schema": {"name": "script_draft", "strict": True, "schema": DRAFT_SCHEMA}, + } + elif strategy == "tool": + body["tools"] = [TOOL_DEF] + body["tool_choice"] = {"type": "function", "function": {"name": "emit_script_draft"}} + return body + + +def _extract_output(data: dict, strategy: str) -> str: + """取这一发的"待判定文本":tool 取 arguments,其余取 content。""" + choice = (data.get("choices") or [{}])[0] + msg = choice.get("message") or {} + if strategy == "tool": + calls = msg.get("tool_calls") or [] + if not calls: + return "" # 没吐 tool_call = 约束没生效 + return calls[0].get("function", {}).get("arguments") or "" + content = msg.get("content") + if isinstance(content, list): # 个别网关把 content 拆成块 + return "".join(c.get("text", "") for c in content if isinstance(c, dict)) + return content or "" + + +def _raw_is_clean(text: str) -> bool: + """原始输出本身就是合法且含 segments 的 JSON(= 约束真生效,无需 normalize 抢救)。""" + try: + obj = json.loads(text) + except (ValueError, TypeError): + return False + return isinstance(obj, dict) and isinstance(obj.get("segments"), list) and len(obj["segments"]) > 0 + + +def _raw_structure(text: str) -> dict: + """用生产同款 _extract_json/_resolve_segments 解析模型**原始**输出,提取它真实用的 + segments 数组键名 + 每镜键集 + 每实体键集 + 顶层键集。用于跨模型逐键比对(normalize 之前)。""" + blob = _extract_json(text) + if not blob: + return {"parse": False} + try: + obj = json.loads(blob) + except (ValueError, TypeError): + return {"parse": False} + if not isinstance(obj, dict): + return {"parse": False} + seg_array_key = next( + (k for k in _SEGMENT_ARRAY_KEYS if isinstance(obj.get(k), list) and obj.get(k)), None + ) + segs = _resolve_segments(obj) + seg_keys = sorted({k for s in segs if isinstance(s, dict) for k in s}) + ents = obj.get("entities") if isinstance(obj.get("entities"), list) else [] + ent_keys = sorted({k for e in ents if isinstance(e, dict) for k in e}) + return { + "parse": True, + "seg_array_key": seg_array_key, + "top_keys": sorted(obj.keys()), + "seg_keys": seg_keys, + "ent_keys": ent_keys, + "n_seg": len(segs), + } + + +def _seg_quality(draft: dict) -> tuple[int, int]: + """(总镜数, 旁白+画面都非空的镜数)——衡量 normalize 后是否真有内容,不是空骨架。""" + segs = draft.get("segments") or [] + full = sum(1 for s in segs if (s.get("narration") or "").strip() and (s.get("visual") or "").strip()) + return len(segs), full + + +def run_cell(label: str, model: str, base_url: str, api_key: str, strategy: str) -> dict: + t0 = time.time() + cell = {"label": label, "model": model, "strategy": strategy, "ok": False, "raw_clean": False, + "segs": 0, "full": 0, "secs": 0.0, "note": "", "raw_preview": "", "draft": None, + "raw_struct": {"parse": False}} + try: + data = _post(base_url, api_key, _build_body(model, strategy)) + text = _extract_output(data, strategy) + if not text.strip(): + cell["note"] = "空输出(tool 未触发 / content 为空)" + return cell + cell["raw_preview"] = text.strip()[:600] # 原始输出头部(看模型有没有裹散文/围栏) + cell["raw_struct"] = _raw_structure(text) # 原始 JSON 结构签名(segments/entity 键集),供逐键 diff + cell["raw_clean"] = _raw_is_clean(text) + # 真·生产 normalizer 验收 + draft = normalize_draft(text, aspect_ratio=ASPECT_RATIO, total_duration=TOTAL_DURATION) + cell["draft"] = draft # 存下规范化后的成稿,供 dump 渲染 + segs, full = _seg_quality(draft) + cell["segs"], cell["full"] = segs, full + cell["ok"] = segs == (TOTAL_DURATION // 15) and full == segs # 镜数对 + 每镜都有词有画面 + if not cell["ok"]: + cell["note"] = f"normalize 后 {full}/{segs} 镜有完整内容(期望 {TOTAL_DURATION // 15} 镜全满)" + except Exception as exc: # noqa: BLE001 + cell["note"] = str(exc)[:200] + finally: + cell["secs"] = round(time.time() - t0, 1) + return cell + + +def _render_draft_md(draft: dict) -> str: + """把规范化后的 ScriptDraft 渲染成可读 markdown(hook/tone/实体/逐镜旁白+画面)。""" + if not draft: + return "_(无成稿)_" + lines = [ + f"- **hook**:{draft.get('hook', '')}", + f"- **tone**:{draft.get('tone', '')} · **时长**:{draft.get('total_duration')}s · **画幅**:{draft.get('aspect_ratio')}", + ] + ents = draft.get("entities") or [] + if ents: + ent_str = "、".join(f"{e.get('name')}({e.get('type')})" for e in ents) + lines.append(f"- **实体**({len(ents)}):{ent_str}") + lines.append("") + lines.append("| 镜 | role | narration(口播) | visual(画面) |") + lines.append("| -- | ---- | ------------- | ----------- |") + for s in draft.get("segments") or []: + nar = (s.get("narration") or "").replace("|", "\\|").replace("\n", " ") + vis = (s.get("visual") or "").replace("|", "\\|").replace("\n", " ") + lines.append(f"| {s.get('index')} | {s.get('role', '')} | {nar} | {vis} |") + return "\n".join(lines) + + +def write_dump(path: str, results: list[dict], strategies: list[str]) -> None: + import datetime + + out = [ + "# 出格式能力探针 · 模型实际产出汇总", + "", + f"> 生成时间:{datetime.datetime.now():%Y-%m-%d %H:%M} · 模拟商品:暖岚 316 保温杯 · 期望 {TOTAL_DURATION // 15} 镜 / 画幅 {ASPECT_RATIO}", + "", + "判定:`✅`=normalize 后镜数对且每镜有词有画面 `raw_clean`=模型原始输出本身就是合规 JSON(约束真生效,未靠后端抢救)", + "", + "## 速览矩阵", + "", + "| 模型 | " + " | ".join(strategies) + " |", + "| ---- | " + " | ".join(["----"] * len(strategies)) + " |", + ] + labels = [] + for r in results: + if r["label"] not in labels: + labels.append(r["label"]) + for label in labels: + cells = [] + for s in strategies: + c = next((r for r in results if r["label"] == label and r["strategy"] == s), None) + if not c: + cells.append("-"); continue + mark = ("✅" if c["ok"] else "❌") + ("·raw✓" if c["raw_clean"] else "·raw✗") + f" {c['secs']}s" + cells.append(mark) + model = next((r["model"] for r in results if r["label"] == label), "") + out.append(f"| **{label}**
`{model}` | " + " | ".join(cells) + " |") + out.append("") + + # 原始结构一致性(normalize 之前):逐策略比三家模型原始 JSON 的键集是否同构 + out.append("## 原始结构一致性(normalize 之前 · 逐键 diff)\n") + out.append("> 看的是模型**原始吐出**的 JSON 用什么键,不是 normalize 抹平后的。`seg_array_key`=模型装分镜用的数组键名;" + "`seg_keys`=每镜的键集;`ent_keys`=每实体的键集。三家在同一策略下若键集相同即「同构」。\n") + for s in strategies: + out.append(f"### 策略:{s}\n") + out.append("| 模型 | parse | seg_array_key | segments 每镜键集 | entities 每实体键集 |") + out.append("| ---- | ----- | ------------- | ---------------- | ------------------ |") + sig_segs, sig_ents = [], [] + for label in labels: + c = next((r for r in results if r["label"] == label and r["strategy"] == s), None) + st = (c or {}).get("raw_struct") or {"parse": False} + if not st.get("parse"): + out.append(f"| {label} | ❌ 解析失败 | - | - | - |") + continue + sk = ",".join(st["seg_keys"]); ek = ",".join(st["ent_keys"]) + sig_segs.append(sk); sig_ents.append(ek) + out.append(f"| {label} | ✓ | `{st['seg_array_key']}` | `{sk}` | `{ek}` |") + same_seg = len(set(sig_segs)) <= 1 and len(sig_segs) == len([l for l in labels]) + same_ent = len(set(sig_ents)) <= 1 and len(sig_ents) == len([l for l in labels]) + verdict = [] + verdict.append("segments 键集" + ("**完全同构**✅" if same_seg else "**有差异**⚠️")) + verdict.append("entities 键集" + ("**完全同构**✅" if same_ent else "**有差异**⚠️")) + out.append(f"\n→ {' · '.join(verdict)}\n") + out.append("") + + # 逐发明细 + for label in labels: + model = next((r["model"] for r in results if r["label"] == label), "") + out.append(f"\n---\n\n## {label} · `{model}`\n") + for s in strategies: + c = next((r for r in results if r["label"] == label and r["strategy"] == s), None) + if not c: + continue + flag = "✅" if c["ok"] else "❌" + # 模型名写进每个子标题:成稿表格很长,滚到中间时 ## 大标题已滚出屏,子标题须自带身份 + out.append(f"### {flag} 【{label} · {model}】策略:{s} · {c['secs']}s · raw_clean={'是' if c['raw_clean'] else '否'}") + if c["note"]: + out.append(f"\n> ⚠️ {c['note']}\n") + out.append("\n**规范化成稿:**\n") + out.append(_render_draft_md(c["draft"])) + if c["raw_preview"]: + out.append("\n
原始输出预览(前 600 字)\n") + out.append("\n```\n" + c["raw_preview"] + "\n```\n") + out.append("
") + out.append("") + Path(path).write_text("\n".join(out), encoding="utf-8") + print(f"\n📄 已写出:{path}") + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--only", default="", help="逗号分隔:doubao,gpt,gemini") + ap.add_argument("--strategies", default="freeform,structured,tool") + ap.add_argument("--dump", default="", help="把每发真实产出写成 markdown 报告到此路径") + args = ap.parse_args() + + only = {x.strip() for x in args.only.split(",") if x.strip()} or set(DEFAULT_MODELS) + strategies = [s.strip() for s in args.strategies.split(",") if s.strip()] + + print(f"\n出格式能力探针 · 模拟商品=暖岚保温杯 · 期望 {TOTAL_DURATION // 15} 镜 · 画幅 {ASPECT_RATIO}\n") + + targets = [] + for label, (provider_name, default_model, env_key) in DEFAULT_MODELS.items(): + if label not in only: + continue + base_url, api_key = _creds(provider_name) + model = os.getenv(env_key, default_model) + if not base_url or not api_key: + print(f" ⏭ {label:8s} 跳过:provider '{provider_name}' 凭证缺失(检查 .env)") + continue + targets.append((label, provider_name, model, base_url, api_key)) + + if not targets: + print("没有可测目标(凭证全缺)。"); return + + results: list[dict] = [] + for label, provider_name, model, base_url, api_key in targets: + print(f"\n▶ {label} ({provider_name} · {model})") + for strategy in strategies: + cell = run_cell(label, model, base_url, api_key, strategy) + results.append(cell) + flag = "✅" if cell["ok"] else "❌" + raw = "raw_clean✓" if cell["raw_clean"] else "raw_clean✗" + line = f" {flag} {strategy:10s} {raw} segs {cell['full']}/{cell['segs']} {cell['secs']}s" + if cell["note"]: + line += f" · {cell['note']}" + print(line) + + # 汇总矩阵 + print("\n" + "=" * 72) + print("汇总(✅=normalize 后镜数对且每镜有词有画面 / raw=原始输出本身就合规)") + print("=" * 72) + header = f"{'model':24s} " + " ".join(f"{s:>12s}" for s in strategies) + print(header) + for label, (_, _, _) in DEFAULT_MODELS.items(): + if label not in only: + continue + row = next((r for r in results if r["label"] == label), None) + if row is None: + continue + cells = [] + for s in strategies: + c = next((r for r in results if r["label"] == label and r["strategy"] == s), None) + if c is None: + cells.append(f"{'-':>12s}"); continue + mark = ("✅" if c["ok"] else "❌") + ("R" if c["raw_clean"] else " ") + cells.append(f"{mark:>12s}") + print(f"{label:24s} " + " ".join(cells)) + print() + + if args.dump: + write_dump(args.dump, results, strategies) + + +if __name__ == "__main__": + main() diff --git a/core/backend/storyboard_style_demo.py b/core/backend/storyboard_style_demo.py new file mode 100644 index 0000000..e05f86a --- /dev/null +++ b/core/backend/storyboard_style_demo.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python +"""故事板「画风锚点」对比 demo。 + +目的:直观演示"锁画风"——同一组分镜,各生成两版: + A. 无锚点(no_anchor) —— 模拟现状故事板提示词,只说"导演故事板…画面清晰",不规定画风 + B. 有锚点(with_anchor)—— 同样内容 + 注入一段「统一画风锚点」(写实电商摄影棚风/统一色调光线构图) + +每版各出 4 帧(钩子/痛点/卖点/CTA),存盘后肉眼对比: + - no_anchor 组:帧与帧画风易漂(这次写实、那次插画、景别色调各异) + - with_anchor 组:4 帧像同一套片子(统一风格) + +走真·图像模型(yunqi/gpt-image-2,纯文生图,隔离出"锚点"这一个变量,不掺参考图)。 +输出:../docs/storyboard-style-demo/{no_anchor,with_anchor}/frame{N}.png + prompts.md + +用法:cd backend && python storyboard_style_demo.py +""" +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +import django + +BASE_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(BASE_DIR)) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development") +django.setup() + +from apps.ai.models import ModelConfig # noqa: E402 +from apps.ai.providers import VolcanoArkProvider # noqa: E402 — media_to_bytes 复用 +from apps.ai.services import build_provider, get_default_model # noqa: E402 + +OUT = BASE_DIR.parent / "docs" / "storyboard-style-demo" +SIZE = "1024x1536" # 9:16 竖屏,与故事板一致 + +# --------------------------------------------------------------------------- # +# 模拟数据:保温杯 4 分镜(钩子→痛点→卖点→CTA),每帧一句画面描述 +# --------------------------------------------------------------------------- # +FRAMES = [ + ("钩子", "年轻女白领坐在办公室工位,皱眉摸了摸桌上凉掉的水杯,抬头看镜头一脸无奈"), + ("痛点", "女白领从通勤包里拿出漏水的旧保温杯,纸巾擦被打湿的笔记本,表情懊恼"), + ("卖点", "女白领单手按下保温杯一键弹盖,杯口冒出热气,桌面横放杯子滴水不漏"), + ("CTA", "女白领手持保温杯对镜头微笑展示,画面右下角出现购物车引导点击"), +] + +# --------------------------------------------------------------------------- # +# A. 无锚点:模拟现状故事板提示词(无任何画风约束) +# --------------------------------------------------------------------------- # +def prompt_no_anchor(role: str, scene: str) -> str: + return ( + f"根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。\n" + f"【镜头功能】{role}\n【画面】{scene}\n" + f"电商竖屏 9:16 导演故事板,画面清晰。" + ) + + +# --------------------------------------------------------------------------- # +# B. 有锚点:同内容 + 一段「统一画风锚点」(每帧逐字相同,把风格焊死) +# --------------------------------------------------------------------------- # +STYLE_ANCHOR = ( + "【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】\n" + "· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通;\n" + "· 布光:统一柔和影棚顺光,同一色温(暖白);\n" + "· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景;\n" + "· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度;\n" + "· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装);\n" + "· 商品:全片同一只白色保温杯(同一外形/配色/logo)。" +) + + +def prompt_with_anchor(role: str, scene: str) -> str: + return ( + f"根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。\n" + f"【镜头功能】{role}\n【画面】{scene}\n" + f"{STYLE_ANCHOR}\n" + f"电商竖屏 9:16 导演故事板,画面清晰。" + ) + + +def gen_one(provider, model, prompt: str, save_path: Path) -> str: + resp = provider.image_generation(model=model.name, endpoint=model.endpoint, prompt=prompt, size=SIZE) + media = provider.extract_first_media_url(resp) + fileobj, _ct = VolcanoArkProvider.media_to_bytes(media) + save_path.write_bytes(fileobj.getvalue()) + return str(save_path) + + +def main() -> None: + model = get_default_model(ModelConfig.Capability.IMAGE) + provider = build_provider(model) + print(f"图像模型:{model.provider.name}/{model.name} · 尺寸 {SIZE}") + (OUT / "no_anchor").mkdir(parents=True, exist_ok=True) + (OUT / "with_anchor").mkdir(parents=True, exist_ok=True) + + prompts_md = ["# 故事板画风锚点 demo · 用到的提示词\n", + f"> 图像模型:{model.provider.name}/{model.name} · 模拟商品:保温杯 · 4 分镜\n"] + + for variant, builder in (("no_anchor", prompt_no_anchor), ("with_anchor", prompt_with_anchor)): + label = "无锚点(现状)" if variant == "no_anchor" else "有锚点(锁画风)" + print(f"\n===== {label} =====") + prompts_md.append(f"\n## {label}\n") + for i, (role, scene) in enumerate(FRAMES, 1): + prompt = builder(role, scene) + path = OUT / variant / f"frame{i}_{role}.png" + t0 = time.time() + try: + gen_one(provider, model, prompt, path) + print(f" ✅ 第{i}镜 {role} {round(time.time()-t0,1)}s → {path.name}") + except Exception as exc: # noqa: BLE001 + print(f" ❌ 第{i}镜 {role} {str(exc)[:160]}") + prompts_md.append(f"\n### 第{i}镜 · {role}\n\n```\n{prompt}\n```\n") + + (OUT / "prompts.md").write_text("\n".join(prompts_md), encoding="utf-8") + print(f"\n📁 图片+提示词已存:{OUT}") + print(" 对比:no_anchor/ 各帧画风易漂 vs with_anchor/ 4 帧同一套风格") + + +if __name__ == "__main__": + main() diff --git a/core/backend/tryon_prompt_test.py b/core/backend/tryon_prompt_test.py new file mode 100644 index 0000000..12f3b7f --- /dev/null +++ b/core/backend/tryon_prompt_test.py @@ -0,0 +1,171 @@ +"""模特上身图 · 新提示词效果测试脚本(一次性,可删)。 + +设计:**提示词模板固定**(下面 TEMPLATE/变化表/负面词都是常量), + **商品参数是变量**(按 PRODUCT_ID 从库里查出来填进模板)。 + +跑法: + .venv/bin/python tryon_prompt_test.py +产物:把生成图写到 OUT_DIR,顺带打印每张实际发出去的提示词。 +""" +import os +import sys +import uuid + +import django + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development") +django.setup() + +from apps.products.models import Product # noqa: E402 +from apps.assets.models import Asset # noqa: E402 +from apps.ai.models import ModelConfig # noqa: E402 +from apps.ai.services import ( # noqa: E402 + get_default_model, + get_image_provider, + _product_cover_url, + _asset_preview_url, + _ratio_to_image_size, +) +from apps.ai.providers.volcano import VolcanoArkProvider # noqa: E402 + +# ───────────────────────── 可调输入(变量) ───────────────────────── +PRODUCT_ID = "045ad8d6-8b15-486b-a4a9-f6968eb1555f" # 南卡 Lite Pro 蓝牙耳机 +# 商品参考图(可多张):图1=真实上传(干净外形) + 图2=NANK 多角度(带品牌)。两张一起喂,锁外形+锁品牌。 +PRODUCT_IMAGE_IDS = [ + "c8c47a2e-6f07-43b2-b776-1be2a401538a", # 真实上传 · 图1 + "463a826c-2adc-43c8-b776-2eea973f0449", # NANK 多角度(带 Logo) +] +MODEL_ID = "ad362810-cf3e-49c0-9426-8142a74b75b2" # 一位真人模特 +RATIO = "3:4" +COUNT = 3 # 生成张数(每张动作/场景/镜头不同) +OUT_DIR = "/Users/maidong/Desktop/zyc/tryon_prompt_test2" + +# ═════════════════════════ 固定提示词模板 ═════════════════════════ +# 占位符全部由商品/模特变量填入;模板本身不随商品变。 +TEMPLATE = ( + "{product_intro}" + "请生成参考图{model_idx}中这位模特{relation}的电商详情页效果图。" + "模特要求:五官、发型、肤色、身形、年龄与气质必须与参考图{model_idx}(模特图)高度一致,不要换人,不要自行生成另一位模特。" + "商品要求:外形、配色、材质、品牌文字与 Logo、图案必须与{product_ref_label}(商品图)严格一致,不要重新设计、不要改样、不要生成相似款。" + "本张画面:{action};场景:{scene};镜头:{shot}。" + "画面风格:真实电商摄影,自然光,干净背景,主体突出,商品细节清晰,模特姿态自然,单人,高分辨率,{ratio} 构图。" + "(商品类目:{category};卖点:{selling_points}。以上文字仅供理解商品,不要据此凭空改变商品外观。)" + " 请规避:{negative}" +) + +# 穿戴 / 非穿戴 两套「商品-模特关系」从句(固定) +RELATION_WEARABLE = ( + "真实穿着参考图1中的这件商品,替换掉模特原本的衣服,让商品自然合身地穿在身上," + "而不是把商品放在一旁展示;保持商品的版型、领口、袖型、长度、纹样不变" +) +RELATION_NON_WEARABLE = ( + "自然地手持 / 在合适位置佩戴 / 正在使用参考图1中的这件商品(如为耳机则佩戴在耳朵上)," + "不要改动模特原本的服装,不要把商品强行穿到身上,商品以真实合理的方式出现在模特手中或身上" +) + +# 每张按序号变化(固定的变化表;非穿戴默认) +VARIATIONS = [ + {"action": "模特正面自然展示该商品", "scene": "干净的室内空间", "shot": "半身近景,商品清晰可见"}, + {"action": "模特正在使用该商品", "scene": "生活化的真实居家场景", "shot": "侧面角度,突出使用方式"}, + {"action": "模特手持并展示商品细节", "scene": "明亮的时尚生活场景", "shot": "中近景,商品占比较高"}, + {"action": "模特与商品自然互动", "scene": "温暖时尚的生活场景", "shot": "半身,强调使用情境"}, +] + +NEGATIVE = ( + "不要换人,不要改商品设计,不要改商品颜色与结构,不要生成错误或乱码的 Logo 与文字," + "不要把商品改成相似款,不要多余的商品堆叠,不要多余文字,不要水印,不要边框," + "不要低清模糊,不要过度磨皮,不要畸变,不要扭曲身体,不要夸张滤镜" +) + +# 穿戴类类目关键词(命中即按穿戴从句) +WEARABLE_HINTS = ("服", "衣", "裤", "裙", "鞋", "帽", "袜", "围巾", "外套", "T恤", "卫衣", "内衣", "泳") + + +def is_wearable(category: str, title: str) -> bool: + blob = f"{category} {title}" + return any(h in blob for h in WEARABLE_HINTS) + + +def build_product_intro(name: str, n_product: int) -> tuple[str, int, str]: + """据商品参考图数量自适应序号:N 张商品 → 参考图1~N=商品, 参考图N+1=模特。 + 返回 (intro, 模特图序号, 商品图序号标签)。""" + if n_product <= 1: + intro = f"参考图1是「{name}」的真实商品图,是该商品外观的唯一依据。参考图2是出镜模特。" + return intro, 2, "参考图1" + rng = f"1-{n_product}" if n_product > 2 else "1、2" + intro = ( + f"参考图{rng}是「{name}」同一件真实商品的不同角度图,是该商品外观的唯一依据," + f"请综合这些角度还原商品。参考图{n_product + 1}是出镜模特。" + ) + return intro, n_product + 1, f"参考图{rng}" + + +def build_prompt(product, index: int, ratio: str, n_product: int) -> str: + name = (product.title or "商品").strip() + category = (product.category or "").strip() + points = list(product.selling_points.all().values_list("title", flat=True)) + selling = "、".join(points) if points else "(无)" + relation = RELATION_WEARABLE if is_wearable(category, name) else RELATION_NON_WEARABLE + var = VARIATIONS[index % len(VARIATIONS)] + product_intro, model_idx, product_ref_label = build_product_intro(name, n_product) + return TEMPLATE.format( + product_intro=product_intro, + model_idx=model_idx, + product_ref_label=product_ref_label, + relation=relation, + action=var["action"], + scene=var["scene"], + shot=var["shot"], + ratio=ratio, + category=category or "(未填)", + selling_points=selling, + negative=NEGATIVE, + ) + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + product = Product.objects.get(id=PRODUCT_ID) + model_asset = Asset.objects.get(id=MODEL_ID) + product_urls = [] + for aid in PRODUCT_IMAGE_IDS: + a = Asset.objects.get(id=aid) + u = _asset_preview_url(a) + if u: + product_urls.append(u) + print(f"商品参考图: {a.name} | {a.source} | {u[:80]}") + model_url = _asset_preview_url(model_asset) + if not product_urls: + print("❌ 没有可用的商品参考图。") + sys.exit(1) + n_product = len(product_urls) + print(f"商品: {product.title} | 类目: {product.category} | 穿戴类: {is_wearable(product.category or '', product.title)} | 商品参考图数: {n_product}") + print(f"模特: {model_asset.name} | {model_url[:80]}") + + model_config = get_default_model(ModelConfig.Capability.IMAGE) + provider = get_image_provider(model_config) + print(f"模型: {model_config.provider.name}:{model_config.name}\n") + + images = product_urls + [model_url] # 参考图1~N=商品多角度, 参考图N+1=模特 + size = _ratio_to_image_size(RATIO) + + for i in range(COUNT): + prompt = build_prompt(product, i, RATIO, n_product) + print(f"━━━ 第 {i + 1} 张 ━━━\n{prompt}\n") + try: + resp = provider.image_edit(model=model_config.name, prompt=prompt, images=images, size=size) + media = provider.extract_first_media_url(resp) + fileobj, content_type = VolcanoArkProvider.media_to_bytes(media) + ext = ".jpg" if "jpeg" in content_type else (".webp" if "webp" in content_type else ".png") + path = os.path.join(OUT_DIR, f"tryon_{i + 1}{ext}") + with open(path, "wb") as f: + f.write(fileobj.getvalue()) + print(f"✅ 第 {i + 1} 张 → {path}\n") + except Exception as exc: # noqa: BLE001 + print(f"❌ 第 {i + 1} 张失败: {exc}\n") + + print(f"完成。产物目录: {OUT_DIR}") + + +if __name__ == "__main__": + main() diff --git a/core/backend/tryon_underwear_test.py b/core/backend/tryon_underwear_test.py new file mode 100644 index 0000000..8842eed --- /dev/null +++ b/core/backend/tryon_underwear_test.py @@ -0,0 +1,86 @@ +"""内衣品类(文胸)测试:对比两条路线能不能出图。 + 路线A · 真人上身(穿戴类) —— 验证 gpt-image-2 内容审核会不会拦(safety=[sexual]) + 路线B · 隐形人台/平铺(无真人) —— 验证绕开审核的安全做法 + +跑法: .venv/bin/python tryon_underwear_test.py +产物: /Users/maidong/Desktop/zyc/underwear_test +""" +import os +import django + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development") +django.setup() + +from apps.products.models import Product # noqa: E402 +from apps.assets.models import Asset # noqa: E402 +from apps.ai.models import ModelConfig # noqa: E402 +from apps.ai.services import ( # noqa: E402 + get_default_model, get_image_provider, _asset_preview_url, _ratio_to_image_size, +) +from apps.ai.providers.volcano import VolcanoArkProvider # noqa: E402 + +PRODUCT_ID = "42078cdc-8714-4fc7-be5c-6aa42b7fc995" # 棉居莫代尔无痕内衣(真实主图) +MODEL_ID = "ad362810-cf3e-49c0-9426-8142a74b75b2" # 一位真人模特 +RATIO = "3:4" +OUT_DIR = "/Users/maidong/Desktop/zyc/underwear_test" + +# 路线A · 真人上身(穿戴类从句) +PROMPT_ON_BODY = ( + "参考图1是「{name}」的真实商品图,是商品外观的唯一依据。参考图2是出镜模特。" + "请生成参考图2中这位模特真实穿着参考图1中这件内衣的电商详情页效果图,得体、健康、非裸露," + "覆盖充分,运动内衣风格,商品的颜色、版型、领口、肩带、纹样、Logo 必须与参考图1严格一致。" + "模特五官/发型/肤色/身形与参考图2一致,不要换人。真实电商摄影,自然光,干净背景,单人,{ratio} 构图。" + " 请规避:不要裸露,不要性暗示,不要换人,不要改商品设计与颜色,不要乱码文字,不要水印。" +) + +# 路线B · 隐形人台 / 平铺(无真人,绕开审核) +PROMPT_GHOST = ( + "参考图1是「{name}」的真实商品图。请生成该内衣的电商主图:采用隐形人台(ghost mannequin)立体悬浮效果," + "衣服呈现被穿着时的自然立体版型与轮廓,但画面中没有真人、没有人体、不出现任何皮肤或身体部位。" + "商品的颜色、版型、肩带、领口、纹理、Logo 必须与参考图1严格一致,不要重新设计。" + "柔和自然光,干净纯净背景,商品居中,细节清晰,高分辨率,{ratio} 构图。" + " 请规避:不要出现人、不要皮肤、不要模特、不要改商品颜色与版型、不要乱码文字、不要水印。" +) + + +def run(provider, model_name, label, prompt, images, size): + print(f"\n━━━ {label} ━━━\n{prompt}\n图数={len(images)}") + try: + resp = provider.image_edit(model=model_name, prompt=prompt, images=images, size=size) + media = provider.extract_first_media_url(resp) + fileobj, ct = VolcanoArkProvider.media_to_bytes(media) + ext = ".jpg" if "jpeg" in ct else (".webp" if "webp" in ct else ".png") + path = os.path.join(OUT_DIR, f"{label}{ext}") + with open(path, "wb") as f: + f.write(fileobj.getvalue()) + print(f"✅ 出图成功 → {path}") + except Exception as exc: # noqa: BLE001 + msg = str(exc) + flagged = any(k in msg.lower() for k in ("moderation", "safety", "sexual", "blocked", "content_policy", "rejected")) + print(f"❌ 失败{' · 命中内容审核' if flagged else ''}: {msg[:600]}") + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + p = Product.objects.get(id=PRODUCT_ID) + product_url = _asset_preview_url(p.cover_asset) + model_url = _asset_preview_url(Asset.objects.get(id=MODEL_ID)) + mc = get_default_model(ModelConfig.Capability.IMAGE) + provider = get_image_provider(mc) + size = _ratio_to_image_size(RATIO) + print(f"商品: {p.title} | 类目: {p.category}") + print(f"模型: {mc.provider.name}:{mc.name}") + print(f"商品图: {product_url[:80]}") + + # 路线A:真人上身(商品图 + 模特图) + run(provider, mc.name, "A_真人上身", PROMPT_ON_BODY.format(name=p.title, ratio=RATIO), + [product_url, model_url], size) + # 路线B:隐形人台(仅商品图) + run(provider, mc.name, "B_隐形人台", PROMPT_GHOST.format(name=p.title, ratio=RATIO), + [product_url], size) + + print(f"\n完成。产物: {OUT_DIR}") + + +if __name__ == "__main__": + main() diff --git a/core/backend/《AI 视频生成师分镜脚本撰写流程说明》.docx b/core/backend/《AI 视频生成师分镜脚本撰写流程说明》.docx new file mode 100644 index 0000000..dabf4c8 Binary files /dev/null and b/core/backend/《AI 视频生成师分镜脚本撰写流程说明》.docx differ diff --git a/core/docs/storyboard-style-demo/README.md b/core/docs/storyboard-style-demo/README.md new file mode 100644 index 0000000..45a3d47 --- /dev/null +++ b/core/docs/storyboard-style-demo/README.md @@ -0,0 +1,87 @@ +# 故事板「画风锚点」对比 demo + +> 探针:[`backend/storyboard_style_demo.py`](../../backend/storyboard_style_demo.py) · 图像模型:yunqi/gpt-image-2 · 纯文生图(隔离"锚点"单一变量) +> 同一组保温杯 4 分镜,各出两版:**无锚点(现状)** vs **有锚点(锁画风)**。 + +--- + +## 一、什么是「画风锚点」 + +就是把一段**固定的、逐帧逐字相同的风格规格**注进每一帧的提示词,把模型本来自由发挥的画风焊死。本 demo 用的锚点: + +``` +【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】 +· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通; +· 布光:统一柔和影棚顺光,同一色温(暖白); +· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景; +· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度; +· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装); +· 商品:全片同一只白色保温杯(同一外形/配色/logo)。 +``` + +关键:**这段每一帧都一模一样地拼进去**,模型就被反复约束到同一套风格上,不会这帧写实、那帧插画。 + +--- + +## 二、对比结果 + +### 无锚点(现状)—— 连"产出物形态"都在漂 + +| 帧 | 实际产出 | +| -- | -------- | +| 第1镜 钩子 | 单张「分镜卡」:顶部元数据表格(镜号/景别 MCU/机位)+ 一张照片,蓝调办公室,白衬衫女主 | +| 第2镜 痛点 | 又一张「分镜卡」,但**布局变了**(元数据挪到左栏)+ 灰西装女主 + 暖调 | +| 第3镜 卖点 | **直接画成一整张 4 行分镜缩略表(contact sheet)**——完全不同的产出物 | +| 第4镜 CTA | (同样各画各的) | + +→ 不只是画风漂,**连"画一张图还是画一张分镜表"都每次不一样**;元数据布局、色调、人物也各不相同。 + +| ![](no_anchor/frame1_钩子.png) | ![](no_anchor/frame2_痛点.png) | ![](no_anchor/frame3_卖点.png) | +| --- | --- | --- | +| 第1镜:单张卡(表格在顶) | 第2镜:单张卡(表格在左)| 第3镜:4行分镜表 | + +### 有锚点(锁画风)—— 4 帧像同一套片子 + +| ![](with_anchor/frame1_钩子.png) | ![](with_anchor/frame3_卖点.png) | ![](with_anchor/frame4_CTA.png) | +| --- | --- | --- | +| 第1镜 | 第3镜 | 第4镜 | + +→ **统一写实摄影质感、统一米灰色调、统一柔光、同一位女白领、同一只白色保温杯**——明显是一套连贯的片子。 +画风一致性被锁住了(对比无锚点那组的杂乱一目了然)。 + +--- + +## 三、demo 意外暴露的第二个问题(比画风更值得修) + +**两组都把图画成了"分镜表/分镜卡"(多格 + 元数据列 + 文字标注),而不是干净的单帧画面。** + +根因:提示词里的 **「导演故事板 / 分镜图」** 这几个字,会让图像模型去画一张"分镜文档"(storyboard sheet), +而不是"这一镜的画面"。锚点锁住了**画风**,但没锁住**产出物类型**。 + +雪上加霜:线上 DB 模板把代码默认里的 **「一镜一图」** 删掉了(见下),而"一镜一图"正是约束"出单帧、别出多格表"的那句。 + +- 代码默认:`…电商竖屏 9:16 导演故事板,**一镜一图**,画面清晰…` +- 线上 DB :`…电商竖屏 9:16 导演故事板,画面清晰…`(少了「一镜一图」) + +> 注:线上真实路径走 `image_edit` + 人物参考图,参考图会把结果往"这个人的实拍照"拽,可能比纯文生图更不易出表; +> 但"导演故事板"这个词的风险真实存在,demo 已证。 + +--- + +## 四、改进建议(锚点要同时锁「画风」+「产出物」) + +把 `storyboard_frame` 后台模板的尾段改成**画风锚点 + 产出物锚点**合体,并恢复「一镜一图」: + +``` +…请严格保持各参考图中角色的同一张脸、同一商品的外观与配色; +【统一画风】写实电商摄影棚实拍质感,统一柔和暖白影棚光、统一明亮干净色彩分级、统一中近景居中构图;禁止插画/线稿/漫画/3D卡通。 +【产出物】只输出单张写实画面(一镜一图);禁止多格分镜表、禁止文字标注/字幕、禁止表格/边框/元数据栏。 +电商竖屏 9:16,画面清晰,可直接指导视频生成。 +``` + +要点: +1. **画风锚点**——锁写实/光线/色调/构图(本 demo 已验证有效)。 +2. **产出物锚点**——明确"单张画面、禁止分镜表/文字/表格"(治本次暴露的"画成分镜表")。 +3. **恢复「一镜一图」**——线上被删了,加回。 +4. **人物/服装跨帧锁**——纯文生图下服装仍会微漂(白上衣 vs 西装);彻底锁要靠线上的 `image_edit` 参考图路径(用同一张人物立绘当参考)。 +5. **可选固定 seed**——进一步压跨次随机漂移(需确认 yunqi/gpt-image 支持)。 diff --git a/core/docs/storyboard-style-demo/no_anchor/frame1_钩子.png b/core/docs/storyboard-style-demo/no_anchor/frame1_钩子.png new file mode 100644 index 0000000..8c20666 Binary files /dev/null and b/core/docs/storyboard-style-demo/no_anchor/frame1_钩子.png differ diff --git a/core/docs/storyboard-style-demo/no_anchor/frame2_痛点.png b/core/docs/storyboard-style-demo/no_anchor/frame2_痛点.png new file mode 100644 index 0000000..84d6e15 Binary files /dev/null and b/core/docs/storyboard-style-demo/no_anchor/frame2_痛点.png differ diff --git a/core/docs/storyboard-style-demo/no_anchor/frame3_卖点.png b/core/docs/storyboard-style-demo/no_anchor/frame3_卖点.png new file mode 100644 index 0000000..7377b7e Binary files /dev/null and b/core/docs/storyboard-style-demo/no_anchor/frame3_卖点.png differ diff --git a/core/docs/storyboard-style-demo/no_anchor/frame4_CTA.png b/core/docs/storyboard-style-demo/no_anchor/frame4_CTA.png new file mode 100644 index 0000000..3664da1 Binary files /dev/null and b/core/docs/storyboard-style-demo/no_anchor/frame4_CTA.png differ diff --git a/core/docs/storyboard-style-demo/prompts.md b/core/docs/storyboard-style-demo/prompts.md new file mode 100644 index 0000000..8104d8a --- /dev/null +++ b/core/docs/storyboard-style-demo/prompts.md @@ -0,0 +1,117 @@ +# 故事板画风锚点 demo · 用到的提示词 + +> 图像模型:yunqi/gpt-image-2 · 模拟商品:保温杯 · 4 分镜 + + +## 无锚点(现状) + + +### 第1镜 · 钩子 + +``` +根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。 +【镜头功能】钩子 +【画面】年轻女白领坐在办公室工位,皱眉摸了摸桌上凉掉的水杯,抬头看镜头一脸无奈 +电商竖屏 9:16 导演故事板,画面清晰。 +``` + + +### 第2镜 · 痛点 + +``` +根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。 +【镜头功能】痛点 +【画面】女白领从通勤包里拿出漏水的旧保温杯,纸巾擦被打湿的笔记本,表情懊恼 +电商竖屏 9:16 导演故事板,画面清晰。 +``` + + +### 第3镜 · 卖点 + +``` +根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。 +【镜头功能】卖点 +【画面】女白领单手按下保温杯一键弹盖,杯口冒出热气,桌面横放杯子滴水不漏 +电商竖屏 9:16 导演故事板,画面清晰。 +``` + + +### 第4镜 · CTA + +``` +根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。 +【镜头功能】CTA +【画面】女白领手持保温杯对镜头微笑展示,画面右下角出现购物车引导点击 +电商竖屏 9:16 导演故事板,画面清晰。 +``` + + +## 有锚点(锁画风) + + +### 第1镜 · 钩子 + +``` +根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。 +【镜头功能】钩子 +【画面】年轻女白领坐在办公室工位,皱眉摸了摸桌上凉掉的水杯,抬头看镜头一脸无奈 +【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】 +· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通; +· 布光:统一柔和影棚顺光,同一色温(暖白); +· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景; +· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度; +· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装); +· 商品:全片同一只白色保温杯(同一外形/配色/logo)。 +电商竖屏 9:16 导演故事板,画面清晰。 +``` + + +### 第2镜 · 痛点 + +``` +根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。 +【镜头功能】痛点 +【画面】女白领从通勤包里拿出漏水的旧保温杯,纸巾擦被打湿的笔记本,表情懊恼 +【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】 +· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通; +· 布光:统一柔和影棚顺光,同一色温(暖白); +· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景; +· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度; +· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装); +· 商品:全片同一只白色保温杯(同一外形/配色/logo)。 +电商竖屏 9:16 导演故事板,画面清晰。 +``` + + +### 第3镜 · 卖点 + +``` +根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。 +【镜头功能】卖点 +【画面】女白领单手按下保温杯一键弹盖,杯口冒出热气,桌面横放杯子滴水不漏 +【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】 +· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通; +· 布光:统一柔和影棚顺光,同一色温(暖白); +· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景; +· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度; +· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装); +· 商品:全片同一只白色保温杯(同一外形/配色/logo)。 +电商竖屏 9:16 导演故事板,画面清晰。 +``` + + +### 第4镜 · CTA + +``` +根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。 +【镜头功能】CTA +【画面】女白领手持保温杯对镜头微笑展示,画面右下角出现购物车引导点击 +【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】 +· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通; +· 布光:统一柔和影棚顺光,同一色温(暖白); +· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景; +· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度; +· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装); +· 商品:全片同一只白色保温杯(同一外形/配色/logo)。 +电商竖屏 9:16 导演故事板,画面清晰。 +``` diff --git a/core/docs/storyboard-style-demo/with_anchor/frame1_钩子.png b/core/docs/storyboard-style-demo/with_anchor/frame1_钩子.png new file mode 100644 index 0000000..8f74943 Binary files /dev/null and b/core/docs/storyboard-style-demo/with_anchor/frame1_钩子.png differ diff --git a/core/docs/storyboard-style-demo/with_anchor/frame2_痛点.png b/core/docs/storyboard-style-demo/with_anchor/frame2_痛点.png new file mode 100644 index 0000000..ddae7e5 Binary files /dev/null and b/core/docs/storyboard-style-demo/with_anchor/frame2_痛点.png differ diff --git a/core/docs/storyboard-style-demo/with_anchor/frame3_卖点.png b/core/docs/storyboard-style-demo/with_anchor/frame3_卖点.png new file mode 100644 index 0000000..08fdadb Binary files /dev/null and b/core/docs/storyboard-style-demo/with_anchor/frame3_卖点.png differ diff --git a/core/docs/storyboard-style-demo/with_anchor/frame4_CTA.png b/core/docs/storyboard-style-demo/with_anchor/frame4_CTA.png new file mode 100644 index 0000000..4d06117 Binary files /dev/null and b/core/docs/storyboard-style-demo/with_anchor/frame4_CTA.png differ diff --git a/core/docs/出格式实测-模型产出汇总.md b/core/docs/出格式实测-模型产出汇总.md new file mode 100644 index 0000000..166f38e --- /dev/null +++ b/core/docs/出格式实测-模型产出汇总.md @@ -0,0 +1,409 @@ +# 出格式能力探针 · 模型实际产出汇总 + +> 生成时间:2026-06-24 12:01 · 模拟商品:暖岚 316 保温杯 · 期望 4 镜 / 画幅 9:16 + +判定:`✅`=normalize 后镜数对且每镜有词有画面 `raw_clean`=模型原始输出本身就是合规 JSON(约束真生效,未靠后端抢救) + +## 速览矩阵 + +| 模型 | freeform | structured | tool | +| ---- | ---- | ---- | ---- | +| **doubao**
`doubao-seed-2-0-pro-260215` | ✅·raw✗ 55.7s | ✅·raw✓ 46.0s | ✅·raw✓ 86.7s | +| **gpt**
`gpt-5.5` | ✅·raw✗ 56.7s | ✅·raw✗ 96.4s | ✅·raw✓ 33.1s | +| **gemini**
`gemini-3.1-pro-preview` | ✅·raw✗ 29.0s | ✅·raw✓ 33.9s | ✅·raw✓ 20.9s | + +## 原始结构一致性(normalize 之前 · 逐键 diff) + +> 看的是模型**原始吐出**的 JSON 用什么键,不是 normalize 抹平后的。`seg_array_key`=模型装分镜用的数组键名;`seg_keys`=每镜的键集;`ent_keys`=每实体的键集。三家在同一策略下若键集相同即「同构」。 + +### 策略:freeform + +| 模型 | parse | seg_array_key | segments 每镜键集 | entities 每实体键集 | +| ---- | ----- | ------------- | ---------------- | ------------------ | +| doubao | ✓ | `segments` | `dialogue,duration,entity_refs,index,narration,product_exposure,role,speaker,visual` | `id,name,ref_index,type,visual_prompt,voice_ref` | +| gpt | ✓ | `segments` | `dialogue,duration,entity_refs,index,narration,product_exposure,role,speaker,visual` | `id,name,ref_index,type,visual_prompt,voice_ref` | +| gemini | ✓ | `segments` | `dialogue,duration,entity_refs,index,narration,product_exposure,role,speaker,visual` | `id,name,ref_index,type,visual_prompt,voice_ref` | + +→ segments 键集**完全同构**✅ · entities 键集**完全同构**✅ + +### 策略:structured + +| 模型 | parse | seg_array_key | segments 每镜键集 | entities 每实体键集 | +| ---- | ----- | ------------- | ---------------- | ------------------ | +| doubao | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` | +| gpt | ✓ | `segments` | `dialogue,duration,entity_refs,index,narration,product_exposure,role,speaker,visual` | `id,name,ref_index,type,visual_prompt,voice_ref` | +| gemini | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` | + +→ segments 键集**有差异**⚠️ · entities 键集**有差异**⚠️ + +### 策略:tool + +| 模型 | parse | seg_array_key | segments 每镜键集 | entities 每实体键集 | +| ---- | ----- | ------------- | ---------------- | ------------------ | +| doubao | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` | +| gpt | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` | +| gemini | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` | + +→ segments 键集**完全同构**✅ · entities 键集**完全同构**✅ + + + +--- + +## doubao · `doubao-seed-2-0-pro-260215` + +### ✅ 【doubao · doubao-seed-2-0-pro-260215】策略:freeform · 55.7s · raw_clean=否 + +**规范化成稿:** + +- **hook**:办公室久坐总喝凉水?这款保温杯24小时锁温超省心! +- **tone**:种草 · **时长**:60s · **画幅**:9:16 +- **实体**(3):女白领(character)、办公室工位(scene)、暖岚保温杯(product) + +| 镜 | role | narration(口播) | visual(画面) | +| -- | ---- | ------------- | ----------- | +| 0 | 钩子 | 办公室久坐总喝凉水?冬天接的开水没半小时就凉透了? | 近景对准工位上凉掉的玻璃杯,镜头快速拉到女主皱眉摸凉杯子的脸,运镜带轻微晃动感强化痛点,最后扫过桌面空水杯位 | +| 1 | 痛点 | 每次想喝口热的还要反复去接水,通勤带的水到公司早就凉透,太麻烦了 | 中景拍女主抱着凉杯子起身走向茶水间,切特写拍她擦旧保温杯漏湿的包的烦躁表情,运镜平缓写实强化共鸣 | +| 2 | 卖点 | 这款暖岚316保温杯24小时长效保温,单手弹盖就能开,放包里横放也不洒 | 特写对准保温杯一键弹开的动作,慢放拍316内胆细节,切女主单手接水直接塞包的动作,运镜顺滑突出便捷性 | +| 3 | CTA | 现在点下方小黄车就能入手,久坐上班族随时都能喝到温热水啦 | 女主手持保温杯对着镜头微笑展示正面,镜头慢慢推近到保温杯logo和下单弹窗提示,停留3秒强化记忆点 | + +
原始输出预览(前 600 字) + + +``` +正在为暖岚316不锈钢保温杯生成4镜种草向竖屏带货脚本~ +```json +{ + "hook": "办公室久坐总喝凉水?这款保温杯24小时锁温超省心!", + "tone": "种草", + "aspect_ratio": "9:16", + "total_duration": 60, + "segment_count": 4, + "entities": [ + { + "id": "c1", + "type": "character", + "name": "女白领", + "visual_prompt": "25岁左右年轻女白领,穿简约通勤衬衫,长相亲切有亲和力,办公淡妆自然", + "ref_index": 1, + "voice_ref": null + }, + { + "id": "s1", + "type": "scene", + "name": "办公室工位", + "visual_prompt": "明亮现代的互联网公司工位,桌面放着电脑、笔记本,自然光充足,9:16竖屏构图", + "ref_index": 2, + "voice_ref": null + }, + { + "id": "p1", + "type": "produ +``` + +
+ +### ✅ 【doubao · doubao-seed-2-0-pro-260215】策略:structured · 46.0s · raw_clean=是 + +**规范化成稿:** + +- **hook**:你是不是也忙起来连喝口热的都顾不上,到嘴的水早就凉透了? +- **tone**:种草 · **时长**:60s · **画幅**:9:16 +- **实体**(3):职场女主(character)、办公室工位(scene)、暖岚保温杯(product) + +| 镜 | role | narration(口播) | visual(画面) | +| -- | ---- | ------------- | ----------- | +| 0 | 钩子 | 你是不是也忙起来连喝口热的都顾不上,到嘴的水早就凉透了? | 近景对准女主皱着眉摸了摸手边凉掉的普通水杯,镜头慢慢拉远扫过堆满文件的工位,最后定格在女主失落的表情上。 | +| 1 | 痛点 | 早上带的热水中午就凉,灌包里还总洒得到处都是,真的太闹心了。 | 特写女主翻出被水打湿的通勤包,拿着湿纸巾擦包内的文件,镜头给到旁边没扣紧的普通水杯,女主无奈叹气摇头。 | +| 2 | 卖点 | 这款暖岚保温杯24小时保温,单手就能弹开,放包里横放也不洒,太适合上班族了。 | 特写女主拿起暖岚保温杯,手指一按就弹开盖子喝了一口,镜头扫过316内胆细节,再给到保温杯塞进包的动作。 | +| 3 | CTA | 现在点下方小黄车就能入手,冬天上班随时能喝上热乎的,太舒服啦。 | 女主手持保温杯正对镜头,包装正面朝向观众,镜头慢慢推近到杯身暖岚logo,下方弹出小黄车弹窗动效。 | + +
原始输出预览(前 600 字) + + +``` +{"hook":"你是不是也忙起来连喝口热的都顾不上,到嘴的水早就凉透了?","tone":"种草","aspect_ratio":"9:16","total_duration":60,"segment_count":4,"entities":[{"id":"c1","type":"character","name":"职场女主","visual_prompt":"25-30岁年轻女白领,穿浅灰色通勤西装,扎低马尾,气质清爽干练,面带亲和力","ref_index":1},{"id":"s1","type":"scene","name":"办公室工位","visual_prompt":"现代化开放式办公室工位,明亮冷白光,桌面摆放笔记本电脑、便签纸、绿植,整洁有氛围感","ref_index":2},{"id":"p1","type":"product","name":"暖岚保温杯","visual_prompt":"奶白色暖岚316不锈钢保温杯,500ml容量,顶部有黑色一键弹盖按钮,杯身印有暖岚极简logo","ref_index":3}],"segments":[{"index":0,"duration":15,"role":"钩子","narration":"你是不是也忙起来连喝口热的都顾不上,到嘴的水早就凉透了?","visual":"近景对准女主皱着眉摸了摸手边凉掉的普 +``` + +
+ +### ✅ 【doubao · doubao-seed-2-0-pro-260215】策略:tool · 86.7s · raw_clean=是 + +**规范化成稿:** + +- **hook**:办公室经常喝凉水的朋友快看过来! +- **tone**:种草 · **时长**:60s · **画幅**:9:16 +- **实体**(3):办公室女白领(character)、办公室工位(scene)、暖岚316不锈钢保温杯(product) + +| 镜 | role | narration(口播) | visual(画面) | +| -- | ---- | ------------- | ----------- | +| 0 | 钩子 | 办公室经常喝凉水的朋友快看过来!是不是早上接的水下午就冰了? | 近景对准女生工位上放着的半杯凉水,镜头上摇到女生皱着眉摸水杯的表情,手持镜头轻微晃动带真实感 | +| 1 | 痛点 | 冬天想喝口热的太费劲,开会腾不出手拧杯盖,装包里还总洒漏弄湿文件。 | 中景拍女生开会时想喝水拧不开杯盖的窘迫,再切到她翻包看到被水打湿的笔记本的无奈表情 | +| 2 | 卖点 | 这款暖岚保温杯24小时保温,单手就能弹开,316内胆用着放心,放包里横放也不洒。 | 特写对准保温杯按动弹盖的动作,再切到女生单手开杯喝水的轻松状态,最后给内胆细节特写 | +| 3 | CTA | 现在点下方小黄车就能入手,办公室久坐的打工人真的可以备一个! | 女生手持保温杯正对镜头展示,镜头拉远给到工位全景,下方弹出小黄车弹窗提示 | + +
原始输出预览(前 600 字) + + +``` +{"hook": "办公室经常喝凉水的朋友快看过来!", "tone": "种草", "aspect_ratio": "9:16", "total_duration": 60, "segment_count": 4, "entities": [{"id":"c1","type":"character","name":"办公室女白领","visual_prompt":"25岁左右年轻女生,穿休闲通勤西装,扎低马尾,气质清爽,坐在工位前","ref_index":1},{"id":"s1","type":"scene","name":"办公室工位","visual_prompt":"明亮现代办公区,浅木色书桌,放着电脑、笔记本,暖黄台灯光线柔和","ref_index":2},{"id":"p1","type":"product","name":"暖岚316不锈钢保温杯","visual_prompt":"奶白色500ml保温杯,圆润杯身,黑色一键弹盖按钮,杯身印有暖岚简约logo","ref_index":3}], "segments": [{"index":0,"duration":15,"role":"钩子","narration":"办公室经常喝凉水的朋友快看过来!是不是早上接的水下午就冰了?","visual":"近景对准女生工位上放着的半杯凉水,镜头上摇到女生皱着眉摸水杯的 +``` + +
+ + +--- + +## gpt · `gpt-5.5` + +### ✅ 【gpt · gpt-5.5】策略:freeform · 56.7s · raw_clean=否 + +**规范化成稿:** + +- **hook**:熬夜加班的人,早上还想喝口温热水吗? +- **tone**:痛点 · **时长**:60s · **画幅**:9:16 +- **实体**(5):年轻白领女主(character)、暖岚316不锈钢保温杯500ml(product)、深夜办公室工位(scene)、早晨通勤包旁(scene)、会议室桌面(scene) + +| 镜 | role | narration(口播) | visual(画面) | +| -- | ---- | ------------- | ----------- | +| 0 | 钩子 | 熬夜加班的人,早上还想喝口温热水吗?这杯我真的离不开。 | 竖屏近景从女主趴在深夜工位抬头开始,镜头缓慢推近她疲惫的脸,再落到桌上的暖岚保温杯,情绪从困倦转到被热水安慰。 | +| 1 | 痛点 | 以前普通杯子放一晚就凉,早上赶路还怕漏,包里纸巾都遭殃。 | 镜头切到清晨通勤包旁的中景,女主翻包找东西,先露出被打湿纸巾的尴尬,再手持旧杯皱眉,画面从慌乱转为想解决问题。 | +| 2 | 卖点 | 暖岚316内胆很安心,晚上灌水,早上喝还是温的,横放也不洒。 | 竖屏特写展示杯盖弹开和内胆细节,镜头跟随女主把杯子横放进包里再拿出,水面稳定不外漏,画面从细节检查过渡到放心收包。 | +| 3 | CTA | 开会单手一按就能喝。想少踩杯子坑,点下方小黄车看看。 | 会议室中景里女主一手看电脑一手按开杯盖喝水,镜头轻微跟拍到杯身正面,再定格包装和杯盖按钮,状态从忙乱变得从容。 | + +
原始输出预览(前 600 字) + + +``` +在为暖岚保温杯生成 4 镜办公室痛点脚本… +```json +{ + "hook": "熬夜加班的人,早上还想喝口温热水吗?", + "tone": "痛点", + "aspect_ratio": "9:16", + "total_duration": 60, + "segment_count": 4, + "entities": [ + { + "id": "c1", + "type": "character", + "name": "年轻白领女主", + "visual_prompt": "25岁左右年轻白领女性,黑色中长发,浅色针织衫配西装外套,干净通勤妆容,气质疲惫但利落,适合办公室短视频口播", + "ref_index": 1, + "voice_ref": null + }, + { + "id": "p1", + "type": "product", + "name": "暖岚316不锈钢保温杯500ml", + "visual_prompt": "暖岚品牌500ml保温杯,简约磨砂浅米色杯身,杯盖带一键弹盖按钮,杯身有小巧品牌标识,316不锈钢内胆质感,适合通勤办公场景", + "ref_index": 2, + "voice_ref": null + +``` + +
+ +### ✅ 【gpt · gpt-5.5】策略:structured · 96.4s · raw_clean=否 + +**规范化成稿:** + +- **hook**:你是不是也忙到水凉了才想起来喝? +- **tone**:痛点 · **时长**:60s · **画幅**:9:16 +- **实体**(3):年轻白领女生(character)、现代办公室工位(scene)、暖岚 316 不锈钢保温杯 500ml(product) + +| 镜 | role | narration(口播) | visual(画面) | +| -- | ---- | ------------- | ----------- | +| 0 | 钩子 | 你是不是也忙到水凉了才想起来喝?办公室真的需要这种保温杯。 | 竖屏中景拍白领女生盯着电脑加班,伸手摸到普通杯子皱眉,镜头慢慢推近她失落的表情,最后桌角暖岚保温杯虚焦入画。 | +| 1 | 痛点 | 早会、改方案、回消息,一上午过去,想喝口热的都像抽盲盒。 | 手持跟拍她在工位和会议桌之间来回切换,普通杯子被遗忘在桌边,画面从匆忙敲键盘转到她拿起冷水杯的无奈停顿。 | +| 2 | 卖点 | 暖岚这只316内胆,晚上灌开水,第二天早上喝还是温热的。 | 特写暖岚保温杯旋开杯盖,热气轻轻冒出,镜头从杯口推到316内胆和杯身标识,再切到女生双手捧杯喝水,表情从疲惫变放松。 | +| 3 | CTA | 一键弹盖单手喝,横放包里也不怕洒。想省心喝热水,点下方小黄车。 | 近景拍她一手拿文件一手按开弹盖喝水,随后把杯子横放进通勤包再取出,镜头拉回正面手持包装展示,眼神轻松对镜点头。 | + +
原始输出预览(前 600 字) + + +``` +在为这款保温杯生成 4 镜办公室痛点脚本… +```json +{ + "hook": "你是不是也忙到水凉了才想起来喝?", + "tone": "痛点", + "aspect_ratio": "9:16", + "total_duration": 60, + "segment_count": 4, + "entities": [ + { + "id": "c1", + "type": "character", + "name": "年轻白领女生", + "visual_prompt": "25岁左右年轻白领女生,齐肩深棕色头发,浅色针织衫配西装外套,妆容干净自然,气质亲和,有轻微疲惫但真实的办公室状态", + "ref_index": 1, + "voice_ref": null + }, + { + "id": "s1", + "type": "scene", + "name": "现代办公室工位", + "visual_prompt": "现代城市办公室工位,桌面有电脑、文件、便签和台灯,冷暖混合光,背景有模糊同事与会议室玻璃,9:16竖屏构图,生活化带货质感", + "ref_index": 2, + "voice_ref": null + }, + { + +``` + +
+ +### ✅ 【gpt · gpt-5.5】策略:tool · 33.1s · raw_clean=是 + +**规范化成稿:** + +- **hook**:你是不是忙到一上午都喝不上热水? +- **tone**:痛点 · **时长**:60s · **画幅**:9:16 +- **实体**(3):办公室女白领(character)、现代办公室工位(scene)、暖岚 316 不锈钢保温杯 500ml(product) + +| 镜 | role | narration(口播) | visual(画面) | +| -- | ---- | ------------- | ----------- | +| 0 | 钩子 | 你是不是忙到一上午都喝不上热水?这杯子我最近天天带。 | 女白领坐在工位前快速回消息,桌角保温杯若隐若现;镜头从电脑屏幕推到她疲惫表情,再落到杯身,情绪从忙乱转向被吸引。 | +| 1 | 痛点 | 早上倒的水,中午想喝却凉了,开会还腾不出手拧杯盖。 | 女白领一手拿资料一手翻电脑,想喝水却被普通杯盖卡住;手持跟拍她来回忙碌,桌面纸杯冒气到变冷,画面从急促变无奈。 | +| 2 | 卖点 | 暖岚这只316内胆,晚上灌热水,第二天早上喝着还温温的。 | 镜头切到保温杯内胆和倒水特写,女白领按下一键弹盖喝水;由杯口热气细节推到她放松的表情,画面从冷清变得温暖。 | +| 3 | CTA | 单手开盖还防漏,通勤包横放也安心。需要就点下方小黄车。 | 女白领把保温杯横放进通勤包再拿起对镜展示;镜头从包内防漏细节拉到杯身正面,最后她微笑点向屏幕下方,动作干脆。 | + +
原始输出预览(前 600 字) + + +``` +{"aspect_ratio":"9:16","entities":[{"id":"c1","name":"办公室女白领","ref_index":1,"type":"character","visual_prompt":"年轻女性白领,25-30岁,黑色中长发,浅色针织衫配西装外套,干净自然妆容,气质亲和,有轻微疲惫感但精神利落"},{"id":"s1","name":"现代办公室工位","ref_index":2,"type":"scene","visual_prompt":"现代办公室工位场景,桌面有电脑、文件、台灯和通勤包,暖色自然光,干净通透,竖屏9:16构图,生活化带货风格"},{"id":"p1","name":"暖岚 316 不锈钢保温杯 500ml","ref_index":3,"type":"product","visual_prompt":"暖岚品牌500ml保温杯,简约磨砂杯身,细长便携杯型,一键弹盖结构,杯盖密封圈细节清晰,316不锈钢内胆,适合办公通勤场景"}],"hook":"你是不是忙到一上午都喝不上热水?","segment_count":4,"segments":[{"duration":15,"entity_refs":["c1","s1","p1"],"index":0,"narration":"你是不是忙到一上午都喝不上热水?这杯子我最近天 +``` + +
+ + +--- + +## gemini · `gemini-3.1-pro-preview` + +### ✅ 【gemini · gemini-3.1-pro-preview】策略:freeform · 29.0s · raw_clean=否 + +**规范化成稿:** + +- **hook**:每天带水杯通勤,不是漏水弄脏包,就是开会忙起来腾不出手? +- **tone**:痛点 · **时长**:60s · **画幅**:9:16 +- **实体**(3):女主(character)、办公室工位(scene)、暖岚保温杯(product) + +| 镜 | role | narration(口播) | visual(画面) | +| -- | ---- | ------------- | ----------- | +| 0 | 钩子 | 每天带水杯通勤,不是漏水弄脏包,就是开会忙起来连喝水都腾不出手? | 全景到手持跟拍,女主在工位上急忙翻找通勤包,发现水杯漏水弄湿了文件,表情从焦急到崩溃,镜头推近包里的水渍。 | +| 1 | 痛点 | 尤其是天冷了,想喝口热的,还得双手去拧盖子,键盘敲一半真不想动。 | 中景转特写,女主一边单手敲击电脑键盘,另一只手试图去拧传统水杯盖子,怎么也拧不开,无奈地放弃并叹气。 | +| 2 | 卖点 | 换上暖岚316保温杯,一键弹盖单手就能喝。晚上灌的热水第二天还烫嘴,横着扔包里也滴水不漏。 | 特写加推镜头,女主单手轻松按键弹开暖岚保温杯喝水,随后画面切到杯子倒置用力摇晃,一滴水也没漏,表情转为惊喜。 | +| 3 | CTA | 办公室熬夜党真的少不了它,几十块钱能用好几年,左下角趁有活动赶紧给自己安排上! | 近景固定镜头,女主微笑着把保温杯放在电脑旁,手持杯身向镜头展示质感,手指指向画面左下方,情绪满是推荐感。 | + +
原始输出预览(前 600 字) + + +``` +正在为您生成暖岚保温杯的 60 秒 4 镜带货脚本,主打办公通勤痛点…… + +```json +{ + "hook": "每天带水杯通勤,不是漏水弄脏包,就是开会忙起来腾不出手?", + "tone": "痛点", + "aspect_ratio": "9:16", + "total_duration": 60, + "segment_count": 4, + "entities": [ + { + "id": "c1", + "type": "character", + "name": "女主", + "visual_prompt": "20多岁的年轻都市白领,穿着干练的通勤装,妆容精致,略显忙碌", + "ref_index": 1, + "voice_ref": null + }, + { + "id": "s1", + "type": "scene", + "name": "办公室工位", + "visual_prompt": "明亮的现代办公室工位,桌上有电脑、文件和键盘,光线自然,充满职场氛围", + "ref_index": 2, + "voice_ref": null + }, + { + "id": "p1", + "type": "pr +``` + +
+ +### ✅ 【gemini · gemini-3.1-pro-preview】策略:structured · 33.9s · raw_clean=是 + +**规范化成稿:** + +- **hook**:你是不是也一忙起来就忘了喝水,等想起来水早凉透了? +- **tone**:痛点 · **时长**:60s · **画幅**:9:16 +- **实体**(3):女主(character)、办公桌旁(scene)、暖岚保温杯(product) + +| 镜 | role | narration(口播) | visual(画面) | +| -- | ---- | ------------- | ----------- | +| 0 | 钩子 | 你是不是也一忙起来就忘了喝水,等想起来去喝,水早就凉透了? | 近景特写:女主坐在办公桌前敲键盘,眉头微皱,拿起普通水杯喝了一口却被冷水冰到,无奈放下。镜头缓慢推近放大烦躁情绪。 | +| 1 | 痛点 | 而且随手塞进包里还容易漏水,开会时一手拿文件一手根本拧不开杯盖,太崩溃了。 | 中景手持跟拍:女主从通勤包里拿出一沓被水阴湿的文件叹气。画面一切,她单手抱着文件,另一只手焦急拧旧水杯盖却拧不开。 | +| 2 | 卖点 | 换了暖岚保温杯,一键弹盖单手就能喝。晚上倒的开水第二天还是热的,横放也不漏水。 | 特写转全景:特写暖岚保温杯一键弹盖冒出热气。镜头拉开,女主单手轻松饮水,并倒置摇晃杯身展示滴水不漏,表情转为轻松满意。 | +| 3 | CTA | 316食品级内胆用着更安心。打工人别在喝水上凑合,趁现在有活动,左下角给自己安排上! | 近景固定镜头:女主微笑着将暖岚保温杯放在办公桌显眼处,手指轻敲杯身,随后指了指屏幕左下方。画面给出杯子内胆的清晰特写。 | + +
原始输出预览(前 600 字) + + +``` +{ + "hook": "你是不是也一忙起来就忘了喝水,等想起来水早凉透了?", + "tone": "痛点", + "aspect_ratio": "9:16", + "total_duration": 60, + "segment_count": 4, + "entities": [ + { + "id": "c1", + "type": "character", + "name": "女主", + "visual_prompt": "25岁左右职场年轻女性,穿着简约干练的浅色通勤衬衫,化着淡妆,略显疲惫但依旧专注", + "ref_index": 1 + }, + { + "id": "s1", + "type": "scene", + "name": "办公桌旁", + "visual_prompt": "明亮现代的办公室桌面,有电脑显示器、整齐的文件和绿色盆栽,自然光从侧面打入,职场通勤风格", + "ref_index": 2 + }, + { + "id": "p1", + "type": "product", + "name": "暖岚保温杯", + "visual_prompt": "简约高颜值的500ml不锈钢保温杯,磨砂质感杯身, +``` + +
+ +### ✅ 【gemini · gemini-3.1-pro-preview】策略:tool · 20.9s · raw_clean=是 + +**规范化成稿:** + +- **hook**:你是不是也一到下午,杯子里的水就凉透了,开完会连口热茶都喝不上? +- **tone**:痛点 · **时长**:60s · **画幅**:9:16 +- **实体**(3):女主(character)、现代办公室(scene)、暖岚保温杯(product) + +| 镜 | role | narration(口播) | visual(画面) | +| -- | ---- | ------------- | ----------- | +| 0 | 钩子 | 你是不是也一到下午,杯子里的水就凉透了,开完会连口热茶都喝不上? | 女主坐在办公桌前,拿起一个普通水杯喝水,皱眉发现水已经凉透了,镜头从全景推近到特写,展现她略显疲惫无奈的表情变化。 | +| 1 | 痛点 | 早上挤地铁不敢把水杯放包里怕漏,工作忙起来一手敲键盘,根本腾不出手去拧旧杯盖,太难了。 | 女主一手疯狂敲击键盘,另一只手试图去拧开桌上的旧水杯盖子,结果滑了一下没拧开,手持跟拍展现她急躁焦虑的动作情绪变化。 | +| 2 | 卖点 | 直到换了这款暖岚保温杯,一键弹盖单手就能喝。316内胆昨晚的热水今天还是温的,放包里横着也不漏。 | 镜头特写保温杯,女主单手大拇指轻轻一按,“啪”地弹开杯盖,紧接着镜头摇到内胆特写,最后展示杯子在包里横放无漏水的状态变化。 | +| 3 | CTA | 对咱们久坐熬夜的打工人来说,随时喝口热水太重要了。几十块钱提升幸福感,点左下角小黄车给自己安排上。 | 女主单手拿着暖岚保温杯微笑着喝了一口热水,神情放松,镜头微微拉远给出全身景别,画面从忙碌过渡到惬意享受的状态。 | + +
原始输出预览(前 600 字) + + +``` +{"aspect_ratio":"9:16","entities":[{"id":"c1","name":"女主","ref_index":1,"type":"character","visual_prompt":"年轻白领女性,穿着干练通勤装,略显疲惫但妆容精致,适合职场打工人设定"},{"id":"s1","name":"现代办公室","ref_index":2,"type":"scene","visual_prompt":"现代明亮的办公室,光线充足,办公桌上放着电脑显示器、文件和凌乱的文具,充满工作氛围"},{"id":"p1","name":"暖岚保温杯","ref_index":3,"type":"product","visual_prompt":"暖岚500ml不锈钢保温杯,简约纯色设计,带有按键弹盖结构,外观高级有质感"}],"hook":"你是不是也一到下午,杯子里的水就凉透了,开完会连口热茶都喝不上?","segment_count":4,"segments":[{"duration":15,"entity_refs":["c1","s1"],"index":0,"narration":"你是不是也一到下午,杯子里的水就凉透了,开完会连口热茶都喝不上?","product_exposure":"反面对照(拿旧杯子)","role":"钩子","visual":" +``` + +
diff --git a/core/docs/脚本Agent流式SSE技术文档.md b/core/docs/脚本Agent流式SSE技术文档.md new file mode 100644 index 0000000..4a31691 --- /dev/null +++ b/core/docs/脚本Agent流式SSE技术文档.md @@ -0,0 +1,318 @@ +# 脚本生成 Agent · 流式 SSE 编排技术文档 + +> 对象代码:[`core/backend/apps/ai/script_agent.py`](../backend/apps/ai/script_agent.py) 的 `stream_script_agent()` +> 端点:`POST /api/projects/{id}/script-agent-stream/` → `text/event-stream` +> 一句话定位:把一次「调大模型出脚本」的过程,包装成一条**可见的、可计费的、可断点回滚的** SSE 事件流,给前端真 agent 体感,同时由后端而非模型保证结构化结果的可靠性。 + +--- + +## 0. 全局视角 + +``` +前端 fetch(POST script-agent-stream) ──SSE──▶ 浏览器逐帧消费 + │ ▲ + ▼ │ data: {json}\n\n + Django StreamingHttpResponse │ + │ 包裹 │ + ▼ │ + stream_script_agent() ← 同步生成器,每 yield 一帧 + │ + ┌────────────┼─────────────────────────────────────────┐ + │ │ │ + 加载skill 建AITask+预扣额度 调豆包流式SSE + (tool) (reserve_credit) (reasoning/delta) + │ │ │ + └──▶ 抽取JSON+规范化 ──▶ 落库ScriptVersion ──▶ charge额度 ──▶ saved/summary/done +``` + +核心思想三条: + +1. **进度即事件**:内部每一步(加载技能 / 分析商品 / 生成分镜 / 提取实体 / 自检)都吐一张「工具卡」,让用户看到 agent 在干活,而不是对着一个转圈等几十秒。 +2. **结构化结果由后端兜底**:模型只管「生成」,JSON 的抽取、字段对齐、镜数补齐全在后端做,不信任模型的排版纪律。 +3. **计费与流式生命周期绑定**:额度预扣(reserve)→ 成功结算(charge)/ 失败或断连释放(release),用 `try/finally` 覆盖包括客户端断连在内的所有退出路径。 + +--- + +## 1. SSE 帧格式与事件协议 + +### 1.1 帧编码 + +每一帧都是标准 SSE: + +```python +def _sse(obj: dict) -> str: + return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n" +``` + +- `ensure_ascii=False`:中文不转义,前端直接拿到可读文本。 +- 每帧一个 JSON 对象,必带 `type` 字段,前端按 `type` 分派渲染。 +- 结尾 `\n\n` 是 SSE 规范的事件分隔符。 + +### 1.2 事件类型清单 + +| type | 载荷 | 语义 | 是否进入「答案」 | +| ---- | ---- | ---- | ---- | +| `tool` | `{id, label?, status: running\|done\|error}` | 工具卡:内部步骤可视化 | 否(纯进度) | +| `reasoning` | `{text}` | 推理模型思考流,逐字 | 否(纯展示) | +| `delta` | `{text}` | 模型自然语言前言 | 否(前言,JSON 不外露) | +| `draft` | `{draft}` | 规范化后的 ScriptDraft | 是(结构化渲染) | +| `saved` | `{script_version_id, version}` | 已落库的 ScriptVersion | 是 | +| `summary` | `{text}` | 模型写的收尾交付语 | 是(当 AI 回复气泡) | +| `done` | `{}` | 正常结束 | — | +| `error` | `{detail}` | 失败(额度已回滚) | — | + +--- + +## 2. 逐帧时序详解 + +下面按生成器实际 `yield` 顺序拆解,标注每一步的技术意图。 + +### 阶段 A · 加载技能(同步、毫秒级) + +```python +yield _sse({"type": "tool", "id": "skill", "label": "加载电商脚本技能", "status": "running"}) +skill_loaded = bool(load_ecommerce_skill()) +yield _sse({"type": "tool", "id": "skill", "status": "done" if skill_loaded else "error"}) +``` + +- `load_ecommerce_skill()` 用 `@lru_cache(maxsize=1)`:把 `SKILL.md + references/*.md` 拼成系统提示词,进程内只读一次磁盘。 +- 同一个 `id: "skill"` 先发 `running` 再发 `done`,前端据 `id` 原地更新同一张卡的状态,而不是堆两张卡。 +- 缺文件不致命:`load_ecommerce_skill` 有兜底字符串,`skill_loaded` 仍为 True。 + +### 阶段 B · 分析商品 + 构建消息(同步) + +```python +yield _sse({"type": "tool", "id": "analyze", "label": f"分析商品:{project.product.title}", "status": "running"}) +# ... 加载基准稿(改稿)、校验镜号、build_agent_messages ... +yield _sse({"type": "tool", "id": "analyze", "status": "done"}) +``` + +这一阶段做了几件关键的前置判断: + +1. **改稿才加载基准稿**:`mode == "revise" and base_version_id` 时 `_load_base_draft` 读出历史稿。基准稿拿不到则 `target_index = None`,单镜改无从谈起,退回整版生成。 +2. **改稿用基准稿的真实时长**:`effective_duration = base_draft.total_duration or total_duration`。这是修「90s/6镜稿被请求侧默认 60 挤掉尾镜」的关键——前端可能硬编码 60,但改稿必须尊重原稿镜数。 +3. **镜号越界先于建任务**:`target_index` 非空时校验 `0 <= target_index < seg_n`,越界直接 `yield error` 并 `return`,**绝不建任务/扣费**,避免计费空转的静默 no-op。 + +> 注意所有 `target_index` 判断一律用 `is None`,**不能用真值判断**——`0` 是合法镜号(第 1 镜),`if target_index:` 会把第 1 镜误当未指定。 + +### 阶段 C · 建任务 + 预扣额度 + +```python +task_type = AITask.Type.SCRIPT_OPTIMIZATION if mode == "revise" else AITask.Type.SCRIPT_GENERATION +try: + task = create_ai_task(project=..., task_type=task_type, model_config=..., request_payload={...}) +except Exception as exc: + yield _sse({"type": "error", "detail": f"任务创建失败(可能额度不足):{exc}"}) + return +reservation = task.credit_reservation +``` + +- `create_ai_task` 内部 `@transaction.atomic`:建 `AITask`(CREATED)→ `reserve_credit` 预扣 → 置 RESERVED。预扣失败(余额不足)抛异常,这里转成 `error` 事件优雅返回。 +- `reservation` 句柄留到后面 charge/release 用。 + +### 阶段 D · 调模型流式生成(核心,耗时几十秒) + +这是整个流程最重的一段,包在 `try/finally`(计费兜底)+ 内层 `try/except`(生成失败处理)里。 + +```python +settled = False # 额度是否已结算 +try: + yield _sse({"type": "tool", "id": "generate", "label": "按黄金结构生成分镜", "status": "running"}) + full: list[str] = [] # 累积模型正文 + shown = 0 # 已外露给前端的可见字符数 + forwarding = True # 是否仍在转发前言(遇到 JSON 起点后置 False) + try: + task.status = AITask.Status.SUBMITTED; task.save(...) + provider = build_provider(model_config) + for ev in provider.chat_completion_stream(model=..., messages=messages, temperature=0.85): + et = ev.get("type") + if et == "reasoning": + rpiece = ev.get("text") or "" + if rpiece: + yield _sse({"type": "reasoning", "text": rpiece}) + continue + if et == "delta": + full.append(ev["text"]) + if forwarding: + text = "".join(full) + cut = _visible_cut(text) + if cut < len(text): + forwarding = False + visible = text[:cut] + if len(visible) > shown: + piece = visible[shown:] + shown = len(visible) + if piece.strip(): + yield _sse({"type": "delta", "text": piece}) + elif et == "done": + break + raw = "".join(full) + draft = normalize_draft(raw, aspect_ratio=..., total_duration=effective_duration) + if target_index is not None and base_draft: + draft = _merge_single_segment(base_draft, draft, target_index, ...) + except Exception as exc: + _fail_task(task, reservation, str(exc)); settled = True + yield _sse({"type": "tool", "id": "generate", "status": "error"}) + yield _sse({"type": "error", "detail": f"脚本生成失败:{exc}"}) + return +``` + +#### D.1 两种 delta 的区分(reasoning vs content) + +底层 [`chat_completion_stream`](../backend/apps/ai/providers/volcano.py) 把 OpenAI 兼容 SSE 的 `delta` 拆成两路: + +- `delta.reasoning_content` → `{type: "reasoning"}` +- `delta.content` → `{type: "delta"}` + +豆包 seed-pro 这类**推理模型**在出 JSON 前会先思考几十秒,思考期**只发 `reasoning_content`、不发 `content`**。如果不单独转发 reasoning,整个思考期前端零输出 = 假死(用户看到「按黄金结构生成分镜」卡了几十秒以为崩了)。所以 reasoning 逐字下发、纯展示、`continue` 掉不进 `full`(它不是答案正文)。 + +#### D.2 前言可见区裁剪(`_visible_cut`) + +模型按运行时输出协议会先说一句口语前言("在为这款保温杯生成 4 镜痛点脚本…"),紧接着吐 ` ```json ` 代码块。前端只该看到前言,不该看到刷屏的 JSON。 + +```python +def _visible_cut(text: str) -> int: + """可见区终点 = JSON 起点(``` 或第一个 {)。""" + cands = [] + for marker in ("```", "{"): + i = text.find(marker) + if i != -1: + cands.append(i) + return min(cands) if cands else len(text) +``` + +转发逻辑用三个游标协作: + +- `full`:累积**全部**模型正文(含 JSON),用于最后解析。 +- `shown`:已经 `delta` 出去的可见字符数,保证只增量发新字符、不重发。 +- `forwarding`:一旦 `cut < len(text)`(即出现了 ``` 或 `{`),说明前言结束、JSON 开始,置 False,此后不再转发任何 `delta`(JSON 不外露)。 + +只发 `piece.strip()` 非空的片段,避免把纯空白也当帧发出去。 + +#### D.3 抽取与规范化 + +`raw = "".join(full)` 是模型完整正文。`normalize_draft(raw, ...)` 负责「不信任模型排版」的全部兜底(抽 JSON、配平括号、挑内容最丰富的 segments 数组、字段模糊匹配、镜数对齐补齐),详见 [`script_agent.py`](../backend/apps/ai/script_agent.py) 的 `_extract_json` / `_resolve_segments` / `_pick_field`。 + +#### D.4 精准改一镜的合并 + +`target_index is not None and base_draft` 时,模型虽被要求只改第 N 镜并输出完整稿,但后端不信它会乖乖保留其余镜——`_merge_single_segment` 以基准稿深拷贝为底,**只用新稿的第 N 镜替换**,其余镜逐字保持,再整体规范化。模型没产出目标镜则抛错(不静默返回 base 空转计费)。 + +### 阶段 E · 自检卡 + draft 事件 + +```python +yield _sse({"type": "tool", "id": "generate", "status": "done"}) +yield _sse({"type": "tool", "id": "extract", "label": f"提取实体 {len(draft['entities'])} 个 · {len(draft['segments'])} 镜", "status": "done"}) +yield _sse({"type": "tool", "id": "check", "label": "自检:镜数 / ≤55字 / 违规词", "status": "done"}) +yield _sse({"type": "draft", "draft": draft}) +``` + +注意 `extract` / `check` 卡直接发 `done`——它们是**对已完成结果的事后陈述**(实体数、镜数都已知),不是真有独立的耗时步骤,目的是补齐 agent 工作流的叙事完整性。`draft` 事件把结构化稿交给前端做卡片化渲染。 + +### 阶段 F · 落库 + 结算额度 + +```python +try: + with transaction.atomic(): + task.status = AITask.Status.SUCCEEDED + task.response_payload = {"raw": raw[:8000]} + task.actual_cost = task.estimated_cost + task.completed_at = timezone.now(); task.save(...) + charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) + source = "revise" if mode == "revise" else ("theme" if mode == "theme" else "ai") + script = persist_script_draft(project=..., task=task, draft=draft, source=source) + settled = True # charge 已提交 +except Exception as exc: + _fail_task(task, reservation, f"保存脚本失败:{exc}"); settled = True + yield _sse({"type": "error", "detail": f"保存脚本失败:{exc}"}) + return +``` + +- **charge 与落库同一事务**:`charge_reserved_credit` 和 `persist_script_draft` 在同一个 `transaction.atomic()` 里。落库失败则 atomic 回滚 charge,`_fail_task` 补释放预留——钱和数据强一致。 +- `settled = True` 标记额度已结算,给最外层 finally 看(见第 3 节)。 +- `persist_script_draft` 建 `ScriptVersion + ScriptSegment`,并把 entities 回填 `project.metadata`(cast/scenes/script_entities),把 SCRIPT 阶段标 `NEEDS_REVIEW`。 + +### 阶段 G · saved / summary / done + +```python +yield _sse({"type": "saved", "script_version_id": str(script.id), + "version": ScriptVersionSerializer(script).data}) +summary = _closing_summary(raw) +if summary: + yield _sse({"type": "summary", "text": summary}) +yield _sse({"type": "done"}) +``` + +`_closing_summary` 取「最后一个 JSON 对象之后的文字」当 AI 回复气泡——这是模型在协议第 3 步写的口语交付语("这版主打熬夜痛点,钩子用了反差,你可以再让我调 CTA")。去掉收尾的 ``` 围栏,若残留 `{` 或太短(<4 字)则返回空串,由前端兜底默认句。 + +--- + +## 3. 计费生命周期与断连兜底(最易踩坑处) + +整段生成包在: + +```python +settled = False +try: + ... # 阶段 D~G +finally: + if not settled: + _fail_task(task, reservation, "stream aborted (client disconnected)") +``` + +为什么必须用 `finally` 而不是普通 `except`: + +> 客户端中途断连时,Django 会对生成器调用 `.close()`,在当前 `yield` 处抛 **`GeneratorExit`**。它继承自 `BaseException` 而非 `Exception`,普通 `except Exception` 抓不到。若不处理,预扣的额度会永久冻结(既没 charge 也没 release)。 + +`settled` 标志覆盖所有路径: + +| 退出路径 | settled | finally 动作 | +| ---- | ---- | ---- | +| 正常完成(charge 成功) | True | 不动 | +| 生成异常(D.4 except) | True(已 `_fail_task`) | 不动 | +| 落库失败(F except) | True(已 `_fail_task`) | 不动 | +| 客户端断连(GeneratorExit) | False | `_fail_task` 释放预扣 | + +`_fail_task` 自身也防御性 `try/finally`:先置任务 FAILED,再 `release_credit`,release 失败也吞掉(不让兜底逻辑自身抛异常)。 + +--- + +## 4. 关键技术细节备忘 + +| 细节 | 说明 | +| ---- | ---- | +| **同步生成器 + StreamingHttpResponse** | `stream_script_agent` 是普通同步 `def + yield`,不是 async。Django 的 `StreamingHttpResponse` 直接迭代它,每 `yield` 一帧立即下发。 | +| **关 nginx 缓冲** | 端点设 `X-Accel-Buffering: no` + `Cache-Control: no-cache`,否则 nginx 会攒够 buffer 才下发,破坏逐帧体感。 | +| **DRF 必须挂 SSE renderer** | `@action(..., renderer_classes=[ServerSentEventRenderer])`,否则 DRF 内容协商返回 406。 | +| **UTF-8 强制** | 底层 `chat_completion_stream` 设 `response.encoding = "utf-8"`,SSE 不带 charset 时 requests 默认 latin-1 会让中文乱码。 | +| **temperature 0.85** | 脚本生成要发散有创意;对比实体提取那条用 0.3(结构化抽取要稳,降 JSON 漂移)。 | +| **同 id 工具卡原地更新** | `running → done/error` 复用同一 `id`,前端据 id 更新而非新增卡片。 | +| **reasoning 不进 full** | 思考流纯展示,`continue` 跳过累积,避免污染待解析正文。 | +| **raw 截断存档** | `task.response_payload = {"raw": raw[:8000]}`,存证据但限长,失败时可回看模型到底吐了啥。 | + +--- + +## 5. 前端消费契约(给前端对接者) + +按 `type` 分派即可: + +- `tool`:维护一个 `Map`,渲染成进度卡列表;同 id 更新状态。 +- `reasoning`:追加到「思考过程」可折叠区(灰字、逐字滚动)。 +- `delta`:追加到 AI 前言气泡。 +- `draft`:用结构化数据渲染分镜卡片(hook/tone/segments),可直接编辑。 +- `saved`:拿 `script_version_id` 标记当前稿,`version` 是完整序列化对象可直接入列表。 +- `summary`:作为 AI 的收尾回复气泡(没有则用默认句兜底)。 +- `done`:关闭 loading。 +- `error`:弹 `detail`,此时后端已回滚额度,前端无需补偿。 + +--- + +## 6. 涉及文件索引 + +| 文件 | 角色 | +| ---- | ---- | +| [`apps/ai/script_agent.py`](../backend/apps/ai/script_agent.py) | 本文主体:`stream_script_agent` 编排 + normalize/merge/persist | +| [`apps/projects/views.py`](../backend/apps/projects/views.py) | `script_agent_stream` 端点 + `ServerSentEventRenderer` | +| [`apps/ai/providers/volcano.py`](../backend/apps/ai/providers/volcano.py) | `chat_completion_stream` 底层 SSE,reasoning/delta 分流 | +| [`apps/ai/services.py`](../backend/apps/ai/services.py) | `build_provider` 可插拔分流、`create_ai_task` 预扣 | +| [`apps/billing/services/ledger.py`](../backend/apps/billing/services/ledger.py) | `reserve/charge/release_credit` | +| [`skills/ecommerce-video-script/SKILL.md`](../backend/skills/ecommerce-video-script/SKILL.md) | 领域知识(系统提示词) | diff --git a/core/docs/脚本Agent编排架构方案-动态知识装配.md b/core/docs/脚本Agent编排架构方案-动态知识装配.md new file mode 100644 index 0000000..7866b9b --- /dev/null +++ b/core/docs/脚本Agent编排架构方案-动态知识装配.md @@ -0,0 +1,311 @@ +# 脚本 Agent 编排架构方案 · 动态知识装配(流式管道) + +> 状态:设计方案(未落地代码) · 作者:架构评审 · 日期:2026-06-24 +> 关联代码:[`apps/ai/script_agent.py`](../backend/apps/ai/script_agent.py) · [`apps/ai/services.py`](../backend/apps/ai/services.py) · [`skills/ecommerce-video-script/`](../backend/skills/ecommerce-video-script/) +> 关联文档:[脚本Agent流式SSE技术文档.md](脚本Agent流式SSE技术文档.md)(现状) · [出格式实测-模型产出汇总.md](出格式实测-模型产出汇总.md)(实测数据) + +--- + +## 1. 背景与要解决的问题 + +### 1.1 现状 + +当前脚本生成把领域知识一次性全量灌入上下文:`load_ecommerce_skill()`(见 [script_agent.py:54](../backend/apps/ai/script_agent.py#L54)) +用 `glob("*.md")` **无条件遍历全部 references**,整篇拼成系统提示词,每次对话(不论全自动/一句话/改稿/改一镜) +都把这 ~28K 字符全量发给模型。 + +SKILL.md 里虽写了一张「输入模式路由 / 参考资料索引」表(指明哪个品类该读哪几篇),但**该路由仅作为提示词 +发给模型,后端并未按它选择性加载**——检索发生在模型的注意力里,不在后端。 + +### 1.2 随业务增长的问题 + +> **核心痛点:上下文随电商品类数量线性膨胀。** + +现在 5 个 references = 28K 字符。未来叠加更多品类话术(美妆/食品/3C/服饰/家居/母婴/宠物/家电…) +与平台调性后,全量灌入会: + +- **上下文臃肿**:单次请求 system prompt 可能涨到数十万字符,逼近/超出上下文窗口; +- **成本线性上涨**:每次都付全量知识的 token,即便这单商品只用得上其中一个品类包; +- **注意力稀释**:无关品类的话术挤占模型注意力,可能拉低相关品类的发挥; +- **缓存难命中**:动态拼接的大 prompt 难以稳定复用 prefix cache。 + +### 1.3 目标 + +把「静态全量灌入」改为「**按需动态装配**」:拿到商品需求后,**只加载与该商品相关的知识模块**, +打包给模型,流式生成,再经过滤/提取输出前端。**单次上下文只随"命中的 1-2 个品类包"走,不随品类总量膨胀。** + +--- + +## 2. 设计目标(验收标准) + +| # | 目标 | 可度量标准 | +| - | ---- | ---------- | +| G1 | 上下文不随品类总数膨胀 | 单次 system prompt 字数 ≈ 内核 + 命中模块,与品类总数解耦 | +| G2 | 输出格式永不因知识缺失而塌 | 任意路由结果下,输出契约恒在 prompt 中 | +| G3 | 全程流式 | 路由/装配/生成/提取每阶段都有 SSE 进度事件 | +| G4 | 运营可扩品类不改代码 | 加一个品类 = 加一个知识模块(文件/DB),无需改 Python | +| G5 | 不引入与任务不匹配的重型框架 | 沿用生成器流式编排,不上状态图运行时 | +| G6 | 平滑迁移 | 分阶段落地,每阶段可独立上线、可回滚 | + +--- + +## 3. 总体架构:六段流式管道 + +``` +商品需求(product + 前置条件) + │ + ▼ ① 路由 Router ────────── 识别品类/平台 → 决定加载哪些知识模块 + │ SSE: tool router "识别品类:美妆洗护 · 平台:抖音" + │ + ▼ ② 装配 Assembler ─────── 取「内核 + 命中品类包 + 命中平台调性」 + │ SSE: tool assemble "装配知识:内核+2模块 共 9.2K 字" ← 可见地证明未膨胀 + │ + ▼ ③ 打包 Packager ──────── 内核(恒在) + 动态知识 + 商品上下文 + 输出契约 + │ + ▼ ④ 生成 Generator ─────── 约束解码(tool/structured)流式出结构 + │ SSE: reasoning(思考) / delta(口语前言) + │ + ▼ ⑤ 过滤提取 Extractor ─── 校验/归一/抽实体/扫违规词(强不变量,后端兜底) + │ SSE: tool extract "提取实体4个 · 自检通过" + │ + ▼ ⑥ 前端 Sink ──────────── draft / saved / summary / done +``` + +**与现状的本质差异**:②③ 从"glob 全部"变为"只装命中"。①②⑤ 是真实工作步骤,不再是装样子的工具卡。 + +--- + +## 4. 核心设计:知识分两层 + +把现有单块 28K 知识拆成**内核(恒在)+ 模块(动态)**两层。 + +### 4.1 内核 Kernel(每次必带,小而稳) + +| 内容 | 来源(现状) | +| ---- | ---------- | +| 黄金结构(钩子→痛点→卖点→CTA)、档位×结构映射 | methodology.md 的结构部分 | +| **输出契约(铁律1):字段名锚定、JSON 形状、镜数规则** | SKILL.md 铁律1 + `_OUTPUT_PROTOCOL` | +| 写作红线:≤55字、违规词清单、口语化 | methodology.md 红线 + SKILL.md 铁律3 | +| 字段纪律:tone/role 枚举、entity 引用合法性 | SKILL.md 铁律1 | + +> ⚠️ **输出契约必须在内核里,永远在。** 这是你们踩过的坑(skills 没进镜像→契约丢失→模型吐散文解析失败) +> 的正式解。无论路由加载了哪些品类包,格式硬底线都不会塌。现有 `_EXTRACT_OUTPUT_CONTRACT` +> (见 [services.py:385](../backend/apps/ai/services.py#L385))写死兜底,就是这一思想的雏形——把它正式化为"内核"。 + +### 4.2 品类模块 Module(按商品命中才加载,多而长) + +| 模块类型 | 例 | frontmatter 选择维度 | +| ------- | -- | ------------------- | +| 品类话术 | 美妆/食品/3C/服饰/家居… | `applies_to: [category...]` | +| 平台调性 | 抖音/快手/小红书/视频号 | `platforms: [...]` | +| 钩子库分册 | 痛点提问/反差/数字冲击… | `tone: [...]` 或 always | + +每个模块是一个独立的、带元数据索引的知识单元(不再是一坨大 concat)。 + +--- + +## 5. 路由机制:怎么选模块 + +三种机制,按本场景适配度排序。**推荐 rule-first 混合**。 + +### 5.1 元数据路由(主力 · 先落地这个) + +把 SKILL.md 的路由表**从提示词搬进代码**:每个模块 frontmatter 声明它服务的品类/平台, +`select_knowledge(product)` 按 `product.category` / 平台前置条件命中。 + +- **优点**:零额外调用、确定性、可解释、可单测。电商商品基本都有 category 字段,**80% 情况足够**。 +- **缺点**:新品类要维护映射——但加品类 = 加一个 `.md` 模块 + 写 frontmatter,**不改 Python**(满足 G4)。 + +### 5.2 向量检索 RAG(扩容兜底 · 品类破百再上) + +把知识块 embedding,用商品上下文检索 top-K 相关片段。 + +- **优点**:处理模糊/新品类(novel category 自动匹配近邻),可无限扩。 +- **缺点**:引入检索失败模式(检错块→知识缺失)、需 embedding 基建与运维。**别过早引入。** + +### 5.3 LLM 路由(灵活但加跳) + +用便宜快模型(如 doubao-lite)先分类"该商品属哪类、用哪套话术"。 + +- **优点**:最灵活,能理解复杂商品描述。**缺点**:多一次调用 + 延迟。 + +### 5.4 推荐:rule-first 混合 + +``` +select_knowledge(product): + modules = [Kernel] # 恒在 + hit = rule_match(product.category, platform) # 5.1 规则命中 + if hit: + modules += hit + else: + modules += fallback() # 命中不到:回落(全量核心包 or 5.2 检索) + return modules +``` + +**先只做 5.1 规则版,留好 `fallback()` 接口**;品类规模或模糊度上来时,把 `fallback` 换成检索/LLM 路由。 + +--- + +## 6. 接口设计 + +### 6.1 知识模块结构(frontmatter 规范) + +每个 reference 模块在文件头加 YAML frontmatter(或等价 DB 字段): + +```markdown +--- +id: playbook-beauty +type: category # core | category | platform | hook +applies_to: [美妆, 护肤, 洗护, 彩妆] # 命中这些 category 时加载 +platforms: [] # 限定平台(空=不限) +keywords: [精华, 面膜, 口红, 防晒] # 检索/模糊命中用 +priority: 10 +enabled: true +--- +(正文:该品类的话术、语气、卖点侧重…) +``` + +`core` 类型 = 内核,恒加载;其余按 `applies_to`/`platforms`/`keywords` 命中。 + +### 6.2 选择函数(替换 `load_ecommerce_skill`) + +```python +# apps/ai/knowledge.py(新增) +@dataclass +class KnowledgeModule: + id: str + type: str # core|category|platform|hook + applies_to: list[str] + platforms: list[str] + keywords: list[str] + body: str + +def load_registry() -> list[KnowledgeModule]: + """扫 skills/ 下模块(含 frontmatter),或读 DB。缓存。""" + +def select_knowledge(*, product, platform: str | None, mode: str) -> list[KnowledgeModule]: + """rule-first:内核恒在 + 按 category/platform 命中品类包/平台调性; + 命中不到走 fallback(全量核心 or 检索)。改稿模式可少带选题类模块。""" + +def assemble_system_prompt(modules: list[KnowledgeModule]) -> tuple[str, int]: + """拼 system prompt = 内核(置顶稳定,利于 prefix cache) + 动态模块。 + 返回 (prompt, 字数) —— 字数用于 SSE 上报,可见证明未膨胀。""" +``` + +`build_agent_messages`(见 [script_agent.py:111](../backend/apps/ai/script_agent.py#L111)) +的 `system = load_ecommerce_skill() + _OUTPUT_PROTOCOL` 改为 +`system, n = assemble_system_prompt(select_knowledge(...))`,其中输出契约并入内核。 + +### 6.3 SSE 事件扩展 + +在现有 `tool/reasoning/delta/draft/saved/summary/done` 基础上,让 ①②⑤ 成为**真实**工具卡: + +| 事件 | 新增/变化 | 载荷 | +| ---- | -------- | ---- | +| `tool: router` | 新增 | `{label:"识别品类:美妆·抖音", status, meta:{category, platform}}` | +| `tool: assemble` | 新增 | `{label:"装配知识 内核+2模块 9.2K字", status, meta:{module_ids, chars}}` | +| `tool: generate` | 不变 | 约束解码流式 | +| `tool: extract` | 强化 | 真实体提取 + 违规词自检结果 | + +> `assemble` 卡把"这次只装了 9.2K 而非 28K"**可观测地**展示给用户/运维,是 G1 的活体证明。 + +--- + +## 7. 生成与过滤提取(④⑤) + +### 7.1 生成:约束解码,让 normalize 退居安全网 + +结合 [出格式实测-模型产出汇总.md](出格式实测-模型产出汇总.md) 的实测结论: + +- 三模型(豆包/GPT-5.5/Gemini-3.1-pro)的 structured/tool 均可产出 `raw_clean✓` 的契约 JSON; +- **freeform 下三家原始输出全 `raw_clean✗`**(靠 `normalize_draft` fuzzy 抢救); +- **tool 策略跨模型 segments 键集完全同构**(最稳)。 + +→ 生成阶段改用 **tool/structured 约束解码**(schema 须补全 `dialogue/speaker/voice_ref` 等契约字段), +内核保留输出契约文字作双保险。`normalize_draft` 从"主力解析器"降为"安全网"。 + +> ⚠️ 约束解码与"先写口语前言"的 `_OUTPUT_PROTOCOL` 有张力(实测 GPT structured 被前言污染)。 +> 落地时**对话气泡(前言/收尾)与结构稿分离**:结构走纯约束,气泡另起轻量一跳或用支持混合流的部件协议。 + +### 7.2 过滤提取:强不变量后端兜底 + +沿用并简化现有逻辑(约束解码后原始已干净,兜底压力骤降): + +- 镜数对齐(=时长/15)、role/tone 枚举归一、entity_refs 合法性 —— `normalize_draft` 现有能力; +- 实体抽取(角色/场景)—— 复用 [services.py](../backend/apps/ai/services.py) 的 `run_extract_entities_task`; +- 违规词自检 —— 可前置为真校验节点(发现即标记/可触发重生成)。 + +--- + +## 8. 为什么不用 LangGraph + +本管道是**线性流水线**(router→assemble→generate→extract→sink):**无环、无 reflect-retry、无多 agent 对话**。 +线性 + 流式正是现有生成器范式的最佳 altitude。LangGraph 的状态图是为"有环/有分支/要回退/human-in-loop" +设计的,**此处上图属过度设计**。真正的编排升级点是"选择性装配"这一层抽象,而非更换运行时。 + +> 若未来产品要做「生成→自检违规词→自动修正→再检」的真闭环,或「编剧/审查/提取」多 agent 协作, +> 那时再评估 LangGraph / PydanticAI(类型契约+重试)/ 轻量自写 retry。当前不需要。 + +--- + +## 9. 分阶段迁移清单 + +每阶段可独立上线、独立回滚。 + +### Phase 1 · 知识分层 + 规则路由(止血膨胀,优先级最高) +- [ ] references 加 frontmatter(`type/applies_to/platforms/keywords`);抽出 `core` 内核。 +- [ ] 新增 `apps/ai/knowledge.py`:`load_registry` / `select_knowledge`(规则版)/ `assemble_system_prompt`。 +- [ ] `build_agent_messages` 改用 `assemble_system_prompt(select_knowledge(...))`;**输出契约并入内核**。 +- [ ] SSE 增 `router`/`assemble` 真实工具卡(含字数)。 +- [ ] 单测:命中/未命中/改稿模式各自加载了哪些模块;内核必含契约。 +- **验收**:单次 system prompt 字数与品类总数解耦(G1);格式零回归。 + +### Phase 2 · 约束解码(让 normalize 退居安全网) +- [ ] 定义完整 `ScriptDraft` JSON Schema(含 dialogue/speaker/voice_ref)。 +- [ ] 生成阶段切 tool/structured(按 provider 能力分流,实测已验证三家可行)。 +- [ ] 对话气泡与结构稿分离,解决 `_OUTPUT_PROTOCOL` 与约束的张力。 +- [ ] `normalize_draft` 降级为兜底;保留以防个别模型/中转站不合规。 +- **验收**:三模型原始输出 `raw_clean✓`;normalize 命中率(需抢救比例)大幅下降。 + +### Phase 3 · 检索扩容(品类规模化后才做) +- [ ] 知识块 embedding + 向量库;`fallback()` 接入检索。 +- [ ] 模糊/新品类召回评估。 +- **验收**:新增品类无需改路由规则即可被正确召回。 + +### Phase 4(可选)· 自检闭环 +- [ ] 违规词/字数校验做成真节点,不过则带错误自动重生成(轻量 retry,非全图)。 + +--- + +## 10. 风险与对策 + +| 风险 | 对策 | +| ---- | ---- | +| **R1 内核漏放契约 → 某品类下格式塌** | 内核**必含**完整输出契约;单测断言"任意路由结果都含契约";保留写死兜底。 | +| **R2 路由漏召(该加载却没加载)→ 模型瞎编** | 漏召比误召危险。规则命中不到**必须回落**(全量核心包 or 检索),严禁裸奔。 | +| **R3 约束解码与对话气泡冲突** | 结构稿走纯约束、气泡分离(见 7.1);或用支持混合流的部件协议。 | +| **R4 prefix cache 未命中,内核成本没摊薄** | 内核置顶且稳定;实测豆包/中转是否支持 prefix cache 再定。 | +| **R5 检索引入新失败模式** | Phase 3 才上;上之前用规则兜底;检索结果可解释、可回退规则。 | + +--- + +## 11. 与现有代码映射(速查) + +| 设计组件 | 现状 | 落点 | +| ------- | ---- | ---- | +| 内核 + 模块拆分 | `load_ecommerce_skill()` glob 全部 | 新 `apps/ai/knowledge.py` | +| 路由 `select_knowledge` | SKILL.md 路由表(给模型看) | 新 `knowledge.py`,规则实现 | +| 装配 `assemble_system_prompt` | 字符串拼全部 | 新 `knowledge.py`,内核+命中 | +| 打包 | `build_agent_messages` system 拼接 | 改 [script_agent.py:122](../backend/apps/ai/script_agent.py#L122) | +| 生成(约束解码) | freeform + `_OUTPUT_PROTOCOL` | 改 provider 调用,见 [providers/](../backend/apps/ai/providers/) | +| 过滤提取 | `normalize_draft` 当主力 | 降为安全网 [script_agent.py:307](../backend/apps/ai/script_agent.py#L307) | +| 流式编排 | `stream_script_agent` 生成器 | 沿用,增 router/assemble 事件 [script_agent.py:561](../backend/apps/ai/script_agent.py#L561) | +| 模块运营管理 | `references/*.md` 文件 | frontmatter 文件,或复用 `PromptTemplate` admin DB 模式 | + +--- + +## 12. 一句话总结 + +把「静态全量灌」改成「**内核恒在 + 品类模块按需装配**」的**线性流式管道**:路由先用规则(SKILL 路由表搬进代码)、 +扩容再上检索;生成换约束解码让 `normalize_draft` 退居安全网;流式编排沿用现有生成器,**不需要 LangGraph**。 +如此品类再叠,单次上下文也只随"命中的一两个包"走,**不随品类总量膨胀**。 diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index 3450a01..2ccda6d 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -170,14 +170,20 @@ 在 .content 内铺满可用高度(shell 由 App.tsx 渲染,这里只占正文)。 ============================================================ */ .image-workbench { - /* 抵消 .content 的 48/28/72 padding,让工作室壳贴边铺满(同旧 .tool-shell 思路) */ - margin: -48px -28px -72px; + /* 抵消 .content 的 24/28/60 padding,让工作室壳贴边铺满(同旧 .tool-shell 思路)。 + 必须与 design-restraint.css .content 的 padding 严格一致:多减则顶部钻进 sticky + topbar(被遮)、底部溢出视口(被裁)。 */ + margin: -24px -28px -60px; height: calc(100vh - 64px); display: flex; flex-direction: column; background: var(--background-base); overflow: hidden; } +/* <1100px 时 .content 改 28/24/48,负边距需同步,否则窄屏又会上下被遮/裁 */ +@media (max-width: 1100px) { + .image-workbench { margin: -28px -24px -48px; } +} /* ════════════════════════════════════════════════ 通用:返回 pill(图片创作侧栏头 / 模特·平台侧栏头共用) @@ -488,6 +494,18 @@ border-color: var(--heat-40); font-weight: 600; } +/* 手动输入比例:宽 : 高 两个数字框 */ +.image-workbench .iw-ratio-manual { + display: flex; align-items: center; gap: 8px; + margin-top: 8px; +} +.image-workbench .iw-ratio-manual .input { + flex: 1; min-width: 0; height: 32px; + text-align: center; +} +.image-workbench .iw-ratio-manual .sep { + color: var(--black-alpha-48); font-size: 13px; flex-shrink: 0; +} /* ── 模特选择 · 3:4 矩形卡多选(基线 .model-card)── */ .image-workbench .model-grid { @@ -522,11 +540,14 @@ object-fit: cover; display: block; background: var(--black-alpha-4); } -.image-workbench .model-card .m-name { font-size: 13px; font-weight: 500; color: var(--accent-black); } -.image-workbench .model-card.selected .m-name { color: var(--heat); } -.image-workbench .model-card .m-tag { - font-family: var(--font-mono); font-size: 12px; - color: var(--black-alpha-48); letter-spacing: .02em; +/* 标题:遮罩层贴图片底部,白字单行省略,节省一行纵向空间 */ +.image-workbench .model-card .m-name { + position: absolute; left: 0; right: 0; bottom: 0; + padding: 14px 8px 6px; + font-size: 13px; font-weight: 500; color: #fff; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + background: linear-gradient(to top, rgba(0, 0, 0, .62), rgba(0, 0, 0, 0)); + pointer-events: none; z-index: 1; } .image-workbench .model-card .m-check { position: absolute; top: 14px; right: 14px; diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index d3d5ac1..4b295a2 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -432,7 +432,7 @@ const MODE_META: Record< const RATIO_OPTIONS = ["1:1", "3:4", "4:5", "9:16", "16:9"]; const COUNT_OPTIONS = ["1", "2", "4"]; const MODEL_RATIO_OPTIONS = ["1:1", "3:4", "9:16"]; -const MODEL_COUNT_OPTIONS = ["4", "8", "12"]; +const MODEL_COUNT_OPTIONS = ["1", "2", "4"]; const COVER_COUNT_OPTIONS = ["4", "8", "12"]; /* 图片创作 · 空态提示词建议 chip(基线 image-optimize EXAMPLES) */ @@ -528,6 +528,10 @@ export function ImageWorkbenchPage({ const product = products.find((item) => item.id === productId) || products[0]; const [prompt, setPrompt] = useState(meta.promptTemplate(products[0]?.title || "商品")); const [ratio, setRatio] = useState(meta.ratio); + // 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill + const [ratioManual, setRatioManual] = useState(false); + const [ratioW, setRatioW] = useState(""); + const [ratioH, setRatioH] = useState(""); const [style, setStyle] = useState("auto"); const [count, setCount] = useState(mode === "image" ? "4" : "4"); // 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果) @@ -613,6 +617,9 @@ export function ImageWorkbenchPage({ if (product) setPrompt(meta.promptTemplate(product.title)); // mode 或商品切换都重置 prompt 与默认比例 setRatio(meta.ratio); + setRatioManual(false); + setRatioW(""); + setRatioH(""); // eslint-disable-next-line react-hooks/exhaustive-deps }, [productId, mode]); @@ -1323,11 +1330,11 @@ export function ImageWorkbenchPage({ {product?.title || "未选择 · 请在左侧商品空间选一个"} - {/* P0④:商品库全屏选择器入口(多选商品,各起一批) */} - + */}
{searchOpen && ( @@ -1402,9 +1409,8 @@ export function ImageWorkbenchPage({ {item.name.slice(0, 4)}
)} +
{item.name}
-
{item.name}
-
// 真人模特
); }) @@ -1423,9 +1429,8 @@ export function ImageWorkbenchPage({
{item.name} +
{item.name}
-
{item.name}
-
{item.tag}
))} @@ -1481,13 +1486,52 @@ export function ImageWorkbenchPage({ ))} + + {ratioManual && ( +
+ { + const w = event.target.value; + setRatioW(w); + if (w && ratioH) setRatio(`${w}:${ratioH}`); + }} + /> + : + { + const h = event.target.value; + setRatioH(h); + if (ratioW && h) setRatio(`${ratioW}:${h}`); + }} + /> +
+ )} )}
diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index e226d6b..899b026 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -10,6 +10,8 @@ import { SkeletonGrid } from "../components/loading"; import { api } from "../api"; const PROD_PAGE_SIZE = 10; +// 商品详情「AI 素材」grid 每页条数(4 列 → 3 整行) +const MAT_PAGE_SIZE = 12; import type { Asset, Product, ProductMaterials, Project } from "../types"; import type { NavigateFn, Page } from "./route-config"; import "../product-create-page.css"; @@ -621,13 +623,8 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na const [triAdoptedId, setTriAdoptedId] = useState(""); const triViewing = triVersions.find((v) => v.id === triViewingId) || triVersions[0]; const triUrl = triViewing?.url || ""; - // 素材 tab 筛选 / 排序 / 分页 / 视图 - const [openFilter, setOpenFilter] = useState<"" | "type" | "status" | "sort">(""); - const [typeFilter, setTypeFilter] = useState(""); - const [statusFilter, setStatusFilter] = useState<"" | "pass" | "fail" | "pending">(""); - const [assetView, setAssetView] = useState<"grid" | "list">("grid"); - const [assetSortDesc, setAssetSortDesc] = useState(true); - const [assetLimit, setAssetLimit] = useState(12); + // AI 素材 grid 当前页(与全站列表一致:整体翻页) + const [matPage, setMatPage] = useState(1); const [videoSortDesc, setVideoSortDesc] = useState(true); // 素材审核状态为只读(直接反映 review_status),不再有用户点击覆盖 // 采用三视图后,本地 prepend 的新素材卡(供「采用版本联动 + prepend 新卡」即时呈现,不等服务端回灌) @@ -665,19 +662,11 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na if (!alive) return; setProductAssets(assetsRes?.results ?? []); setMaterials(matRes); + setMatPage(1); // 切商品 / 刷新素材后回到第 1 页 }).finally(() => { if (alive) setAssetsLoading(false); }); return () => { alive = false; }; }, [product?.id, assetReload]); - useEffect(() => { - if (!openFilter) return; - const close = (event: MouseEvent) => { - if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenFilter(""); - }; - document.addEventListener("click", close); - return () => document.removeEventListener("click", close); - }, [openFilter]); - async function onPickProductImage(event: ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; @@ -757,29 +746,27 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na // 采用三视图后本地 prepend 的新卡:置顶且与服务端列表去重(服务端回灌同 id 后即不再重复) const serverIds = new Set(serverImageAssets.map((a) => a.id)); const allImageAssets = [...adoptedAssets.filter((a) => !serverIds.has(a.id)), ...serverImageAssets]; - // 联动采用状态前的「全部素材基线」(供 adoptTriView 找出该商品所有三视图) - const allAssetsBase = allImageAssets; - // 单个素材当前显示状态:本地覆盖(点击循环 / 采用联动)优先,否则回退真实 review_status 映射 - const statusOf = (asset: Asset): PdAssetStatus => pdBaseStatus(asset); - // 类型筛选选项(当前素材里真实存在的 category) - const typeOptions = Array.from(new Set(allImageAssets.map((a) => a.category).filter(Boolean))); - // 全集计数(给状态筛选下拉做计数前缀,不随当前筛选变化) - const filteredAssets = allImageAssets - .filter((asset) => !typeFilter || asset.category === typeFilter) - .filter((asset) => !statusFilter || statusOf(asset) === statusFilter) - .slice() - .sort((a, b) => { - const cmp = (b.created_at || "").localeCompare(a.created_at || ""); - return assetSortDesc ? cmp : -cmp; - }); - const assetCount = filteredAssets.length; - const imageAssets = filteredAssets.slice(0, assetLimit); // 结构化素材总数(角色 + 商品图 + 图片成品 + 项目包内素材),用于头部计数 / 空态判断 const matTotal = materials ? materials.roles.length + materials.product_images.length + materials.image_products.length + materials.project_packs.reduce((s, p) => s + p.items.length, 0) : 0; - const hasActiveAssetFilter = Boolean(typeFilter || statusFilter); - const resetAssetFilters = () => { setTypeFilter(""); setStatusFilter(""); setAssetLimit(12); setOpenFilter(""); }; + // 拍平 4 类素材为单序列(角色 → 商品图 → 图片成品 → 视频项目包),整体翻页(与全站列表统一 Pager 一致) + type MatItem = + | { kind: "role"; role: ProductMaterials["roles"][number] } + | { kind: "product_image"; asset: Asset } + | { kind: "image_product"; asset: Asset } + | { kind: "pack"; pack: ProductMaterials["project_packs"][number] }; + const matItems: MatItem[] = materials + ? [ + ...materials.roles.map((role) => ({ kind: "role", role } as MatItem)), + ...materials.product_images.map((asset) => ({ kind: "product_image", asset } as MatItem)), + ...materials.image_products.map((asset) => ({ kind: "image_product", asset } as MatItem)), + ...materials.project_packs.map((pack) => ({ kind: "pack", pack } as MatItem)), + ] + : []; + const matTotalPages = Math.max(1, Math.ceil(matItems.length / MAT_PAGE_SIZE)); + const matCurPage = Math.min(matPage, matTotalPages); + const matPageItems = matItems.slice((matCurPage - 1) * MAT_PAGE_SIZE, matCurPage * MAT_PAGE_SIZE); // 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段(按更新时间排序) const videoProjects = [...projects].sort((a, b) => { const cmp = (b.updated_at || "").localeCompare(a.updated_at || ""); @@ -1075,68 +1062,62 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
) : ( + <>
- {/* 角色:逐个(形象图+三视图绑一起);左上标签、左下「N 个项目引用」;点开看这套 */} - {materials!.roles.map((role) => { - const cover = pdAssetPreview(role.portrait); - return ( -
setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] })} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] }); } }}> -
- {cover ? {role.name} : 角色} - 角色 - {role.triview && 含三视图} + {matPageItems.map((item) => { + // 角色:逐个(形象图+三视图绑一起);左上标签、左下「N 个项目引用」;点开看这套 + if (item.kind === "role") { + const role = item.role; + const cover = pdAssetPreview(role.portrait); + return ( +
setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] })} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] }); } }}> +
+ {cover ? {role.name} : 角色} + 角色 + {role.triview && 含三视图} +
+
{role.ref_count > 0 ? `${role.ref_count} 个项目引用` : "// 暂未引用"}
-
{role.ref_count > 0 ? `${role.ref_count} 个项目引用` : "// 暂未引用"}
-
- ); - })} - {/* 商品图:平铺 */} - {materials!.product_images.map((a) => { + ); + } + // 视频项目包:场景+分镜+视频素材按项目打包,点开看整组 + if (item.kind === "pack") { + const pack = item.pack; + const first = pack.items[0]; + const cover = pdAssetPreview(first); + const isVid = first?.asset_type === "video"; + return ( +
setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items })} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items }); } }}> +
+ {isVid && cover ?
+
{pack.project_name}
+
+ ); + } + // 商品图 / 图片成品(模特上身图/平台套图/自由创作):平铺,各带类型标签 + const a = item.asset; const u = pdAssetPreview(a); + const label = item.kind === "product_image" ? "商品图" : pdAssetTypeLabel(a); return (
setPreview({ src: u, name: a.name }) : undefined}> - {u ? {a.name} : 商品图} - 商品图 + {u ? {a.name} : {label}} + {label}
{(a.created_at || "").slice(0, 10)}
); })} - {/* 图片成品(模特上身图/平台套图/自由创作):平铺,各带类型标签 */} - {materials!.image_products.map((a) => { - const u = pdAssetPreview(a); - return ( -
-
setPreview({ src: u, name: a.name }) : undefined}> - {u ? {a.name} : {pdAssetTypeLabel(a)}} - {pdAssetTypeLabel(a)} -
-
{(a.created_at || "").slice(0, 10)}
-
- ); - })} - {/* 视频项目包:场景+分镜+视频素材按项目打包,点开看整组 */} - {materials!.project_packs.map((pack) => { - const first = pack.items[0]; - const cover = pdAssetPreview(first); - const isVid = first?.asset_type === "video"; - return ( -
setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items })} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items }); } }}> -
- {isVid && cover ?
-
{pack.project_name}
-
- ); - })}
+ + )}