2031 lines
98 KiB
Python
2031 lines
98 KiB
Python
"""对话式脚本生成 agent(出稿 + 改稿一体,多模型可选,流式 SSE)。
|
||
|
||
设计:
|
||
- 加载电商脚本 skill(SKILL.md + references)作为领域知识系统提示词;模型无关。
|
||
- 3 种输入模式(全自动 / 一句话 / 改稿)收敛到同一份结构化 ScriptDraft(铁律1契约)。
|
||
- 流式:边生成边吐「工具卡 + 思考」事件,给前端真 agent 体感;JSON 由后端可靠抽取,不靠模型排版。
|
||
- 计费走现有 AITask + 额度预扣(reserve→charge/release),与 generate_project_script 一致。
|
||
|
||
SSE 事件(每帧 `data: {json}\n\n`,json 带 type):
|
||
tool {id,label?,status:running|done|error} —— 工具卡(加载skill/分析商品/生成分镜/提取实体/自检)
|
||
reasoning {text} —— 推理模型思考流(逐字,纯展示,不进答案)
|
||
delta {text} —— 模型自然语言前言(JSON 部分不外露)
|
||
draft {draft} —— 规范化后的 ScriptDraft(前端结构化渲染)
|
||
saved {script_version_id, version} —— 已落库的 ScriptVersion(含 segments/metadata)
|
||
summary {text} —— 模型自己写的收尾交付语(当 AI 回复气泡,替代写死的「已生成」)
|
||
done {} —— 结束
|
||
error {detail,error} —— 失败(已回滚额度;error 为安全业务错误对象)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import uuid
|
||
from decimal import Decimal
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
|
||
from django.conf import settings
|
||
from django.utils import timezone
|
||
|
||
from apps.ai.generation_errors import classify_generation_error
|
||
from apps.ai.models import AITask, ModelConfig
|
||
from apps.billing.services.ledger import charge_reserved_credit, release_credit
|
||
|
||
VALID_TONES = ["种草", "测评", "剧情", "痛点"]
|
||
VALID_ROLES = ["钩子", "痛点", "卖点", "CTA"]
|
||
VALID_ENTITY_TYPES = ["character", "scene", "product"]
|
||
|
||
# 时长:主流程每镜固定 15 秒;总时长只能是 15/30/45/60。
|
||
# 4–15 仍是出片模型合法区间,手改单镜 / 精准改一镜(旧稿)才用。
|
||
TOTAL_DURATION_MIN = 15
|
||
TOTAL_DURATION_MAX = 60
|
||
TOTAL_DURATION_STEP = 15
|
||
SEGMENT_DURATION_MIN = 4
|
||
SEGMENT_DURATION_MAX = 15
|
||
DEFAULT_TOTAL_DURATION = 30
|
||
|
||
# 表现形式 × 视频结构(二期)。key 用 ASCII 找套路文件,label 是给模型和用户看的中文。
|
||
PRESENTATION_FORMATS: dict[str, str] = {"oral": "口播", "drama": "短剧", "vlog": "Vlog"}
|
||
VIDEO_STRUCTURES: dict[str, str] = {
|
||
"pain": "痛点解决",
|
||
"contrast": "前后对比",
|
||
"review": "测评验证",
|
||
"scene": "场景种草",
|
||
"promo": "促销抢购",
|
||
"knowledge": "知识分享",
|
||
}
|
||
DEFAULT_PRESENTATION_FORMAT = "oral"
|
||
DEFAULT_VIDEO_STRUCTURE = "pain"
|
||
|
||
# 唯一禁用组合:短剧 × 测评验证。演出来的实测没有可信度,详见 playbooks/combo-matrix.md。
|
||
FORBIDDEN_COMBOS: set[tuple[str, str]] = {("drama", "review")}
|
||
|
||
# 表现形式推荐的默认总时长:口播短平快,短剧要装下三幕,Vlog 要铺氛围。
|
||
FORMAT_DEFAULT_DURATION: dict[str, int] = {"oral": 30, "drama": 45, "vlog": 30}
|
||
# 各结构能压到的最短总时长(须落在 15 秒步进上),见 playbooks/combo-matrix.md。
|
||
STRUCTURE_MIN_DURATION: dict[str, int] = {
|
||
"pain": 15, "contrast": 15, "review": 30, "scene": 30, "promo": 15, "knowledge": 30,
|
||
}
|
||
|
||
# 用户在设定卡里选的是「怎么说服」,不是一个仅供展示的标签。这里把每种结构收成
|
||
# 可直接塞进任务上下文的验收合同;模型不得拿痛点结构去代替知识分享、拿种草去代替测评。
|
||
STRUCTURE_CONTRACTS: dict[str, str] = {
|
||
"pain": (
|
||
"【痛点解决·结构合同】必须按顺序完成:具体问题出现 → 这个问题造成的不便 → 商品作为解法介入"
|
||
"→ 对应卖点与真实使用过程 → 可见的改善 → 一个行动引导。开头先给可拍到的具体问题,"
|
||
"商品不能在第一句话直接硬推;一个痛点只对应一个卖点。禁止把普通不便夸成严重后果或制造焦虑。\n"
|
||
),
|
||
"contrast": (
|
||
"【前后对比·结构合同】必须建立同一人物/场景/角度/光线下可比较的“使用前”和“使用后”,"
|
||
"中间必须有商品使用过程作为转折,再总结差异并 CTA。旁白只交代条件、过程和差异,"
|
||
"不能写成长篇原理讲解;不得跳过过程、换条件伪造差异,或同时对比多个维度。\n"
|
||
),
|
||
"review": (
|
||
"【测评验证·结构合同】必须按顺序完成:提出要验证什么 → 说清测评标准/条件 → 完整展示测试动作"
|
||
"→ 呈现画面可观察的结果 → 给出带适用边界的结论和 CTA。开场必须是疑问或待验证目标,"
|
||
"不是直接夸商品;每个卖点都要改写成可演示的验证项目。禁止虚构检测数据、权威结论、用户评价或编造缺点。\n"
|
||
),
|
||
"scene": (
|
||
"【场景种草·结构合同】必须按顺序完成:先交代人物、具体时间和地点 → 场景中自然出现需求"
|
||
"→ 商品以正在被使用的方式入场 → 写出该场景里的真实体验 → 轻度推荐/软 CTA。第一镜先建立场景,"
|
||
"不要一上来抛痛点提问或商品参数;商品不能像硬广道具摆拍,卖点要融入动作与感受。\n"
|
||
),
|
||
"promo": (
|
||
"【促销抢购·结构合同】必须按顺序完成:真实福利/价格钩子(仅在资料明确时)→ 商品基础价值"
|
||
"→ 优惠适用的规格、条件与领取方式 → 真实的时间/库存信息(仅在资料明确时)→ 明确购买入口和操作 CTA。"
|
||
"若资料没有价格、优惠、库存或时间,就不得捏造或制造抢购氛围,改为商品价值与查看详情的引导。\n"
|
||
),
|
||
"knowledge": (
|
||
"【知识分享·结构合同】必须按顺序完成:提出一个选购/使用问题或常见误区 → 给出清楚的判断方法"
|
||
"→ 用实物细节、步骤或正确/错误示范讲清方法 → 商品在方法建立后作为符合该标准的实例出现"
|
||
"→ 总结选择建议和自然 CTA。只讲 1–3 个知识点,商品不能在开头就变成卖点罗列。"
|
||
"禁止虚构数据、标准、专业身份或功效承诺,也不能只靠人物口头科普而没有画面演示。\n"
|
||
),
|
||
}
|
||
|
||
STRUCTURE_STAGES: dict[str, tuple[str, ...]] = {
|
||
"pain": ("具体问题与不便", "商品介入和真实用法", "可见改善与行动引导"),
|
||
"contrast": ("使用前的基准状态", "商品使用过程", "使用后的同条件状态", "差异总结与行动引导"),
|
||
"review": ("测评目标与标准", "完整测试过程", "可观察的结果", "适用结论与行动引导"),
|
||
"scene": ("具体时间、地点与人物状态", "场景中的自然需求", "商品被使用着入场", "真实体验与软 CTA"),
|
||
"promo": ("真实福利或商品价值钩子", "商品与适用场景", "明确的优惠条件和购买方法", "入口与操作 CTA"),
|
||
"knowledge": ("问题或常见误区", "判断方法或核心结论", "实物步骤或正反示范", "商品承接、选择建议与 CTA"),
|
||
}
|
||
|
||
# 设定卡人物 key → 中文(与前端 WIZ_PERSONA_LABEL / 模板 coerce_persona 对齐)。
|
||
PERSONA_LABELS: dict[str, str] = {
|
||
"urban": "都市白领女性",
|
||
"bestie": "闺蜜种草",
|
||
"ceo": "总裁亲选",
|
||
"reviewer": "专业测评师",
|
||
"mom": "实用宝妈",
|
||
"genz": "学生党",
|
||
}
|
||
PERSONA_BRIEFS: dict[str, str] = {
|
||
"urban": "25–32岁都市白领,工位或下班回家,说话像跟同事吐槽,不要主播腔",
|
||
"bestie": "闺蜜分享口吻,带点兴奋,爱用「你懂的」「我跟你讲」",
|
||
"ceo": "利落、判断句、少形容词,像拍板不是带货",
|
||
"reviewer": "先讲怎么试的再给结论;可信度来自过程、适用边界或意外发现,不编造小缺点",
|
||
"mom": "带娃/家务间隙,讲省事,孩子或家人能沾边",
|
||
"genz": "宿舍或通勤,短句,像给朋友发语音条",
|
||
}
|
||
_PERSONA_KEY_BY_LABEL = {label: key for key, label in PERSONA_LABELS.items()}
|
||
|
||
# 电商口播需要同时承载观点、证据和转折;15 秒以 4.3–5.2 字/秒为可用区间。
|
||
# 这比日常闲聊快,但仍给句间停顿和镜头切换留出空间。
|
||
NARRATION_CHARS_PER_SECOND = 5.2
|
||
NARRATION_CHARS_PER_SECOND_MIN = 4.3
|
||
NARRATION_CHARS_HARD_CAP = 78
|
||
# 故事板下线后,visual 是出片模型唯一的画面依据,门槛从「够写一句」抬到「够当导演说明书」
|
||
VISUAL_CHARS_MIN = 110
|
||
SHOT_BEATS_MIN = 3
|
||
BEAT_SPAN_RE = re.compile(
|
||
r"(?P<start>\d{1,2})\s*[-–—~到至]\s*(?P<end>\d{1,2})\s*(?:s|秒)?\s*[::]",
|
||
re.IGNORECASE,
|
||
)
|
||
# 景别(拍多大)与 机位/运镜(怎么拍)分开把关:出片模型两样都要,缺一样画面就会平。
|
||
_SHOT_SIZE_MARKERS = (
|
||
"大特写", "特写", "近景", "中近景", "中景", "全景", "远景", "胸上", "过肩",
|
||
)
|
||
_CAMERA_MOVE_MARKERS = (
|
||
"手持", "跟拍", "跟随", "俯拍", "仰拍", "平视", "推近", "推进", "推镜",
|
||
"拉远", "拉回", "拉镜", "摇镜", "横摇", "移镜", "平移", "环绕", "升降",
|
||
"固定机位", "定机位", "固定镜头", "过肩", "第一人称", "越肩",
|
||
)
|
||
_MINOR_CHARACTER_RE = re.compile(
|
||
r"(?:婴儿|宝宝|宝贝|幼儿|儿童|小孩|小朋友|未成年|男童|女童|baby|toddler|infant)",
|
||
re.IGNORECASE,
|
||
)
|
||
_NUMERIC_AGE_RE = re.compile(r"(?<!\d)(\d{1,2})\s*岁")
|
||
_CHINESE_MINOR_AGE_RE = re.compile(r"(?:[零一二三四五六七八九]岁|十岁|十[一二三四五六七]岁)")
|
||
_CANNED_DEFECT_RE = re.compile(r"唯一(?:的)?(?:小)?(?:缺点|不足|遗憾)")
|
||
_NATURAL_TURN_PHRASES = (
|
||
"我会额外留意的使用细节",
|
||
"换个场景更能看出差别的地方",
|
||
"下单前可以先确认的一点",
|
||
"实测时我更在意的细节",
|
||
)
|
||
|
||
|
||
_FORMAT_KEY_BY_LABEL = {label: key for key, label in PRESENTATION_FORMATS.items()}
|
||
_STRUCTURE_KEY_BY_LABEL = {label: key for key, label in VIDEO_STRUCTURES.items()}
|
||
|
||
|
||
def coerce_persona(value: str | None) -> str:
|
||
"""把设定卡 key 或中文标签收成 key;空/未知原样(未知时当补充描述用)。"""
|
||
raw = (value or "").strip()
|
||
if not raw:
|
||
return ""
|
||
if raw in PERSONA_LABELS:
|
||
return raw
|
||
return _PERSONA_KEY_BY_LABEL.get(raw, raw)
|
||
|
||
|
||
def persona_label(value: str | None) -> str:
|
||
key = coerce_persona(value)
|
||
return PERSONA_LABELS.get(key, key)
|
||
|
||
|
||
def _wizard_meta(project) -> dict:
|
||
meta = getattr(project, "metadata", None) or {}
|
||
wizard = meta.get("wizard") if isinstance(meta, dict) else None
|
||
return wizard if isinstance(wizard, dict) else {}
|
||
|
||
|
||
def _resolve_selling_point_ids(project, selling_point_ids: list | None) -> list[str]:
|
||
if selling_point_ids:
|
||
return [str(item) for item in selling_point_ids if item]
|
||
raw = _wizard_meta(project).get("selling_point_ids") or []
|
||
if not isinstance(raw, list):
|
||
return []
|
||
return [str(item) for item in raw if item]
|
||
|
||
|
||
def _resolve_persona(project, persona: str | None) -> str:
|
||
return coerce_persona(persona) or coerce_persona(_wizard_meta(project).get("persona"))
|
||
|
||
|
||
def combo_keys(value_format, value_structure) -> tuple[str, str]:
|
||
"""把「中文标签或 ASCII key」都归一成 key。落库存的是中文,请求传的是 key,两边都要认。"""
|
||
fmt = _FORMAT_KEY_BY_LABEL.get(value_format, value_format)
|
||
structure = _STRUCTURE_KEY_BY_LABEL.get(value_structure, value_structure)
|
||
return coerce_combo(fmt, structure)
|
||
|
||
|
||
def allowed_structures(fmt: str) -> list[str]:
|
||
"""某表现形式下可选的视频结构 key(1.8 组合联动:换表现形式,结构列表跟着变)。"""
|
||
fmt = fmt if fmt in PRESENTATION_FORMATS else DEFAULT_PRESENTATION_FORMAT
|
||
return [key for key in VIDEO_STRUCTURES if (fmt, key) not in FORBIDDEN_COMBOS]
|
||
|
||
|
||
def coerce_combo(fmt: str | None, structure: str | None) -> tuple[str, str]:
|
||
"""把任意输入夹成一组合法的(表现形式, 视频结构)。禁用组合回落到该形式的第一个合法结构。"""
|
||
fmt = fmt if fmt in PRESENTATION_FORMATS else DEFAULT_PRESENTATION_FORMAT
|
||
structure = structure if structure in VIDEO_STRUCTURES else DEFAULT_VIDEO_STRUCTURE
|
||
if (fmt, structure) in FORBIDDEN_COMBOS:
|
||
structure = allowed_structures(fmt)[0]
|
||
return fmt, structure
|
||
|
||
|
||
# 与前端 script-setup.ts CATEGORY_COMBOS 对齐。改这里时两边一起改。
|
||
_CATEGORY_COMBOS: dict[str, dict[str, str]] = {
|
||
"美妆个护": {"format": "oral", "structure": "contrast", "persona": "bestie"},
|
||
"食品饮料": {"format": "oral", "structure": "scene", "persona": "bestie"},
|
||
"服饰鞋包": {"format": "oral", "structure": "scene", "persona": "urban"},
|
||
"家居日用": {"format": "oral", "structure": "pain", "persona": "mom"},
|
||
"数码家电": {"format": "oral", "structure": "review", "persona": "reviewer"},
|
||
"母婴玩具": {"format": "oral", "structure": "pain", "persona": "mom"},
|
||
"运动健康": {"format": "oral", "structure": "scene", "persona": "urban"},
|
||
"珠宝配饰": {"format": "oral", "structure": "scene", "persona": "ceo"},
|
||
"汽车用品": {"format": "oral", "structure": "review", "persona": "reviewer"},
|
||
"宠物用品": {"format": "oral", "structure": "pain", "persona": "mom"},
|
||
"图书文教": {"format": "oral", "structure": "knowledge", "persona": "urban"},
|
||
"五金农资": {"format": "oral", "structure": "review", "persona": "reviewer"},
|
||
"其他商品": {"format": "oral", "structure": "pain", "persona": "urban"},
|
||
}
|
||
_CATEGORY_KEYWORD_RULES: tuple[tuple[tuple[str, ...], dict[str, str]], ...] = (
|
||
(("美妆", "护肤", "彩妆", "面膜", "精华", "洗护", "个护"), _CATEGORY_COMBOS["美妆个护"]),
|
||
(("保健", "营养", "膳食", "益生菌", "维生素"), {"format": "oral", "structure": "review", "persona": "reviewer"}),
|
||
(("数码", "3c", "电子", "电器", "手机", "耳机", "相机", "工具", "家电"), _CATEGORY_COMBOS["数码家电"]),
|
||
(("食品", "零食", "饮料", "咖啡", "茶", "生鲜", "酒"), _CATEGORY_COMBOS["食品饮料"]),
|
||
(("服饰", "女装", "男装", "服装", "鞋", "包", "配饰", "内衣"), _CATEGORY_COMBOS["服饰鞋包"]),
|
||
(("家居", "家纺", "收纳", "厨具", "清洁", "日用"), _CATEGORY_COMBOS["家居日用"]),
|
||
(("母婴", "宝宝", "儿童", "玩具"), _CATEGORY_COMBOS["母婴玩具"]),
|
||
(("宠物",), _CATEGORY_COMBOS["宠物用品"]),
|
||
(("户外", "露营", "运动", "健身"), _CATEGORY_COMBOS["运动健康"]),
|
||
)
|
||
_FALLBACK_COMBO: dict[str, str] = {"format": "oral", "structure": "pain", "persona": "urban"}
|
||
|
||
|
||
def recommend_script_setup(category: str = "", title: str = "") -> dict:
|
||
"""按商品品类推荐表现形式 × 视频结构 × 人物 × 时长。用户随时可改。"""
|
||
category = (category or "").strip()
|
||
title = (title or "").strip()
|
||
combo = _FALLBACK_COMBO
|
||
reason = "商品信息不足,先给一组最通用的"
|
||
if category in _CATEGORY_COMBOS:
|
||
combo = _CATEGORY_COMBOS[category]
|
||
reason = f"按「{category}」品类推荐"
|
||
else:
|
||
haystack = f"{category} {title}".lower()
|
||
for keywords, item in _CATEGORY_KEYWORD_RULES:
|
||
hit = next((word for word in keywords if word.lower() in haystack), None)
|
||
if hit:
|
||
combo = item
|
||
reason = f"按「{hit}」品类推荐"
|
||
break
|
||
# 自动推荐只给口播;Vlog / 短剧留给用户自己选。
|
||
fmt, structure = coerce_combo("oral", combo["structure"])
|
||
persona = coerce_persona(combo.get("persona") or "urban")
|
||
if persona not in PERSONA_LABELS:
|
||
persona = "urban"
|
||
duration = max(FORMAT_DEFAULT_DURATION[fmt], STRUCTURE_MIN_DURATION[structure])
|
||
return {
|
||
"format": fmt,
|
||
"structure": structure,
|
||
"persona": persona,
|
||
"duration": duration,
|
||
"reason": reason,
|
||
}
|
||
|
||
|
||
def narration_limit(duration: int) -> int:
|
||
"""这一镜旁白的字数上限:秒数 × 5.2,且不超过硬上限 78。"""
|
||
return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND)))
|
||
|
||
|
||
def narration_floor(duration: int) -> int:
|
||
"""15 秒口播至少要说到这个字数,再短就撑不满镜头。"""
|
||
cap = narration_limit(duration)
|
||
return max(1, min(cap - 6, int(duration * NARRATION_CHARS_PER_SECOND_MIN)))
|
||
|
||
|
||
def _compact_len(text: str) -> int:
|
||
return len(re.sub(r"\s+", "", text or ""))
|
||
|
||
|
||
def _speech_text(seg: dict) -> str:
|
||
dialogue = seg.get("dialogue") if isinstance(seg.get("dialogue"), list) else []
|
||
lines = [
|
||
str(item.get("line") or "")
|
||
for item in dialogue
|
||
if isinstance(item, dict) and (item.get("line") or "").strip()
|
||
]
|
||
if lines:
|
||
return "".join(lines)
|
||
return str(seg.get("narration") or "")
|
||
|
||
|
||
def min_beats_for_duration(duration: int) -> int:
|
||
if duration >= 12:
|
||
return SHOT_BEATS_MIN
|
||
if duration >= 8:
|
||
return 2
|
||
return 0
|
||
|
||
|
||
def parse_visual_beats(text: str) -> list[tuple[int, int, str]]:
|
||
"""从 visual 文本里抽出「0-3s:…」分镜。接得上才能喂下游视频。"""
|
||
raw = (text or "").strip()
|
||
if not raw:
|
||
return []
|
||
matches = list(BEAT_SPAN_RE.finditer(raw))
|
||
if not matches:
|
||
return []
|
||
beats: list[tuple[int, int, str]] = []
|
||
for index, match in enumerate(matches):
|
||
start, end = int(match.group("start")), int(match.group("end"))
|
||
if end < start:
|
||
start, end = end, start
|
||
body_to = matches[index + 1].start() if index + 1 < len(matches) else len(raw)
|
||
body = re.sub(r"[\n\r]+", " ", raw[match.end():body_to]).strip(" ;;,,、")
|
||
if start == end or not body:
|
||
continue
|
||
beats.append((start, end, body))
|
||
return beats
|
||
|
||
|
||
def format_visual_beats(beats: list[tuple[int, int, str]]) -> str:
|
||
return "\n".join(f"{start}-{end}s:{body}" for start, end, body in beats)
|
||
|
||
|
||
def _is_minor_character(name: str, visual_prompt: str) -> bool:
|
||
"""角色基础资产不得是未成年人,避免真人生图审核拦截与儿童肖像风险。"""
|
||
text = f"{name or ''} {visual_prompt or ''}"
|
||
if _MINOR_CHARACTER_RE.search(text) or _CHINESE_MINOR_AGE_RE.search(text):
|
||
return True
|
||
return any(int(age) < 18 for age in _NUMERIC_AGE_RE.findall(text))
|
||
|
||
|
||
def _product_only_visual(duration: int) -> str:
|
||
"""模型误建未成年人角色时,后端降级为无人物的商品导演说明,绝不下传儿童画面。"""
|
||
end = max(4, min(SEGMENT_DURATION_MAX, int(duration or SEGMENT_DURATION_MAX)))
|
||
if end >= 12:
|
||
beats = [
|
||
(0, 3, "商品包装与整体外观特写;俯拍;缓慢推近;商品平放在干净展示台;先看清真实配色与轮廓。"),
|
||
(3, 8, "商品关键材质与细节近景;固定机位;微距横移;镜头逐项扫过结构和图案;信息从整体转到细节。"),
|
||
(8, 12, "商品使用相关部位特写;45度侧拍;缓慢推近;只展示商品本身与必要道具;强调真实做工。"),
|
||
(12, end, "商品完整陈列中景;平视;轻微拉远;干净背景中保留商品主体;画面收束到购买信息。"),
|
||
]
|
||
else:
|
||
mid = max(1, end // 2)
|
||
beats = [
|
||
(0, mid, "商品整体外观特写;俯拍;缓慢推近;商品平放在干净展示台;看清真实配色与轮廓。"),
|
||
(mid, end, "商品细节近景;45度侧拍;微距横移;只展示商品本身;画面收束到关键结构。"),
|
||
]
|
||
return "【本镜任务】用商品本身传达关键信息,不出现人物。\n【声音】旁白继续,画面不出现未成年人。\n【画面内容】\n" + format_visual_beats(beats)
|
||
|
||
|
||
def _replace_canned_defect_phrase(text: str) -> str:
|
||
"""不改模型给出的事实,只替换会让测评显得千篇一律的“唯一缺点”话术。"""
|
||
if not text or not _CANNED_DEFECT_RE.search(text):
|
||
return text
|
||
variant = _NATURAL_TURN_PHRASES[sum(map(ord, text)) % len(_NATURAL_TURN_PHRASES)]
|
||
return _CANNED_DEFECT_RE.sub(variant, text)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# skill 加载(缓存)
|
||
# --------------------------------------------------------------------------- #
|
||
def _skill_dir() -> Path:
|
||
override = getattr(settings, "ECOMMERCE_SKILL_DIR", None)
|
||
if override:
|
||
return Path(override)
|
||
# skills 已随后端打进镜像(core/backend/skills);优先 BASE_DIR/skills,回落仓库根(本地/旧布局)。
|
||
# 旧版只看仓库根 → 镜像里没有(构建上下文是 ./core/backend)→ 提示词为空、退化兜底。详见 services._skills_root。
|
||
base = Path(settings.BASE_DIR)
|
||
for cand in (base / "skills", base.parent.parent / "skills"):
|
||
if (cand / "ecommerce-video-script").is_dir():
|
||
return cand / "ecommerce-video-script"
|
||
return base / "skills" / "ecommerce-video-script"
|
||
|
||
|
||
def _read_ref(path: Path, label: str) -> str:
|
||
if not path.exists():
|
||
return ""
|
||
return f"\n\n===== {label} =====\n\n{path.read_text(encoding='utf-8')}"
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def _load_skill_base() -> str:
|
||
"""SKILL.md + references 根目录下的通用资料(方法论/钩子库/品类/平台/自检),每次都要。
|
||
|
||
playbooks/ 是子目录,glob("*.md") 不会递归到,套路由 load_ecommerce_skill 按组合单独挑。
|
||
"""
|
||
skill_dir = _skill_dir()
|
||
parts: list[str] = []
|
||
main = skill_dir / "SKILL.md"
|
||
if main.exists():
|
||
parts.append(main.read_text(encoding="utf-8"))
|
||
ref_dir = skill_dir / "references"
|
||
if ref_dir.exists():
|
||
for ref in sorted(ref_dir.glob("*.md")):
|
||
parts.append(_read_ref(ref, f"references/{ref.name}"))
|
||
return "".join(parts)
|
||
|
||
|
||
@lru_cache(maxsize=32)
|
||
def load_ecommerce_skill(
|
||
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
|
||
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
|
||
) -> str:
|
||
"""通用资料 + 组合矩阵 + **只挑被选中的那一份表现形式和那一份视频结构**。
|
||
|
||
套路全量灌进去会让系统提示词翻倍(每份 2-3K 字),而且模型会在 11 套互相矛盾的
|
||
镜头语言里挑花眼。只给当前这一组,提示词更短、约束更硬。
|
||
"""
|
||
fmt, structure = coerce_combo(presentation_format, video_structure)
|
||
playbooks = _skill_dir() / "references" / "playbooks"
|
||
parts = [
|
||
_load_skill_base(),
|
||
_read_ref(playbooks / "combo-matrix.md", "references/playbooks/combo-matrix.md"),
|
||
_read_ref(playbooks / f"format-{fmt}.md", f"references/playbooks/format-{fmt}.md"),
|
||
_read_ref(playbooks / f"structure-{structure}.md", f"references/playbooks/structure-{structure}.md"),
|
||
]
|
||
joined = "".join(parts)
|
||
if not joined.strip():
|
||
# 兜底:skill 文件缺失也能退化生成(交接文档会提示补 skills 目录)
|
||
return "你是电商带货短视频脚本生成 agent,输出结构化 ScriptDraft JSON。"
|
||
return joined
|
||
|
||
|
||
# 运行时输出协议:优先级高于 skill 里的「只输出 JSON / 不展示思考」,只为流式体感放开一句前言。
|
||
_OUTPUT_PROTOCOL = """
|
||
|
||
---
|
||
|
||
## 运行时输出协议(AirShelf 流式展示专用,优先级高于技能正文的「只输出 JSON」)
|
||
|
||
严格按以下顺序输出,不要有别的内容:
|
||
1. 先用 **1 句中文口语**告诉用户你正在做什么(≤40 字,例:「在为这款保温杯生成 2 镜痛点脚本…」),让用户看到进展;
|
||
2. 紧接着输出**且仅输出一个** ```json 代码块,内容为符合技能契约(铁律1)的 ScriptDraft 对象;
|
||
3. json 代码块**收尾之后另起一行**,用 **1–2 句中文口语**跟用户交付这一版:做了什么、为什么这么改、可以怎么接着调(像同事汇报,**别复述 JSON 字段、别再写代码块**)。这句会作为你的回复气泡展示给用户。
|
||
|
||
### 字段名锚定(硬性 · 下游靠它取数,跑偏即数据全空)
|
||
- 分镜数组的键名**必须**叫 `segments`(禁止用 scenes / script / shots / 分镜 等同义词)。
|
||
- 每镜口播键名**必须**叫 `narration`(禁止用 voiceover / audio / line)。
|
||
- 每镜画面键名**必须**叫 `visual`(禁止用 scene / screen / 画面 当键名)。
|
||
- `visual` 是**多行字符串**,每行一条秒级分镜:`0-3s:近景,……`(禁止只写一句静态动作,也禁止只给 setting/camera 对象而不给 visual)。
|
||
- 允许额外给 `beats`/`shots` 数组,后端会折进 visual;有数组也必须能折成「起-止秒」格式。
|
||
- 即使你额外附带了 scenes / shots 等创作结构,也**必须同时**给出标准 `segments` 数组,并把口播填进 `narration`、画面填进 `visual`,否则视为不合格。
|
||
"""
|
||
|
||
|
||
# 创作方向不放在前端文案里,而是和输出协议一起作为运行时最高优先级约束。
|
||
# 它解决的不是 JSON 合不合法,而是「脚本合格却像商品详情页朗读」的问题。
|
||
_CREATIVE_DIRECTION = """
|
||
|
||
---
|
||
|
||
## 出片感与转化感(硬性创作方向)
|
||
|
||
**先服从用户选定的视频结构。** 用户选的是痛点解决、前后对比、测评验证、场景种草、促销抢购或知识分享中的哪一种,
|
||
就只走该结构合同的顺序与说服方式;不能因为“更好写”而擅自换成痛点开场、测评口吻或场景氛围。
|
||
下列通用写法仅用于增强已选结构,和结构合同冲突时一律以结构合同为准。
|
||
|
||
你的脚本不能像商品详情页、说明书或主播念稿;它必须让用户在第一秒看到一个
|
||
**正在发生的具体瞬间**。先选一个最能代表典型使用者的「时间 + 地点 + 小麻烦/小欲望」,
|
||
全片只围绕这一个情境推进。不要在一条短视频里罗列所有卖点。
|
||
|
||
### 每一镜都必须有推进,禁止平铺直叙
|
||
- 钩子镜:前 15 个字就抛出反常、尴尬、选择或结果;禁止「大家好」「今天分享」
|
||
「给你们推荐」「这款很好用」「值得买」等任何寒暄或泛推荐开场。
|
||
- 痛点镜:拍得到的细节,而非抽象感受。例如「开会前刘海粘成一绺」而不是「头发很油」。
|
||
- 卖点镜:只证明一个卖点,必须写清「原本卡在哪里 → 手怎么使用商品 → 眼前有什么变化」。
|
||
不准只报参数、堆形容词或重复商品名。
|
||
- CTA 镜:回到前面那个具体情境,给一个自然的选择/动作;不要硬喊「赶紧下单」。
|
||
|
||
### 画面与台词必须互相提供新信息
|
||
- 每个 15 秒场只安排**一个连续的核心动作链**,例如「拆开 → 倒入 → 颜色变化」,
|
||
不要在同一段塞进无关剧情、三个卖点和多次场景跳转。
|
||
- 台词说人物的判断、感受或转折;画面证明这句话。台词不要逐字复述画面。
|
||
- 至少有一个可见证据:包装/质地/声音/前后状态/动作结果。没有可见证据的卖点不写。
|
||
- 用像真实人在当下会说的短句,可以有停顿、转折和个人立场;禁止「不仅…而且…」
|
||
「全面升级」「高品质」「性价比很高」「闭眼入」等详情页腔。
|
||
|
||
输出前自问:遮住商品名后,这是不是仍然像一个真实的人在讲自己刚遇到的一件事?
|
||
如果像广告口号,重写得更具体、更有立场。
|
||
|
||
### visual 必须写成「导演说明书」,因为它是出片模型唯一的画面依据
|
||
|
||
**这条最重要:流程里没有分镜图了。** 你写的 `visual` 不会再经过一张关键帧图去"翻译",
|
||
而是**连同角色/商品/场景参考图一起,原样交给视频生成模型**。你没写清楚的,模型就自己编;
|
||
你写含糊的,画面就含糊。所以 `visual` 要写到「一个不认识这个商品的摄影师,照着就能拍」的程度。
|
||
|
||
每个 segment 的 `visual` 采用下面的层级,逐栏写满:
|
||
```
|
||
【本镜任务】这一段要让观众看懂的变化或悬念。
|
||
【光线氛围】光源方向与性质(窗光/顶光/台灯/逆光);色温冷暖;明暗对比;整体色调。全片各镜保持一致。
|
||
【声音】台词/旁白状态;音效;背景音乐;字幕。没有就写「无」;没有配乐写「无配乐,仅同期声」。
|
||
【画面内容】
|
||
0-3s:景别;机位高度与角度;运镜;主体在画面中的位置;具体动作;情绪或信息变化。
|
||
3-8s:景别;机位;运镜;手与商品的空间关系(哪只手、握哪里、从哪个方向入画);可见细节。
|
||
8-12s:景别;机位;运镜;动作结果或卖点证据(看得见的变化:颜色/质地/形态/前后差别)。
|
||
12-15s:景别;机位;运镜;反应/悬念/自然收束。
|
||
```
|
||
|
||
每条秒级分镜必须同时写出这五项,不能省:
|
||
1. **景别** —— 大特写/特写/近景/中近景/中景/全景(15 秒内至少切两次,别一个景别拍到底)
|
||
2. **机位** —— 高度与角度:平视/俯拍/仰拍/过肩/桌面视角
|
||
3. **运镜** —— 手持跟拍/推近/拉远/横摇/环绕/固定机位(**每镜至少出现一个运镜词**,否则画面会死)
|
||
4. **动作** —— 具体到肢体:谁、用哪只手、对什么、做了什么动作,动作要连贯可拍
|
||
5. **信息变化** —— 这 3–5 秒里画面上多了什么、变了什么
|
||
|
||
### 只写拍得出来的东西
|
||
- **禁止抽象词**:「展示商品」「呈现质感」「体现高级感」「氛围到位」「传递温暖」——
|
||
这些不是画面,是评价。要写成「杯壁上的水珠顺着往下滑」「她眉头松开,肩膀塌下来」。
|
||
- **禁止心理描写**:模型拍不出「她内心很纠结」,要写「她拿起又放下,手指在包装边缘停了两秒」。
|
||
- **禁止画面里出现文字**:不要写字幕、花字、标题、价格贴片、UI、弹窗、对比图表;
|
||
商品包装上原有的真实文字除外。
|
||
- **禁止一镜内跨场景/跨时间蒙太奇**:15 秒是**一个连续的动作链**,同一地点、同一光线、
|
||
同一套衣服。要换环境就换到下一镜,并在 entity_refs 里换成另一个 scene。
|
||
|
||
### 一致性靠文字锁死
|
||
同一角色、商品、场景的外观一律引用 entities 里的既定设定;不在每一镜随意换发型、服装、
|
||
包装、光线、地点或色调。每一镜的【光线氛围】要和相邻镜衔接得上(同一场戏不能上一镜暖黄台灯、
|
||
下一镜冷白日光)。商品的用法要符合物理常识:热饮有蒸汽、液体会流动、包装要先打开才能取出内容物。
|
||
|
||
输入如果是 `【镜头 01】` 导演分镜稿,把原稿的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注
|
||
折进对应栏,不要压成一句画面摘要。
|
||
"""
|
||
|
||
# 这些句式几乎总会让首屏像模板广告。只作为生成后的最后一道门,不替代模型的创作判断。
|
||
_GENERIC_HOOK_PHRASES = (
|
||
"大家好", "今天", "给大家", "给你们", "给姐妹", "分享一下", "推荐一下",
|
||
"这款", "好物分享", "真的好用", "值得买", "闭眼入", "宝子们",
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 提示词构建(3 模式)
|
||
# --------------------------------------------------------------------------- #
|
||
def _specs_lines(specs) -> str:
|
||
if not isinstance(specs, dict) or not specs:
|
||
return ""
|
||
skip = {"source"}
|
||
labels = {"price": "价格"}
|
||
lines: list[str] = []
|
||
for key, val in specs.items():
|
||
if key in skip or val in (None, "", [], {}):
|
||
continue
|
||
if isinstance(val, (dict, list)):
|
||
rendered = json.dumps(val, ensure_ascii=False)
|
||
else:
|
||
rendered = str(val).strip()
|
||
if not rendered:
|
||
continue
|
||
lines.append(f"- {labels.get(key, key)}:{rendered}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _product_facts(project, selling_point_ids: list[str] | None):
|
||
"""商品事实 + 本次勾选的卖点(供提示词和落库前原词校验共用)。
|
||
|
||
新建向导历史上把卖点**标题**(如「茶」)写进 metadata.wizard.selling_point_ids,
|
||
字段名叫 ids 但不是 UUID。按 id__in 过滤会直接 ValidationError 把 SSE 打崩。
|
||
这里 UUID 和标题都认;对不上就回落全部卖点,绝不抛。
|
||
"""
|
||
product = project.product
|
||
keys = [str(item).strip() for item in _resolve_selling_point_ids(project, selling_point_ids) if str(item).strip()]
|
||
all_points = list(product.selling_points.all())
|
||
if not keys:
|
||
return product, all_points
|
||
uuid_keys: set[str] = set()
|
||
title_keys: set[str] = set()
|
||
for key in keys:
|
||
try:
|
||
uuid_keys.add(str(uuid.UUID(key)))
|
||
except (ValueError, AttributeError, TypeError):
|
||
title_keys.add(key)
|
||
matched = [
|
||
point
|
||
for point in all_points
|
||
if str(getattr(point, "id", "") or "") in uuid_keys
|
||
or (getattr(point, "title", "") or "").strip() in title_keys
|
||
]
|
||
return product, matched or all_points
|
||
|
||
|
||
def _product_context(project, selling_point_ids: list[str] | None, persona: str | None = None) -> str:
|
||
product, selling_points = _product_facts(project, selling_point_ids)
|
||
selling_text = "\n".join(f"- {sp.title}:{sp.detail or sp.title}" for sp in selling_points)
|
||
business_type = getattr(product, "business_type", "") or "ecommerce"
|
||
if business_type == "local_life":
|
||
type_line = "业务类型:本地生活(团购/到店核销,无实物,按虚拟商品走主流程 SOP)\n"
|
||
else:
|
||
type_line = "业务类型:电商(实物商品)\n"
|
||
specs_text = _specs_lines(getattr(product, "specs", None) or {})
|
||
persona_key = _resolve_persona(project, persona)
|
||
persona_text = persona_label(persona_key)
|
||
persona_brief = PERSONA_BRIEFS.get(persona_key, "")
|
||
must = [f"商品名「{product.title}」"] if product.title else []
|
||
if product.brand:
|
||
must.append(f"品牌「{product.brand}」")
|
||
must.extend(f"卖点「{sp.title.strip()}」" for sp in selling_points if (sp.title or "").strip())
|
||
must_line = "、".join(must) if must else "无(根据商品描述自行提炼,禁止空话套话)"
|
||
specs_block = f"\n{specs_text}" if specs_text else "未填写"
|
||
desc = (product.description or "").strip() or "未填写"
|
||
return (
|
||
f"商品标题:{product.title}\n"
|
||
f"品牌:{product.brand or '未填写'}\n"
|
||
f"{type_line}"
|
||
f"品类:{product.category or '未填写'}\n"
|
||
f"人物设定:{persona_text or '未指定,按商品信息自行定一个具体身份'}\n"
|
||
f"人物口吻:{persona_brief or '按人物设定自己定一个具体身份,不要用万能主播腔'}\n"
|
||
f"商品描述:{desc}\n"
|
||
f"规格:{specs_block}\n"
|
||
f"本次必须用上的卖点:\n{selling_text or '未勾选卖点,请根据商品信息自行提炼,禁止空话。'}\n"
|
||
f"【必须原词用上】旁白/对白里要出现:{must_line}。"
|
||
f"禁止用「补水/好用/值得买/宝藏」这类空卖点替换上面的原词。"
|
||
f"商品名全片点名 1–2 次即可,其余镜用卖点原词和可感知细节(口感/气味/动作/使用场景),不要每句重复商品名。"
|
||
f"描述若未填写,就从标题+卖点把使用感写具体,禁止只喊商品名。"
|
||
)
|
||
|
||
|
||
def _script_product_reference_urls(project, model_config: ModelConfig | None = None) -> list[str]:
|
||
"""脚本模型可见的商品实拍图,最多三张。
|
||
|
||
标题和卖点通常没有颜色、外形等视觉事实。若脚本先写错,后面的视频模型就会收到
|
||
相互矛盾的指令。Seed 2.1 Pro 原生支持图文消息;其他模型须在后台显式声明视觉能力,
|
||
避免把图片发给纯文本模型而导致脚本生成失败。
|
||
"""
|
||
if model_config is None:
|
||
return []
|
||
metadata = model_config.metadata if isinstance(model_config.metadata, dict) else {}
|
||
capabilities = metadata.get("capabilities") if isinstance(metadata.get("capabilities"), dict) else {}
|
||
features = {str(item) for item in capabilities.get("features") or []}
|
||
known_seed_vision = str(model_config.name or "").startswith("doubao-seed-2-1-pro-")
|
||
if not (known_seed_vision or {"vision", "image_input", "multimodal"} & features):
|
||
return []
|
||
# 与图片/视频链路一致:真实上传图优先、主图优先,最多三张。
|
||
from apps.ai.services import _product_reference_urls
|
||
|
||
return _product_reference_urls(project.product, limit=3)
|
||
|
||
|
||
def _append_product_visual_references(messages: list[dict], image_urls: list[str]) -> list[dict]:
|
||
"""将用户选择的真实商品图附到任务消息,并声明其为外观事实的最高优先级。"""
|
||
if not image_urls or not messages:
|
||
return messages
|
||
result = [dict(message) for message in messages]
|
||
last = dict(result[-1])
|
||
text = str(last.get("content") or "")
|
||
content: list[dict] = [{
|
||
"type": "text",
|
||
"text": (
|
||
f"{text}\n\n【商品视觉参考·最高优先级】以下 {len(image_urls)} 张图片是用户选择的真实商品图。"
|
||
"颜色、外形、材质、结构、配件与可见品牌标识必须以图片为准;若文字资料与图片不一致,以图片为准。"
|
||
"旁白、商品实体 visual_prompt 和每镜 visual 不得臆测或改写这些外观事实;"
|
||
"尤其不得把深色商品写成白色或浅色商品。"
|
||
),
|
||
}]
|
||
content.extend({"type": "image_url", "image_url": {"url": url}} for url in image_urls)
|
||
last["content"] = content
|
||
result[-1] = last
|
||
return result
|
||
|
||
|
||
def build_agent_messages(
|
||
*,
|
||
project,
|
||
mode: str,
|
||
user_prompt: str,
|
||
selling_point_ids: list[str] | None,
|
||
base_draft: dict | None,
|
||
aspect_ratio: str,
|
||
total_duration: int,
|
||
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
|
||
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
|
||
target_index: int | None = None,
|
||
persona: str | None = None,
|
||
product_image_urls: list[str] | None = None,
|
||
) -> list[dict]:
|
||
fmt, structure = coerce_combo(presentation_format, video_structure)
|
||
if target_index is not None:
|
||
try:
|
||
total = int(total_duration)
|
||
except (TypeError, ValueError):
|
||
total = DEFAULT_TOTAL_DURATION
|
||
if total <= 0:
|
||
total = DEFAULT_TOTAL_DURATION
|
||
else:
|
||
total = coerce_total_duration(total_duration)
|
||
system = load_ecommerce_skill(fmt, structure) + _CREATIVE_DIRECTION + _OUTPUT_PROTOCOL
|
||
|
||
suggested = plan_segment_durations(total, fmt)
|
||
shot_n = len(suggested)
|
||
stage_plan = structure_stage_plan(structure, shot_n)
|
||
extra = (user_prompt or "").strip()
|
||
if target_index is not None and base_draft:
|
||
existing_n = len(base_draft.get("segments") or [])
|
||
duration_line = (
|
||
f"【分镜时长】保持现有 {existing_n} 镜和每镜秒数,"
|
||
f"只改第 {target_index + 1} 镜的文案与画面,不要加减镜、不要改时长。\n"
|
||
)
|
||
else:
|
||
duration_line = (
|
||
f"【分镜时长】每镜必须 {SEGMENT_DURATION_MAX} 秒,一共 {shot_n} 镜;"
|
||
f"禁止写成 8/10/12 这种不等长,也禁止加减镜数。\n"
|
||
)
|
||
speech_floor = narration_floor(SEGMENT_DURATION_MAX)
|
||
speech_cap = narration_limit(SEGMENT_DURATION_MAX)
|
||
beats_line = (
|
||
f"【秒级分镜】流程里没有分镜图了,visual 会**原样交给出片模型**,写多细画面就有多准。"
|
||
f"每个 {SEGMENT_DURATION_MAX} 秒场必须拆成 3–5 个分镜,visual 按「导演说明书」写成多行:"
|
||
"先写【本镜任务】、【光线氛围】(光源方向/色温/明暗/色调,各镜保持一致)、【声音】、【画面内容】,再写秒级分镜;"
|
||
f"每条格式`0-3s:景别;机位;运镜;主体位置与具体动作;信息变化`,最后一条接到 {SEGMENT_DURATION_MAX}s。"
|
||
"每条必须带景别(15 秒内至少切两次)+ 机位 + 运镜词(手持/跟拍/推近/拉远/环绕/固定机位,缺了画面会死),"
|
||
"并写清哪只手、从哪个方向入画、握商品哪里,以及真实用法"
|
||
"(茶=热水+蒸汽+茶汤变色,禁止茶包丢进冷白开;禁止悬浮肢体、商品凭空出现)。"
|
||
"只写拍得出来的东西:禁止「展示商品/呈现质感/体现高级感」这类评价词,禁止心理描写,"
|
||
"禁止画面里出现字幕花字标题价签,禁止一镜内跨场景跨时间。"
|
||
f"禁止一句空画面撑满 {SEGMENT_DURATION_MAX} 秒。允许另给 beats 数组,后端会折进 visual。\n"
|
||
)
|
||
if fmt == "oral":
|
||
writing_line = (
|
||
f"【写法硬约束】口播每镜必须说满 {speech_floor}–{speech_cap} 字(2–4 句短句,"
|
||
f"禁止一句 20 字收工);visual 至少 {VISUAL_CHARS_MIN} 字。"
|
||
"第一镜用人物口吻交代身份,不要万能主播腔。\n"
|
||
)
|
||
elif fmt == "drama":
|
||
writing_line = (
|
||
f"【写法硬约束】对白每镜合计 {speech_floor}–{speech_cap} 字;"
|
||
f"visual 至少 {VISUAL_CHARS_MIN} 字。\n"
|
||
)
|
||
else:
|
||
writing_line = (
|
||
f"【写法硬约束】有人声的镜口播/对白 {speech_floor}–{speech_cap} 字,允许个别镜纯画面;"
|
||
f"visual 至少 {VISUAL_CHARS_MIN} 字。\n"
|
||
)
|
||
structure_line = STRUCTURE_CONTRACTS[structure]
|
||
stage_line = "【镜序验收】" + ";".join(
|
||
f"第 {index + 1} 镜:{stage}" for index, stage in enumerate(stage_plan)
|
||
) + "。镜数不够时只可合并相邻步骤,顺序不得颠倒,也不得插入其他结构的主线。\n"
|
||
combo_line = (
|
||
f"严格按已加载的「{PRESENTATION_FORMATS[fmt]} × {VIDEO_STRUCTURES[structure]}」套路写,"
|
||
f"不要串成别的结构的套话。\n"
|
||
)
|
||
authenticity_line = (
|
||
"【真实感转折】禁止使用「唯一缺点/唯一不足/唯一的小遗憾」这类模板句,也不要为了显得真实而编造缺点。"
|
||
"每版从以下角度自然选一个推进:测试过程里的意外发现、适用人群的边界、不同使用场景的反差、"
|
||
"一个可观察的细节、或使用习惯建议;必须由商品资料或画面可观察事实支持,不能每镜重复同一种。\n"
|
||
)
|
||
head = (
|
||
f"【画幅】{aspect_ratio}\n"
|
||
f"【表现形式】{PRESENTATION_FORMATS[fmt]}(套路见 playbooks/format-{fmt}.md,已加载)\n"
|
||
f"【视频结构】{VIDEO_STRUCTURES[structure]}(套路见 playbooks/structure-{structure}.md,已加载)\n"
|
||
f"【总时长】{total} 秒\n"
|
||
f"{duration_line}"
|
||
f"{writing_line}"
|
||
f"{beats_line}"
|
||
f"{structure_line}"
|
||
f"{stage_line}"
|
||
f"{combo_line}"
|
||
f"{authenticity_line}"
|
||
f"【商品信息】\n{_product_context(project, selling_point_ids, persona)}"
|
||
)
|
||
if mode == "revise" and base_draft and target_index is not None:
|
||
# 精准改一镜:读全脚本上下文,只重写第 N 镜,强制与前后镜衔接;其余镜后端会强制保持原样。
|
||
user = (
|
||
f"【任务】只重写第 {target_index + 1} 镜(共 {len(base_draft.get('segments', []))} 镜),其余镜保持不变。\n"
|
||
f"{head}\n\n"
|
||
f"【现有完整脚本 JSON(读它保证与前后镜衔接)】\n{json.dumps(base_draft, ensure_ascii=False)}\n\n"
|
||
f"【对第 {target_index + 1} 镜的修改意见】{extra or '让这一镜更有吸引力、表达更清晰。'}\n\n"
|
||
"仍输出**完整** ScriptDraft(我只会采用第 "
|
||
f"{target_index + 1} 镜的改动);若意见涉及角色对白,就给这一镜填 dialogue。"
|
||
)
|
||
elif mode == "revise" and base_draft:
|
||
user = (
|
||
"【任务】改稿(模式③):在保留用户原意的前提下,增强钩子/节奏/卖点/CTA,并归一化到契约 JSON。\n"
|
||
f"{head}\n\n"
|
||
f"【现有脚本 JSON】\n{json.dumps(base_draft, ensure_ascii=False)}\n\n"
|
||
f"【用户修改意见】{extra or '让整体更有吸引力、转化感更强,并保持各镜衔接连贯。'}\n\n"
|
||
"请输出修订后的**完整** ScriptDraft。"
|
||
)
|
||
elif mode == "theme":
|
||
user = (
|
||
"【任务】一句话主题扩写(模式②):以用户主题为脚本主轴,但商品事实和必须原词仍要全部用上。\n"
|
||
f"{head}\n\n"
|
||
f"【用户主题】{extra or '按商品最强卖点选题'}\n\n"
|
||
"请按技能流程一次性产出 ScriptDraft。"
|
||
)
|
||
else:
|
||
extra_block = f"\n\n【补充要求】{extra}\n" if extra else "\n"
|
||
if extra and _looks_like_shot_digest(extra):
|
||
user = (
|
||
"【任务】参考视频改写:用户给了一份逐镜拆解稿。"
|
||
"照搬它的镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,"
|
||
"把人物、商品、品牌、台词全部换成当前商品。"
|
||
"写 visual 时把拆解稿里的景别/机位/运镜/人物动作/表情/音效/背景音乐/字幕/备注"
|
||
"折进【声音】和【画面内容】的秒级分镜,不要压成一句画面摘要。"
|
||
"原稿写「无 / 不可见 / 听不清」的栏不要编造。"
|
||
"钩子、已选结构的核心步骤和商品事实必须能对上。"
|
||
"每镜旁白要能撑满指定时长;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n"
|
||
f"{head}"
|
||
f"{extra_block}\n"
|
||
"请按技能流程一次性产出 ScriptDraft。"
|
||
)
|
||
else:
|
||
user = (
|
||
"【任务】全自动(模式①):仅凭上面的商品事实与前置条件,按指定的表现形式与视频结构套路"
|
||
"自动定镜/选 tone/造 entity/填结构骨架。"
|
||
"不要另起一个空主题;钩子、已选结构的核心步骤和商品事实必须能对上这份商品,而不是品类套话。"
|
||
"每镜旁白要能撑满 15 秒;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n"
|
||
f"{head}"
|
||
f"{extra_block}\n"
|
||
"请按技能流程一次性产出 ScriptDraft。"
|
||
)
|
||
return _append_product_visual_references(
|
||
[{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||
product_image_urls or [],
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# JSON 抽取 + 契约规范化(模型无关,后端兜底)
|
||
# --------------------------------------------------------------------------- #
|
||
def _balanced_object(text: str) -> str | None:
|
||
"""从首个 '{' 起按括号深度扫描,返回第一个配平的 {...}(忽略字符串内的括号)。
|
||
避免 rfind('}') 在 JSON 后还有含花括号的散文时越界截出非法片段。"""
|
||
start = text.find("{")
|
||
if start == -1:
|
||
return None
|
||
depth = 0
|
||
in_str = False
|
||
esc = False
|
||
for i in range(start, len(text)):
|
||
c = text[i]
|
||
if in_str:
|
||
if esc:
|
||
esc = False
|
||
elif c == "\\":
|
||
esc = True
|
||
elif c == '"':
|
||
in_str = False
|
||
continue
|
||
if c == '"':
|
||
in_str = True
|
||
elif c == "{":
|
||
depth += 1
|
||
elif c == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
return text[start : i + 1]
|
||
return None
|
||
|
||
|
||
def _looks_like_shot_digest(text: str) -> bool:
|
||
"""用户贴进来的是视频提炼分镜稿,不是一句补充要求。"""
|
||
blob = text or ""
|
||
has_shot = "【镜头" in blob or ("【第" in blob and "镜】" in blob)
|
||
has_picture = "画面:" in blob or "画面:" in blob
|
||
return has_shot and has_picture
|
||
|
||
|
||
def _looks_like_draft(blob: str) -> bool:
|
||
try:
|
||
d = json.loads(blob)
|
||
except (ValueError, TypeError):
|
||
return False
|
||
return isinstance(d, dict) and ("segments" in d or "hook" in d)
|
||
|
||
|
||
def _extract_json(text: str) -> str | None:
|
||
"""抽取 ScriptDraft JSON。容错:模型可能先给示例 ```json 块再给正式块,
|
||
故取**最后一个**含 segments/hook 的合法围栏块;都不像草稿再退而取末个配平对象;无围栏再裸扫。"""
|
||
fences = re.findall(r"```(?:json)?\s*(.+?)```", text, re.DOTALL)
|
||
for block in reversed(fences):
|
||
obj = _balanced_object(block)
|
||
if obj and _looks_like_draft(obj):
|
||
return obj
|
||
for block in reversed(fences):
|
||
obj = _balanced_object(block)
|
||
if obj:
|
||
return obj
|
||
return _balanced_object(text)
|
||
|
||
|
||
def coerce_total_duration(value) -> int:
|
||
"""总时长夹到 15–60 秒、15 秒步进(15/30/45/60)。空值/0/非法输入一律回落默认 30。"""
|
||
if value in (None, "", 0):
|
||
return DEFAULT_TOTAL_DURATION
|
||
try:
|
||
value = int(value)
|
||
except (TypeError, ValueError):
|
||
return DEFAULT_TOTAL_DURATION
|
||
if value <= 0:
|
||
return DEFAULT_TOTAL_DURATION
|
||
value = max(TOTAL_DURATION_MIN, min(TOTAL_DURATION_MAX, value))
|
||
stepped = int(round(value / TOTAL_DURATION_STEP) * TOTAL_DURATION_STEP)
|
||
return max(TOTAL_DURATION_MIN, min(TOTAL_DURATION_MAX, stepped))
|
||
|
||
|
||
def plan_segment_durations(total_duration: int, presentation_format: str | None = None) -> list[int]:
|
||
"""主流程每镜固定 15 秒。镜数 = 总时长 / 15。presentation_format 保留签名以免调用方改动。"""
|
||
total = coerce_total_duration(total_duration)
|
||
count = max(1, total // SEGMENT_DURATION_MAX)
|
||
return [SEGMENT_DURATION_MAX] * count
|
||
|
||
|
||
def structure_stage_plan(video_structure: str, segment_count: int) -> list[str]:
|
||
"""把选中结构的完整说服路径按当前镜数连续压缩,绝不换成通用痛点模板。"""
|
||
stages = list(STRUCTURE_STAGES[video_structure])
|
||
count = max(1, int(segment_count or 1))
|
||
if count >= len(stages):
|
||
return stages + [stages[-1]] * (count - len(stages))
|
||
# 前面镜先承接前置步骤,最后一镜必须保留收束/CTA;每个阶段只会向相邻镜合并。
|
||
groups: list[list[str]] = [[] for _ in range(count)]
|
||
for index, stage in enumerate(stages):
|
||
target = min(count - 1, index * count // len(stages))
|
||
groups[target].append(stage)
|
||
return [" → ".join(group) for group in groups]
|
||
|
||
|
||
def plan_roles(count: int) -> list[str]:
|
||
"""镜数 → role 序列。通用规则:首钩子、次痛点、末 CTA,中间全是卖点。"""
|
||
if count <= 1:
|
||
return ["钩子"]
|
||
if count == 2:
|
||
return ["钩子", "卖点"]
|
||
if count == 3:
|
||
return ["钩子", "卖点", "CTA"]
|
||
return ["钩子", "痛点"] + ["卖点"] * (count - 3) + ["CTA"]
|
||
|
||
|
||
def _fit_segment_durations(raw: list, total: int, presentation_format: str) -> list[int]:
|
||
"""主流程强制每镜 15 秒。模型给多少秒都丢掉,按总时长切成 N 个 15。"""
|
||
return plan_segment_durations(total, presentation_format)
|
||
|
||
|
||
def _layout_durations(preserve_layout: dict | None, count: int) -> list[int] | None:
|
||
"""精准改一镜:保住原稿每镜秒数,避免 3 镜旧稿被压成 2×15。"""
|
||
if not preserve_layout:
|
||
return None
|
||
raw = preserve_layout.get("durations")
|
||
if not isinstance(raw, list) or len(raw) != count:
|
||
return None
|
||
durations: list[int] = []
|
||
for value in raw:
|
||
try:
|
||
seconds = int(value)
|
||
except (TypeError, ValueError):
|
||
seconds = 0
|
||
durations.append(max(SEGMENT_DURATION_MIN, min(SEGMENT_DURATION_MAX, seconds or SEGMENT_DURATION_MAX)))
|
||
return durations
|
||
|
||
|
||
def _script_plaintext(draft: dict) -> str:
|
||
parts = [str(draft.get("hook") or "")]
|
||
for seg in draft.get("segments") or []:
|
||
if not isinstance(seg, dict):
|
||
continue
|
||
parts.append(str(seg.get("narration") or ""))
|
||
parts.append(str(seg.get("visual") or ""))
|
||
parts.append(str(seg.get("product_exposure") or ""))
|
||
for item in seg.get("dialogue") or []:
|
||
if isinstance(item, dict):
|
||
parts.append(str(item.get("line") or ""))
|
||
return "".join(parts)
|
||
|
||
|
||
def _fact_mentioned(text: str, fact: str) -> bool:
|
||
fact = (fact or "").strip()
|
||
if not fact:
|
||
return False
|
||
if fact in text:
|
||
return True
|
||
# 超长卖点/标题模型常念前半截,允许前 4 字命中
|
||
return len(fact) >= 6 and fact[:4] in text
|
||
|
||
|
||
def assert_product_facts_used(draft: dict, *, brand: str = "", selling_titles: list[str] | None = None) -> None:
|
||
"""整版生成时至少用上一个勾选卖点原词。单字卖点(如「茶」)也算。
|
||
|
||
品牌不强制:口播经常只喊卖点不念品牌,硬卡会把已经写对的稿整单作废。
|
||
"""
|
||
del brand # 保留调用方签名,避免改一串入口
|
||
text = _script_plaintext(draft)
|
||
titles = [t.strip() for t in (selling_titles or []) if (t or "").strip()]
|
||
if titles and not any(_fact_mentioned(text, title) for title in titles):
|
||
raise ValueError("脚本没有用上商品卖点原词,请按【必须原词用上】重写")
|
||
|
||
|
||
def assert_shot_density(draft: dict, presentation_format: str = DEFAULT_PRESENTATION_FORMAT) -> None:
|
||
"""口播/短剧每镜要说满、画面要写厚;Vlog 允许个别镜无声,但开口就不能偷懒。"""
|
||
fmt, _ = coerce_combo(_FORMAT_KEY_BY_LABEL.get(presentation_format, presentation_format), DEFAULT_VIDEO_STRUCTURE)
|
||
allow_silent = fmt == "vlog"
|
||
for index, seg in enumerate(draft.get("segments") or []):
|
||
if not isinstance(seg, dict):
|
||
continue
|
||
try:
|
||
duration = int(seg.get("duration") or SEGMENT_DURATION_MAX)
|
||
except (TypeError, ValueError):
|
||
duration = SEGMENT_DURATION_MAX
|
||
visual_n = _compact_len(str(seg.get("visual") or ""))
|
||
if visual_n < VISUAL_CHARS_MIN:
|
||
raise ValueError(f"第 {index + 1} 镜画面描写太短,撑不满 {duration} 秒")
|
||
assert_intra_shot_beats(seg, index, duration)
|
||
speech_n = _compact_len(_speech_text(seg))
|
||
if speech_n == 0 and allow_silent:
|
||
continue
|
||
floor = narration_floor(duration)
|
||
if speech_n < floor:
|
||
raise ValueError(f"第 {index + 1} 镜旁白太短,口播至少要说到 {floor} 字才能撑满 {duration} 秒")
|
||
|
||
|
||
def assert_script_has_a_hook(draft: dict) -> None:
|
||
"""拦住最常见的模板开场,逼模型从一个具体瞬间切入而不是先介绍商品。"""
|
||
hook = str(draft.get("hook") or "")
|
||
if _compact_len(hook) < 6:
|
||
raise ValueError("开场钩子太弱,请用一个具体场景、反差或问题重写前3秒")
|
||
opening = hook[:18]
|
||
if any(phrase in opening for phrase in _GENERIC_HOOK_PHRASES):
|
||
raise ValueError("开场像泛泛推荐,请直接从具体场景、反差或问题切入")
|
||
|
||
|
||
def assert_intra_shot_beats(seg: dict, index: int, duration: int) -> None:
|
||
"""15 秒场必须按秒拆出分镜,否则下游视频只能对着一句空描述乱编。"""
|
||
need = min_beats_for_duration(duration)
|
||
if need <= 0:
|
||
return
|
||
visual = str(seg.get("visual") or "")
|
||
beats = parse_visual_beats(visual)
|
||
if len(beats) < need:
|
||
raise ValueError(
|
||
f"第 {index + 1} 镜画面必须按秒拆分镜,至少 {need} 条「0-3s:景别,动作」,接到 {duration}s"
|
||
)
|
||
if beats[0][0] > 1:
|
||
raise ValueError(f"第 {index + 1} 镜画面必须按秒拆分镜,第一条要从 0 秒起")
|
||
if beats[-1][1] < duration - 1:
|
||
raise ValueError(f"第 {index + 1} 镜画面必须按秒拆分镜,最后一条要接到 {duration}s")
|
||
markers = [mark for mark in _SHOT_SIZE_MARKERS if mark in visual]
|
||
if len(set(markers)) < 2:
|
||
raise ValueError(f"第 {index + 1} 镜 15 秒内至少要切两次景别,并写进秒级分镜")
|
||
# 故事板下线后没有关键帧兜底,机位/运镜只能靠这段文字告诉出片模型
|
||
if not any(mark in visual for mark in _CAMERA_MOVE_MARKERS):
|
||
raise ValueError(
|
||
f"第 {index + 1} 镜必须写清机位与运镜(手持/跟拍/推近/拉远/环绕/固定机位…),否则出片只会拍成呆板静止画面"
|
||
)
|
||
|
||
|
||
# 模型每次生成都可能换字段名(scene/screenDescription/visual…、dialogue/lines/caption…),
|
||
# 与其逐一追变体,不如「优先键命中 → 否则按关键词模糊匹配」通用解析。SKIP 掉明显的非内容键,
|
||
# 避免误抓(shotNo/duration/bgMusic/note 等)。
|
||
_PICK_SKIP_KEYS = {
|
||
"shotno", "shotsize", "shotsizetype", "duration", "duration_seconds", "starttime", "endtime",
|
||
"timerange", "time_range", "bgmusic", "bg_music", "music", "sound", "sfx", "note", "notes",
|
||
"tips", "index", "role", "speaker", "entity_refs", "product_exposure", "id", "no", "transition",
|
||
"beats", "timeline",
|
||
# 标题/编号类:含 scene 字样会误命中画面 fuzzy,显式跳过(注意:不跳裸 scene,它常=画面)
|
||
"scene_id", "sceneid", "scene_no", "sceneno", "scene_number", "scenenumber",
|
||
"scene_title", "scenetitle", "title", "scene_title_type",
|
||
}
|
||
_VISUAL_EXACT = ("visual", "visual_prompt", "visual_description", "screen_description", "screendescription", "screen", "picture", "shot_description", "scene", "画面")
|
||
_VISUAL_FUZZY = ("visual", "screen", "picture", "scene", "画面", "镜头描述", "分镜画面")
|
||
_NARRATION_EXACT = ("narration", "voiceover", "voice_over", "vo", "line", "caption", "subtitle", "speech", "口播", "旁白")
|
||
_NARRATION_FUZZY = ("narrat", "voice", "旁白", "口播", "台词", "dialog", "caption", "subtitle", "字幕", "speech", "monolog", "audio")
|
||
|
||
|
||
def _flatten_text(val) -> str:
|
||
"""把 字符串/字典/列表 里的文本拍平成一句。模型常把 visual 写成 {setting,camera,key_shots}
|
||
对象、把口播写成数组,这里统一抽成纯文本,避免结构化值被当空丢弃(全空根因之一)。"""
|
||
if isinstance(val, str):
|
||
return val.strip()
|
||
if isinstance(val, dict):
|
||
return " · ".join(p for p in (_flatten_text(v) for v in val.values()) if p)
|
||
if isinstance(val, (list, tuple)):
|
||
return " ".join(p for p in (_flatten_text(v) for v in val) if p)
|
||
return ""
|
||
|
||
|
||
def _pick_field(seg: dict, exact: tuple[str, ...], fuzzy: tuple[str, ...]) -> str:
|
||
"""从一镜里取某类文本字段:先按优先键精确命中,再按关键词在剩余键里模糊匹配(跳过非内容键)。
|
||
值允许是 字符串/对象/数组(嵌套结构拍平成一句),不再只认裸字符串。"""
|
||
for key in exact:
|
||
if key in seg:
|
||
txt = _flatten_text(seg.get(key))
|
||
if txt:
|
||
return txt
|
||
for key, val in seg.items():
|
||
kl = str(key).lower()
|
||
if kl in _PICK_SKIP_KEYS:
|
||
continue
|
||
if any(f in kl for f in fuzzy):
|
||
txt = _flatten_text(val)
|
||
if txt:
|
||
return txt
|
||
return ""
|
||
|
||
|
||
def _opt_int(value) -> int | None:
|
||
try:
|
||
if value is None or value == "":
|
||
return None
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _beat_from_dict(item: dict) -> dict | None:
|
||
action = _flatten_text(
|
||
item.get("action")
|
||
or item.get("shot")
|
||
or item.get("visual")
|
||
or item.get("description")
|
||
or item.get("画面")
|
||
or ""
|
||
)
|
||
shot_size = str(item.get("shot_size") or item.get("camera") or item.get("景别") or "").strip()
|
||
start = _opt_int(item.get("start") if item.get("start") is not None else item.get("from"))
|
||
end = _opt_int(item.get("end") if item.get("end") is not None else item.get("to"))
|
||
time_s = str(item.get("time") or item.get("t") or "").strip()
|
||
if start is None and end is None and time_s:
|
||
parsed = parse_visual_beats(f"{time_s}:{action or '画面'}")
|
||
if parsed:
|
||
start, end, parsed_body = parsed[0]
|
||
action = action or parsed_body
|
||
if not action and not shot_size:
|
||
return None
|
||
piece = f"{shot_size},{action}" if shot_size and action else (action or shot_size)
|
||
return {"start": start, "end": end, "action": piece}
|
||
|
||
|
||
def coerce_beats(raw) -> list[dict]:
|
||
if raw is None or raw == "":
|
||
return []
|
||
if isinstance(raw, str):
|
||
return [{"start": start, "end": end, "action": body} for start, end, body in parse_visual_beats(raw)]
|
||
if isinstance(raw, dict):
|
||
nested = raw.get("beats") or raw.get("shots") or raw.get("timeline") or raw.get("key_shots") or raw.get("分镜")
|
||
if nested:
|
||
return coerce_beats(nested)
|
||
beat = _beat_from_dict(raw)
|
||
return [beat] if beat else []
|
||
if isinstance(raw, list):
|
||
out: list[dict] = []
|
||
for item in raw:
|
||
if isinstance(item, dict):
|
||
nested = item.get("beats") or item.get("shots")
|
||
if nested and not (item.get("action") or item.get("shot") or item.get("visual")):
|
||
out.extend(coerce_beats(nested))
|
||
continue
|
||
beat = _beat_from_dict(item)
|
||
if beat:
|
||
out.append(beat)
|
||
else:
|
||
out.extend(coerce_beats(item))
|
||
return out
|
||
return []
|
||
|
||
|
||
def _assign_beat_times(beats: list[dict], duration: int) -> list[tuple[int, int, str]]:
|
||
n = len(beats)
|
||
if n == 0:
|
||
return []
|
||
out: list[tuple[int, int, str]] = []
|
||
for index, beat in enumerate(beats):
|
||
start = beat.get("start")
|
||
end = beat.get("end")
|
||
if start is None:
|
||
start = int(round(index * duration / n))
|
||
if end is None:
|
||
end = duration if index == n - 1 else int(round((index + 1) * duration / n))
|
||
if end <= start:
|
||
end = start + 1
|
||
out.append((int(start), int(end), str(beat.get("action") or "").strip()))
|
||
return [item for item in out if item[2]]
|
||
|
||
|
||
def compose_segment_visual(seg: dict, duration: int = SEGMENT_DURATION_MAX) -> str:
|
||
"""把 beats 数组或已写好的秒级 visual 收成多行「0-3s:…」。"""
|
||
visual_raw = seg.get("visual")
|
||
if isinstance(visual_raw, str) and visual_raw.strip():
|
||
parsed = parse_visual_beats(visual_raw)
|
||
if len(parsed) >= 2:
|
||
return format_visual_beats(parsed)
|
||
candidates = [
|
||
seg.get("beats"),
|
||
seg.get("timeline"),
|
||
seg.get("分镜"),
|
||
visual_raw if isinstance(visual_raw, (list, dict)) else None,
|
||
]
|
||
shots_raw = seg.get("shots")
|
||
if isinstance(shots_raw, list) and shots_raw:
|
||
sample = shots_raw[0]
|
||
looks_like_nested_segments = isinstance(sample, dict) and (
|
||
sample.get("narration") or sample.get("role") in VALID_ROLES
|
||
)
|
||
if not looks_like_nested_segments:
|
||
candidates.append(shots_raw)
|
||
for raw in candidates:
|
||
assigned = _assign_beat_times(coerce_beats(raw), duration)
|
||
if len(assigned) >= 2:
|
||
return format_visual_beats(assigned)
|
||
return _pick_field(seg, _VISUAL_EXACT, _VISUAL_FUZZY)
|
||
|
||
|
||
# 模型给分镜数组的键名五花八门(segments/scenes/script/shots…),且常同时给一个**空的**
|
||
# segments 骨架 + 真内容放在 scenes 里。所以不能「见 segments 是 list 就用」,要在所有候选里
|
||
# 挑「能解析出最多非空旁白/画面」的那个。segments(契约本名)排第一,同分时优先。
|
||
_SEGMENT_ARRAY_KEYS = (
|
||
"segments", "shots", "scenes", "script", "shot_list", "shotlist", "shotList",
|
||
"scene_list", "scenes_list", "storyboard", "分镜", "镜头",
|
||
)
|
||
|
||
|
||
def _resolve_segments(draft: dict) -> list:
|
||
"""在所有候选数组键里挑内容最丰富的分镜数组(按非空旁白/画面条数打分)。"""
|
||
best: list = []
|
||
best_score = -1
|
||
for key in _SEGMENT_ARRAY_KEYS:
|
||
arr = draft.get(key)
|
||
if not isinstance(arr, list) or not arr:
|
||
continue
|
||
dict_items = [s for s in arr if isinstance(s, dict)]
|
||
if not dict_items:
|
||
continue
|
||
score = sum(
|
||
1
|
||
for s in dict_items
|
||
if _pick_field(s, _NARRATION_EXACT, _NARRATION_FUZZY)
|
||
or _pick_field(s, _VISUAL_EXACT, _VISUAL_FUZZY)
|
||
)
|
||
if score > best_score: # 严格大于 → 同分保留更靠前的键(segments 优先)
|
||
best, best_score = arr, score
|
||
return best
|
||
|
||
|
||
def normalize_draft(
|
||
raw_text: str,
|
||
*,
|
||
aspect_ratio: str,
|
||
total_duration: int,
|
||
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
|
||
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
|
||
preserve_layout: dict | None = None,
|
||
) -> dict:
|
||
"""把模型输出抽成 JSON 并按铁律1契约规范化。宽容:小问题就地修,不轻易抛错。"""
|
||
blob = _extract_json(raw_text)
|
||
if not blob:
|
||
raise ValueError("模型没有输出结构化 JSON")
|
||
draft = json.loads(blob)
|
||
if not isinstance(draft, dict):
|
||
raise ValueError("脚本 JSON 顶层不是对象")
|
||
|
||
# 兼容模型不按契约的常见变体:① 把内容裹进 {"ScriptDraft": {...}} 外壳;
|
||
# ② 用 basicInfo(camelCase)放时长/比例;③ 用 shots 代替 segments。
|
||
# 解开外壳并把别名拍平到契约字段,避免「找不到 segments → 全填空占位镜」(旁白/画面全空)。
|
||
for wrapper in ("ScriptDraft", "script_draft", "scriptDraft", "draft"):
|
||
inner = draft.get(wrapper)
|
||
if isinstance(inner, dict):
|
||
draft = {**inner, **{k: v for k, v in draft.items() if k != wrapper}}
|
||
break
|
||
basic = draft.get("basicInfo") if isinstance(draft.get("basicInfo"), dict) else {}
|
||
if basic:
|
||
draft.setdefault("total_duration", basic.get("totalDuration") or basic.get("total_duration"))
|
||
draft.setdefault("aspect_ratio", basic.get("aspectRatio") or basic.get("aspect_ratio"))
|
||
draft.setdefault("theme", basic.get("theme"))
|
||
# 在所有候选键里挑内容最丰富的分镜数组(空 segments 骨架会被更丰富的 scenes/shots 顶替)
|
||
draft["segments"] = _resolve_segments(draft)
|
||
|
||
draft["aspect_ratio"] = (draft.get("aspect_ratio") or aspect_ratio or "9:16").strip()
|
||
# 总时长以「请求参数」为准:模型经常把它算错,而下游出片/计价都按这个数走。
|
||
dur = coerce_total_duration(total_duration)
|
||
draft["total_duration"] = dur
|
||
fmt, structure = coerce_combo(presentation_format, video_structure)
|
||
draft["presentation_format"] = PRESENTATION_FORMATS[fmt]
|
||
draft["video_structure"] = VIDEO_STRUCTURES[structure]
|
||
tone = (draft.get("tone") or "").strip()
|
||
draft["tone"] = tone if tone in VALID_TONES else "种草"
|
||
draft["hook"] = (draft.get("hook") or "").strip()
|
||
|
||
# entities 规范化:补 id / ref_index,过滤非法 type。未成年人不能作为角色基础资产生成,
|
||
# 否则会触发真人生图审核,也不应把儿童肖像交给后续分镜/视频链路。
|
||
entities = draft.get("entities") if isinstance(draft.get("entities"), list) else []
|
||
norm_entities: list[dict] = []
|
||
seen_ids: set[str] = set()
|
||
minor_character_ids: set[str] = set()
|
||
for i, ent in enumerate(entities):
|
||
if not isinstance(ent, dict):
|
||
continue
|
||
eid = str(ent.get("id") or f"e{i + 1}").strip() or f"e{i + 1}"
|
||
while eid in seen_ids:
|
||
eid = f"{eid}_{i}"
|
||
seen_ids.add(eid)
|
||
etype = (ent.get("type") or "").strip()
|
||
if etype not in VALID_ENTITY_TYPES:
|
||
etype = "character"
|
||
name = (ent.get("name") or eid).strip()
|
||
visual_prompt = (ent.get("visual_prompt") or "").strip()
|
||
if etype == "character" and _is_minor_character(name, visual_prompt):
|
||
minor_character_ids.add(eid)
|
||
continue
|
||
norm_entities.append(
|
||
{
|
||
"id": eid,
|
||
"type": etype,
|
||
"name": name,
|
||
"visual_prompt": visual_prompt,
|
||
"ref_index": ent.get("ref_index") if isinstance(ent.get("ref_index"), int) else i + 1,
|
||
"voice_ref": ent.get("voice_ref") or None,
|
||
}
|
||
)
|
||
draft["entities"] = norm_entities
|
||
valid_ids = {e["id"] for e in norm_entities}
|
||
|
||
# segments 规范化:主流程镜数 = 总时长/15;精准改一镜则保住原稿镜数。
|
||
segments = draft.get("segments") if isinstance(draft.get("segments"), list) else []
|
||
layout_count = 0
|
||
if preserve_layout:
|
||
try:
|
||
layout_count = int(preserve_layout.get("count") or 0)
|
||
except (TypeError, ValueError):
|
||
layout_count = 0
|
||
expected = layout_count or max(1, dur // SEGMENT_DURATION_MAX)
|
||
segments = segments[:expected]
|
||
seg_count = expected
|
||
role_plan = plan_roles(seg_count)
|
||
norm_segments: list[dict] = []
|
||
segments_with_minor_reference: set[int] = set()
|
||
for i, seg in enumerate(segments):
|
||
if not isinstance(seg, dict):
|
||
seg = {}
|
||
role = (seg.get("role") or "").strip()
|
||
if role not in VALID_ROLES:
|
||
role = role_plan[i]
|
||
raw_refs = seg.get("entity_refs") or []
|
||
if not isinstance(raw_refs, list):
|
||
raw_refs = []
|
||
speaker = seg.get("speaker")
|
||
if speaker in minor_character_ids or any(ref in minor_character_ids for ref in raw_refs):
|
||
segments_with_minor_reference.add(i)
|
||
speaker = speaker if (speaker in valid_ids) else None
|
||
refs = [r for r in raw_refs if r in valid_ids]
|
||
# 对白(剧情向):[{speaker(合法 entity id 或 null=旁白), line}];默认空 = 纯口播。
|
||
# 模型变体的口播字段五花八门:dialogue(字符串/数组)/ lines(数组)/ 每项 line|text|content。
|
||
# 这里把任意数组形态归一成结构化对白,字符串形态留给下面当整句旁白。
|
||
raw_dialogue = seg.get("dialogue")
|
||
dialogue = []
|
||
dialogue_array = raw_dialogue if isinstance(raw_dialogue, list) else (seg.get("lines") if isinstance(seg.get("lines"), list) else [])
|
||
for d in dialogue_array:
|
||
if not isinstance(d, dict):
|
||
continue
|
||
line = (d.get("line") or d.get("text") or d.get("content") or "").strip()
|
||
if not line:
|
||
continue
|
||
sp = d.get("speaker")
|
||
dialogue.append({"speaker": sp if sp in valid_ids else None, "line": _replace_canned_defect_phrase(line)})
|
||
# 旁白:结构化对白/lines 优先,其次整句字符串 dialogue,再退到通用字段解析(narration/voiceover/caption/字幕…)
|
||
narration = ""
|
||
if isinstance(raw_dialogue, str) and raw_dialogue.strip():
|
||
narration = raw_dialogue.strip()
|
||
elif dialogue:
|
||
narration = " ".join(d["line"] for d in dialogue) # 扁平拼接,兼容下游字幕/配音
|
||
if not narration:
|
||
narration = _pick_field(seg, _NARRATION_EXACT, _NARRATION_FUZZY)
|
||
narration = _replace_canned_defect_phrase(narration)
|
||
# 画面:优先收成秒级分镜;beats 数组会折进 visual,下游故事板/视频直接读这一段。
|
||
visual = _replace_canned_defect_phrase(compose_segment_visual(seg))
|
||
norm_segments.append(
|
||
{
|
||
"index": i,
|
||
"duration": seg.get("duration"), # 先原样收着,后面统一写成 15 或保住原稿秒数
|
||
"role": role,
|
||
"narration": narration,
|
||
"speaker": speaker,
|
||
"visual": visual,
|
||
"product_exposure": (seg.get("product_exposure") or "").strip(),
|
||
"entity_refs": refs,
|
||
"dialogue": dialogue,
|
||
}
|
||
)
|
||
# 不足下限则补占位镜(极少发生,避免出现超过 15 秒的镜导致下游拒片)
|
||
while len(norm_segments) < seg_count:
|
||
i = len(norm_segments)
|
||
norm_segments.append(
|
||
{
|
||
"index": i,
|
||
"duration": None,
|
||
"role": role_plan[i],
|
||
"narration": "",
|
||
"speaker": None,
|
||
"visual": "",
|
||
"product_exposure": "",
|
||
"entity_refs": [],
|
||
"dialogue": [],
|
||
}
|
||
)
|
||
if not norm_segments:
|
||
raise ValueError("脚本没有任何分镜")
|
||
|
||
kept = _layout_durations(preserve_layout, len(norm_segments))
|
||
if kept:
|
||
fitted = kept
|
||
draft["total_duration"] = sum(fitted)
|
||
else:
|
||
fitted = plan_segment_durations(dur, fmt)
|
||
if len(fitted) != len(norm_segments):
|
||
fitted = [SEGMENT_DURATION_MAX] * len(norm_segments)
|
||
draft["total_duration"] = sum(fitted)
|
||
for index, (norm, seconds) in enumerate(zip(norm_segments, fitted)):
|
||
norm["duration"] = seconds
|
||
if index in segments_with_minor_reference:
|
||
norm["visual"] = _product_only_visual(seconds)
|
||
elif index < len(segments) and isinstance(segments[index], dict):
|
||
composed = compose_segment_visual(segments[index], seconds)
|
||
if composed:
|
||
norm["visual"] = _replace_canned_defect_phrase(composed)
|
||
|
||
draft["segments"] = norm_segments
|
||
draft["segment_count"] = len(norm_segments)
|
||
return draft
|
||
|
||
|
||
def _preserve_layout_from(draft: dict | None) -> dict | None:
|
||
if not draft:
|
||
return None
|
||
segs = [s for s in (draft.get("segments") or []) if isinstance(s, dict)]
|
||
if not segs:
|
||
return None
|
||
return {"count": len(segs), "durations": [s.get("duration") for s in segs]}
|
||
|
||
|
||
def _merge_single_segment(
|
||
base: dict,
|
||
new: dict,
|
||
idx: int,
|
||
aspect_ratio: str,
|
||
total_duration: int,
|
||
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
|
||
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
|
||
) -> dict:
|
||
"""精准改一镜:以基准稿为底,只用新稿的第 idx 镜替换,其余镜逐字保持;合并新稿引入的新 entity(对白可能加角色)。再整体规范化。"""
|
||
merged = json.loads(json.dumps(base)) # 深拷贝
|
||
base_ids = {e.get("id") for e in merged.get("entities", []) if isinstance(e, dict)}
|
||
for e in new.get("entities", []):
|
||
if isinstance(e, dict) and e.get("id") and e["id"] not in base_ids:
|
||
merged.setdefault("entities", []).append(e)
|
||
base_ids.add(e["id"])
|
||
new_segs = new.get("segments", [])
|
||
target = next((s for s in new_segs if isinstance(s, dict) and s.get("index") == idx), None)
|
||
if target is None and 0 <= idx < len(new_segs):
|
||
target = new_segs[idx]
|
||
if not isinstance(target, dict):
|
||
# 模型没产出目标镜(没按 N 镜输出)→ 抛错让上层释放额度+报错,而不是静默返回 base 空转计费
|
||
raise ValueError(f"模型未产出第 {idx + 1} 镜的改动,请重试")
|
||
segs = merged.get("segments", [])
|
||
if isinstance(target, dict) and 0 <= idx < len(segs):
|
||
target = dict(target)
|
||
target["index"] = idx
|
||
segs[idx] = target
|
||
merged["segments"] = segs
|
||
layout = _preserve_layout_from(base)
|
||
return normalize_draft(
|
||
json.dumps(merged, ensure_ascii=False),
|
||
aspect_ratio=aspect_ratio,
|
||
total_duration=total_duration,
|
||
presentation_format=presentation_format,
|
||
video_structure=video_structure,
|
||
preserve_layout=layout,
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 落库
|
||
# --------------------------------------------------------------------------- #
|
||
def _map_entities_to_project_metadata(project, entities: list[dict]) -> None:
|
||
"""把结构化 entities 回填到 project.metadata,复用下游已有的 cast/scenes/*_prompts 接线
|
||
(脚本页标签 + 基础资产 seed + 故事板 @图N)。只在有内容时覆盖,空结果不清旧标签。"""
|
||
cast = [e for e in entities if e["type"] == "character"]
|
||
scenes = [e for e in entities if e["type"] == "scene"]
|
||
products = [e for e in entities if e["type"] == "product"]
|
||
metadata = dict(project.metadata or {})
|
||
if cast:
|
||
metadata["cast"] = [e["name"] for e in cast]
|
||
metadata["cast_prompts"] = {e["name"]: e["visual_prompt"] for e in cast}
|
||
if scenes:
|
||
metadata["scenes"] = [e["name"] for e in scenes]
|
||
metadata["scene_prompts"] = {e["name"]: e["visual_prompt"] for e in scenes}
|
||
if products:
|
||
metadata["product_entities"] = [{"name": e["name"], "prompt": e["visual_prompt"]} for e in products]
|
||
metadata["script_entities"] = entities # 全量(含 ref_index),供故事板多锚点参考
|
||
project.metadata = metadata
|
||
project.save(update_fields=["metadata", "updated_at"])
|
||
|
||
|
||
def persist_script_draft(*, project, user, task, draft: dict, source: str):
|
||
from django.db import transaction
|
||
|
||
from apps.projects.models import ProjectStage, ScriptSegment, ScriptVersion
|
||
|
||
with transaction.atomic():
|
||
script = ScriptVersion.objects.create(
|
||
project=project,
|
||
task=task,
|
||
title=(draft.get("hook") or "AI 脚本")[:128],
|
||
content=json.dumps(draft, ensure_ascii=False, indent=2),
|
||
source=source if source in ("ai", "theme", "manual", "video", "revise") else "ai",
|
||
is_adopted=False,
|
||
metadata={
|
||
"hook": draft.get("hook", ""),
|
||
"tone": draft.get("tone", ""),
|
||
"aspect_ratio": draft.get("aspect_ratio", "9:16"),
|
||
"total_duration": draft.get("total_duration", DEFAULT_TOTAL_DURATION),
|
||
"segment_count": draft.get("segment_count", len(draft.get("segments") or [])),
|
||
# 二期:表现形式 × 视频结构 跟着稿子走,改稿和「保存模板」都要读它
|
||
"presentation_format": draft.get("presentation_format", ""),
|
||
"video_structure": draft.get("video_structure", ""),
|
||
"entities": draft.get("entities", []),
|
||
},
|
||
)
|
||
for seg in draft["segments"]:
|
||
ScriptSegment.objects.create(
|
||
script_version=script,
|
||
sort_order=seg["index"],
|
||
duration_seconds=seg.get("duration") or SEGMENT_DURATION_MAX,
|
||
narration=seg.get("narration", ""),
|
||
visual_prompt=seg.get("visual", ""),
|
||
role=seg.get("role", ""),
|
||
speaker=seg.get("speaker") or "",
|
||
product_exposure=seg.get("product_exposure", ""),
|
||
entity_refs=seg.get("entity_refs") or [],
|
||
dialogue=seg.get("dialogue") or [],
|
||
product_points=[],
|
||
)
|
||
_map_entities_to_project_metadata(project, draft.get("entities", []))
|
||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
|
||
stage.status = ProjectStage.Status.NEEDS_REVIEW
|
||
stage.save(update_fields=["status", "updated_at"])
|
||
return script
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 流式编排
|
||
# --------------------------------------------------------------------------- #
|
||
def _sse(obj: dict) -> str:
|
||
return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"
|
||
|
||
|
||
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)
|
||
|
||
|
||
def _closing_summary(raw: str) -> str:
|
||
"""模型在 json 之后写的收尾交付语 = 给用户的回复气泡(像同事汇报「这版做了啥/怎么接着调」)。
|
||
取最后一个 json 对象之后的文字,去掉收尾围栏与空白;没写或残留花括号就返回空(前端兜底)。"""
|
||
js = _extract_json(raw)
|
||
tail = ""
|
||
if js:
|
||
idx = raw.rfind(js)
|
||
if idx != -1:
|
||
tail = raw[idx + len(js):]
|
||
tail = re.sub(r"`+", " ", tail).strip() # 去掉 json 收尾的 ``` 围栏
|
||
if "{" in tail or len(tail) < 4:
|
||
return ""
|
||
return tail[:160]
|
||
|
||
|
||
def stream_script_agent(
|
||
*,
|
||
project,
|
||
user,
|
||
model_config: ModelConfig,
|
||
mode: str = "auto",
|
||
user_prompt: str = "",
|
||
selling_point_ids: list[str] | None = None,
|
||
base_version_id: str | None = None,
|
||
aspect_ratio: str = "9:16",
|
||
total_duration: int = DEFAULT_TOTAL_DURATION,
|
||
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
|
||
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
|
||
target_index: int | None = None,
|
||
entry_source: str = "",
|
||
persona: str | None = None,
|
||
):
|
||
"""生成 SSE 帧字符串的同步生成器,供 StreamingHttpResponse 包裹。
|
||
target_index 非空 = 精准只改第 N 镜(读全脚本上下文,后端强制保留其余镜原样)。"""
|
||
from apps.ai.services import create_ai_task, stream_routed_text_request
|
||
|
||
# 极速成片与专业创作都只产出口播。历史项目、模板或请求里的短剧/Vlog
|
||
# 仅作兼容读取,绝不能重新进入实际生成链路。
|
||
del presentation_format
|
||
fmt, structure = coerce_combo(DEFAULT_PRESENTATION_FORMAT, video_structure)
|
||
|
||
yield _sse({"type": "tool", "id": "skill", "label": f"加载套路:{PRESENTATION_FORMATS[fmt]} · {VIDEO_STRUCTURES[structure]}", "status": "running"})
|
||
skill_loaded = bool(load_ecommerce_skill(fmt, structure))
|
||
yield _sse({"type": "tool", "id": "skill", "status": "done" if skill_loaded else "error"})
|
||
|
||
yield _sse({"type": "tool", "id": "analyze", "label": f"分析商品:{project.product.title}", "status": "running"})
|
||
base_draft = None
|
||
if mode == "revise" and base_version_id:
|
||
base_draft = _load_base_draft(project, base_version_id)
|
||
if base_draft is None:
|
||
target_index = None # 没有基准稿就退回整版生成,单镜改无从谈起
|
||
# 改稿以基准稿的时长/镜数为准,避免请求侧默认值把长稿的尾镜挤掉
|
||
# 精准改一镜不把总时长夹成 15 步进,否则旧的不等长稿会被提示词误导切镜。
|
||
if target_index is not None and base_draft is not None:
|
||
effective_duration = int(base_draft.get("total_duration") or total_duration or DEFAULT_TOTAL_DURATION)
|
||
else:
|
||
effective_duration = coerce_total_duration(
|
||
(base_draft.get("total_duration") if base_draft else None) or total_duration
|
||
)
|
||
# 精准改一镜:镜号越界直接报错返回,绝不建任务/扣费(避免计费空转的静默 no-op)
|
||
if target_index is not None and base_draft is not None:
|
||
seg_n = len(base_draft.get("segments", []))
|
||
if not (0 <= target_index < seg_n):
|
||
yield _sse({"type": "error", "detail": f"镜号越界:第 {target_index + 1} 镜(共 {seg_n} 镜)"})
|
||
return
|
||
selling_point_ids = _resolve_selling_point_ids(project, selling_point_ids)
|
||
persona = _resolve_persona(project, persona)
|
||
product, selling_points = _product_facts(project, selling_point_ids)
|
||
selling_titles = [sp.title for sp in selling_points]
|
||
layout = _preserve_layout_from(base_draft) if target_index is not None else None
|
||
product_image_urls = _script_product_reference_urls(project, model_config)
|
||
messages = build_agent_messages(
|
||
project=project,
|
||
mode=mode,
|
||
user_prompt=user_prompt,
|
||
selling_point_ids=selling_point_ids,
|
||
base_draft=base_draft,
|
||
aspect_ratio=aspect_ratio,
|
||
total_duration=effective_duration, # 改稿用基准稿时长,prompt head 才不会误导模型镜数
|
||
presentation_format=fmt,
|
||
video_structure=structure,
|
||
target_index=target_index,
|
||
persona=persona,
|
||
product_image_urls=product_image_urls,
|
||
)
|
||
yield _sse({"type": "tool", "id": "analyze", "status": "done"})
|
||
|
||
task_type = AITask.Type.SCRIPT_OPTIMIZATION if mode == "revise" else AITask.Type.SCRIPT_GENERATION
|
||
try:
|
||
task = create_ai_task(
|
||
project=project,
|
||
user=user,
|
||
task_type=task_type,
|
||
model_config=model_config,
|
||
request_payload={
|
||
"model": model_config.name,
|
||
"endpoint": model_config.endpoint,
|
||
"mode": mode,
|
||
"aspect_ratio": aspect_ratio,
|
||
"total_duration": total_duration,
|
||
"base_version_id": str(base_version_id or ""),
|
||
"target_index": target_index,
|
||
"product_image_references": len(product_image_urls),
|
||
"model_routing_v1": True,
|
||
},
|
||
)
|
||
except Exception as exc: # noqa: BLE001 — 多为额度不足
|
||
internal_kind = "user_credit_insufficient" if str(exc).strip().lower() == "insufficient credit" else ""
|
||
yield _sse(_script_error_event(exc, internal_kind=internal_kind))
|
||
return
|
||
reservation = task.credit_reservation
|
||
# 额度是否已结算(charge 成功 / release 失败)。客户端中途断连时,生成器被 .close() 抛
|
||
# GeneratorExit —— 它是 BaseException 不是 Exception,普通 except 抓不到,会让预扣额度冻结。
|
||
# 故用 try/finally 兜底:任何未结算路径(含断连)都释放预扣。
|
||
settled = False
|
||
try:
|
||
yield _sse({"type": "tool", "id": "generate", "label": "按黄金结构生成分镜", "status": "running"})
|
||
full: list[str] = []
|
||
shown = 0
|
||
forwarding = True
|
||
try:
|
||
task.status = AITask.Status.SUBMITTED
|
||
task.submitted_at = timezone.now()
|
||
task.save(update_fields=["status", "submitted_at", "updated_at"])
|
||
def validate_script_text(raw_text: str) -> dict:
|
||
candidate = normalize_draft(
|
||
raw_text,
|
||
aspect_ratio=aspect_ratio,
|
||
total_duration=effective_duration,
|
||
presentation_format=fmt,
|
||
video_structure=structure,
|
||
preserve_layout=layout,
|
||
)
|
||
if target_index is not None and base_draft:
|
||
return _merge_single_segment(
|
||
base_draft,
|
||
candidate,
|
||
target_index,
|
||
aspect_ratio,
|
||
effective_duration,
|
||
fmt,
|
||
structure,
|
||
)
|
||
if mode != "revise":
|
||
assert_product_facts_used(
|
||
candidate,
|
||
brand=getattr(product, "brand", "") or "",
|
||
selling_titles=selling_titles,
|
||
)
|
||
assert_shot_density(candidate, fmt)
|
||
return candidate
|
||
|
||
routed_stream = stream_routed_text_request(
|
||
task=task,
|
||
primary_model=model_config,
|
||
messages=messages,
|
||
streaming=True,
|
||
structured_output=True,
|
||
business_operation="script_generate",
|
||
temperature=0.85,
|
||
validate_text=validate_script_text,
|
||
request_summary={
|
||
"mode": mode,
|
||
"target_index": target_index,
|
||
"base_version_id": str(base_version_id or ""),
|
||
"aspect_ratio": aspect_ratio,
|
||
"total_duration": effective_duration,
|
||
},
|
||
)
|
||
while True:
|
||
try:
|
||
ev = next(routed_stream)
|
||
except StopIteration as completed:
|
||
routed = completed.value
|
||
break
|
||
et = ev.get("type")
|
||
if et == "reasoning":
|
||
# 思考流:推理模型在出 JSON 前会先想很久,把思考逐字下发给前端(像对话一样可见),
|
||
# 不进 full(不是答案正文,纯展示)。这是「卡在生成分镜」假死的根因修复。
|
||
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":
|
||
continue
|
||
raw, _provider_response, draft = routed.value
|
||
except Exception as exc: # noqa: BLE001
|
||
_fail_task(task, reservation, str(exc))
|
||
settled = True
|
||
yield _sse({"type": "tool", "id": "generate", "status": "error"})
|
||
yield _sse(_script_error_event(exc, reference_id=str(task.id)))
|
||
return
|
||
|
||
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})
|
||
|
||
from django.db import transaction
|
||
|
||
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(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
|
||
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
|
||
# 三个入口(辅助生成 / 上传脚本 / 上传视频提炼)都走 mode=auto,只有 entry_source
|
||
# 分得清是哪个来的 —— 脚本卡的「来源」徽标靠它,别一律记成 ai。
|
||
if mode == "revise":
|
||
source = "revise"
|
||
elif mode == "theme":
|
||
source = "theme"
|
||
else:
|
||
source = entry_source if entry_source in {"manual", "video"} else "ai"
|
||
script = persist_script_draft(project=project, user=user, task=task, draft=draft, source=source)
|
||
settled = True # charge 已提交
|
||
except Exception as exc: # noqa: BLE001 — 落库失败:atomic 已回滚 charge,补释放预留
|
||
_fail_task(task, reservation, f"保存脚本失败:{exc}")
|
||
settled = True
|
||
yield _sse(_script_error_event(exc, reference_id=str(task.id), internal_kind="processing_failed"))
|
||
return
|
||
|
||
from apps.projects.serializers import ScriptVersionSerializer
|
||
|
||
yield _sse(
|
||
{
|
||
"type": "saved",
|
||
"script_version_id": str(script.id),
|
||
"version": ScriptVersionSerializer(script).data,
|
||
}
|
||
)
|
||
# 模型自己写的收尾交付语 → AI 回复气泡(没写则前端兜底默认句)
|
||
summary = _closing_summary(raw)
|
||
if summary:
|
||
yield _sse({"type": "summary", "text": summary})
|
||
yield _sse({"type": "done"})
|
||
finally:
|
||
# 断连(GeneratorExit)或任何 settled=False 的退出路径:释放预扣,避免额度冻结
|
||
if not settled:
|
||
_fail_task(task, reservation, "stream aborted (client disconnected)")
|
||
|
||
|
||
def _fail_task(task, reservation, message: str) -> None:
|
||
try:
|
||
task.status = AITask.Status.FAILED
|
||
task.error_message = message[:2000]
|
||
task.completed_at = timezone.now()
|
||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||
finally:
|
||
try:
|
||
release_credit(reservation=reservation, reason=message[:200])
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
def _script_error_event(
|
||
exc: Exception,
|
||
*,
|
||
reference_id: str | None = None,
|
||
internal_kind: str = "",
|
||
) -> dict:
|
||
"""脚本 SSE 的安全失败帧;原始异常只落任务记录/日志,不回传普通用户。"""
|
||
public_error = classify_generation_error(
|
||
exc,
|
||
operation="script_generate",
|
||
internal_kind=internal_kind,
|
||
reference_id=reference_id,
|
||
)
|
||
return {
|
||
"type": "error",
|
||
"detail": public_error.fallback_message,
|
||
"error": public_error.as_dict(),
|
||
}
|
||
|
||
|
||
def _load_base_draft(project, base_version_id: str) -> dict | None:
|
||
from apps.projects.models import ScriptVersion
|
||
|
||
try:
|
||
version = ScriptVersion.objects.get(project=project, id=base_version_id)
|
||
except (ScriptVersion.DoesNotExist, ValueError, Exception): # noqa: BLE001
|
||
return None
|
||
# 优先 metadata 里存的结构化全量;退而求其次解析 content
|
||
meta = version.metadata or {}
|
||
if meta.get("entities") is not None or meta.get("hook"):
|
||
try:
|
||
return json.loads(version.content)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
try:
|
||
return json.loads(version.content)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
|
||
def _draft_from_version(version) -> dict:
|
||
"""从 ScriptVersion 的 DB 行(segments + metadata)重建 ScriptDraft —— 比解析可能已 stale 的 content 可靠
|
||
(用户增删/改镜后 content 不一定同步)。total_duration 按各镜真实秒数加总,避免 normalize 按 stale 值截/补镜。"""
|
||
meta = version.metadata or {}
|
||
segs = list(version.segments.order_by("sort_order"))
|
||
# 总时长按各镜真实秒数加总(旧稿可能不等长;新稿每镜 15 秒)。
|
||
actual_total = sum(s.duration_seconds or SEGMENT_DURATION_MAX for s in segs)
|
||
return {
|
||
"hook": meta.get("hook", ""),
|
||
"tone": meta.get("tone", ""),
|
||
"presentation_format": meta.get("presentation_format", ""),
|
||
"video_structure": meta.get("video_structure", ""),
|
||
"aspect_ratio": meta.get("aspect_ratio", "9:16"),
|
||
"total_duration": actual_total or coerce_total_duration(meta.get("total_duration")),
|
||
"segment_count": len(segs),
|
||
"entities": meta.get("entities", []),
|
||
"segments": [
|
||
{
|
||
"index": s.sort_order,
|
||
"duration": s.duration_seconds or SEGMENT_DURATION_MAX,
|
||
"role": s.role or "",
|
||
"narration": s.narration or "",
|
||
"speaker": s.speaker or None,
|
||
"visual": s.visual_prompt or "",
|
||
"product_exposure": s.product_exposure or "",
|
||
"entity_refs": s.entity_refs or [],
|
||
"dialogue": s.dialogue or [],
|
||
}
|
||
for s in segs
|
||
],
|
||
}
|
||
|
||
|
||
def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, segment, instruction: str = ""):
|
||
"""非流式·精准改一镜(「场次刷新」按钮复用 agent 单镜逻辑):读全脚本上下文,只重写该镜,落新 ScriptVersion。
|
||
与 stream_script_agent 的 target_index 分支同源,但同步返回(不走 SSE)。计费 reserve→charge/release 闭环。"""
|
||
from django.db import transaction
|
||
|
||
from apps.ai.services import create_ai_task, execute_routed_text_request
|
||
from apps.billing.services.ledger import charge_reserved_credit
|
||
|
||
base_draft = _draft_from_version(segment.script_version) # 用 DB 行重建基准,别用可能 stale 的 content
|
||
target_index = segment.sort_order
|
||
aspect_ratio = (base_draft.get("aspect_ratio") or "9:16").strip()
|
||
seg_n = len(base_draft.get("segments") or [])
|
||
if not (0 <= target_index < seg_n):
|
||
raise ValueError(f"镜号越界:第 {target_index + 1} 镜(共 {seg_n} 镜)")
|
||
total_duration = base_draft["total_duration"] # 已按各镜真实秒数加总,normalize 不会截掉用户增删后的镜
|
||
# 改一镜要沿用原稿的套路,否则重写出来的那一镜镜头语言会跟其余镜打架
|
||
# 单镜重写也必须遵守当前统一的口播规则,不能被旧稿的表现形式带回短剧/Vlog。
|
||
fmt, structure = combo_keys(DEFAULT_PRESENTATION_FORMAT, base_draft.get("video_structure"))
|
||
|
||
product_image_urls = _script_product_reference_urls(project, model_config)
|
||
messages = build_agent_messages(
|
||
project=project,
|
||
mode="revise",
|
||
user_prompt=instruction,
|
||
selling_point_ids=_resolve_selling_point_ids(project, None),
|
||
base_draft=base_draft,
|
||
aspect_ratio=aspect_ratio,
|
||
total_duration=total_duration,
|
||
presentation_format=fmt,
|
||
video_structure=structure,
|
||
target_index=target_index,
|
||
persona=_resolve_persona(project, None),
|
||
product_image_urls=product_image_urls,
|
||
)
|
||
task = create_ai_task(
|
||
project=project,
|
||
user=user,
|
||
task_type=AITask.Type.SCRIPT_OPTIMIZATION,
|
||
model_config=model_config,
|
||
request_payload={
|
||
"model": model_config.name,
|
||
"endpoint": model_config.endpoint,
|
||
"mode": "revise",
|
||
"target_index": target_index,
|
||
"product_image_references": len(product_image_urls),
|
||
"model_routing_v1": True,
|
||
},
|
||
)
|
||
reservation = task.credit_reservation
|
||
# 每条真实尝试的平台成本由统一执行器累计;用户积分仍只结算这一条脚本任务。
|
||
task.base_cost = Decimal("0")
|
||
task.save(update_fields=["base_cost", "updated_at"])
|
||
# 实际平台成本由每条 AIModelAttempt 累加;用户积分仍只结算这一条逻辑任务。
|
||
task.base_cost = Decimal("0")
|
||
task.save(update_fields=["base_cost", "updated_at"])
|
||
try:
|
||
task.status = AITask.Status.SUBMITTED
|
||
task.submitted_at = timezone.now()
|
||
task.save(update_fields=["status", "submitted_at", "updated_at"])
|
||
def validate_segment_text(raw_text: str) -> dict:
|
||
layout = _preserve_layout_from(base_draft)
|
||
candidate = normalize_draft(
|
||
raw_text,
|
||
aspect_ratio=aspect_ratio,
|
||
total_duration=total_duration,
|
||
presentation_format=fmt,
|
||
video_structure=structure,
|
||
preserve_layout=layout,
|
||
)
|
||
return _merge_single_segment(
|
||
base_draft, candidate, target_index, aspect_ratio, total_duration, fmt, structure
|
||
)
|
||
|
||
routed = execute_routed_text_request(
|
||
task=task,
|
||
primary_model=model_config,
|
||
messages=messages,
|
||
streaming=False,
|
||
structured_output=True,
|
||
business_operation="script_generate",
|
||
temperature=0.3,
|
||
validate_text=validate_segment_text,
|
||
request_summary={
|
||
"mode": "revise",
|
||
"target_index": target_index,
|
||
"base_version_id": str(segment.script_version_id),
|
||
},
|
||
)
|
||
raw, _response, draft = routed.value
|
||
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(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
|
||
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
|
||
script = persist_script_draft(project=project, user=user, task=task, draft=draft, source="revise")
|
||
return script
|
||
except Exception as exc:
|
||
_fail_task(task, reservation, str(exc) or "单镜重跑失败")
|
||
raise
|