feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
directors — 导演系统注册表(替换点①)
|
||||
========================================
|
||||
|
||||
统一入口:任何导演系统在此注册,下游通过 `get_director(id)` 取用。
|
||||
|
||||
接入新导演(3 步):
|
||||
1. directors/<新导演>.py 实现 BaseDirector
|
||||
2. 本文件 import + 加进 _DIRECTORS
|
||||
3. 刷新前端,「🎬 导演创作」页的下拉里就会出现
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPTS = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
|
||||
from directors.base import BaseDirector # noqa: E402,F401
|
||||
from directors.legacy_scene_planner import LegacyScenePlanner # noqa: E402
|
||||
|
||||
log = logging.getLogger("directors")
|
||||
|
||||
# ── 已注册的导演(后续接入的在此追加)──────────────────
|
||||
_DIRECTORS: list[BaseDirector] = [LegacyScenePlanner()]
|
||||
|
||||
# v5.2(ai-film-skills):技能文档就位后自动注册
|
||||
try:
|
||||
from directors.storyboard_v52 import StoryboardV52
|
||||
_DIRECTORS.append(StoryboardV52())
|
||||
except Exception as _e: # 技能文档未下载 / 模块未就绪
|
||||
log.debug(" storyboard_v52 未注册: %s", _e)
|
||||
|
||||
# Seedance 导演方法论(精编移植,skill 文档就位后自动注册)
|
||||
try:
|
||||
from directors.seedance_director import SeedanceDirector
|
||||
_DIRECTORS.append(SeedanceDirector())
|
||||
except Exception as _e:
|
||||
log.debug(" seedance_director 未注册: %s", _e)
|
||||
|
||||
|
||||
def all_directors() -> list[BaseDirector]:
|
||||
return list(_DIRECTORS)
|
||||
|
||||
|
||||
def get_director(director_id: str) -> BaseDirector | None:
|
||||
for d in _DIRECTORS:
|
||||
if d.id == director_id:
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def default_director() -> BaseDirector:
|
||||
"""默认导演(第一个 ready 的)"""
|
||||
for d in _DIRECTORS:
|
||||
if d.status == "ready" and getattr(d, "available", True):
|
||||
return d
|
||||
return _DIRECTORS[0]
|
||||
|
||||
|
||||
def list_info() -> list[dict]:
|
||||
"""给前端:全部导演 + 可用性"""
|
||||
out = []
|
||||
for d in _DIRECTORS:
|
||||
info = d.info()
|
||||
info["available"] = bool(getattr(d, "available", True))
|
||||
out.append(info)
|
||||
return out
|
||||
|
||||
|
||||
__all__ = ["BaseDirector", "LegacyScenePlanner", "all_directors",
|
||||
"get_director", "default_director", "list_info"]
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
directors._shot_builder — 公共镜头构建器(各 skill 型导演共用)
|
||||
|
||||
职责:把 LLM 产出的 JSON 数组 → 统一的 Shot 列表(含完整镜头契约/表演/声音/技术需求)。
|
||||
被 storyboard_v52(v5.2 技能)与 seedance_director(Seedance 方法论)共用,
|
||||
保证不同导演产出同一套 Schema,下游无感知。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SCRIPTS = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
|
||||
from schemas import (GlobalAnchors, Shot, ShotContract, # noqa: E402
|
||||
Transition, Speed, AudioConfig, SFX, ExpressionDetail)
|
||||
|
||||
|
||||
def safe_dict(v) -> dict:
|
||||
return v if isinstance(v, dict) else {}
|
||||
|
||||
|
||||
def norm_type(t: Any) -> str:
|
||||
t = (str(t) if t else "").strip().lower()
|
||||
aliases = {"talk": "talking_head", "b_roll": "background", "broll": "background",
|
||||
"scene": "action", "environment": "background"}
|
||||
v = aliases.get(t, t or "action")
|
||||
return v if v in ("action", "talking_head", "background", "vfx", "comfyui", "3d_character") else "action"
|
||||
|
||||
|
||||
def parse_expression(it: dict) -> ExpressionDetail | None:
|
||||
e = safe_dict(it.get("expression_detail"))
|
||||
if not e:
|
||||
return None
|
||||
eyes = (e.get("eyes") or "").strip()
|
||||
mouth = (e.get("mouth") or "").strip()
|
||||
brow = (e.get("brow") or "").strip()
|
||||
if not (eyes or mouth or brow):
|
||||
return None
|
||||
return ExpressionDetail(eyes=eyes or None, mouth=mouth or None, brow=brow or None)
|
||||
|
||||
|
||||
def parse_audio(it: dict) -> AudioConfig | None:
|
||||
cue = (it.get("audio_cue") or "").strip()
|
||||
sfx_raw = it.get("sfx") or []
|
||||
sfx: list[SFX] = []
|
||||
if isinstance(sfx_raw, list):
|
||||
for i, s in enumerate(sfx_raw, 1):
|
||||
d = safe_dict(s)
|
||||
if not d:
|
||||
continue
|
||||
try:
|
||||
at = float(d.get("at_second") or 0)
|
||||
except Exception:
|
||||
at = 0.0
|
||||
sfx.append(SFX(id=f"sfx_{i}", at_second=at,
|
||||
volume=float(d.get("volume") or 0.4),
|
||||
desc=(d.get("desc") or "").strip()))
|
||||
if not cue and not sfx:
|
||||
return None
|
||||
return AudioConfig(audio_cue=cue or None, sfx=sfx)
|
||||
|
||||
|
||||
def parse_transition(v) -> Transition:
|
||||
d = safe_dict(v)
|
||||
t = (d.get("type") or "cut").strip().lower()
|
||||
if t not in ("cut", "dissolve", "fade", "wipe"):
|
||||
t = "cut"
|
||||
try:
|
||||
dur = float(d.get("duration") or 0)
|
||||
except Exception:
|
||||
dur = 0.0
|
||||
return Transition(type=t, duration=dur)
|
||||
|
||||
|
||||
def parse_speed(v) -> Speed:
|
||||
d = safe_dict(v)
|
||||
try:
|
||||
f = float(d.get("factor") or 1.0)
|
||||
except Exception:
|
||||
f = 1.0
|
||||
ramp = (d.get("ramp") or "").strip()
|
||||
return Speed(factor=f, ramp=ramp or None)
|
||||
|
||||
|
||||
def parse_tech_requirements(it: dict) -> dict | None:
|
||||
"""技术需求声明(透传 + 基本校验),供技术优选层消费"""
|
||||
tr = it.get("tech_requirements")
|
||||
if not isinstance(tr, dict):
|
||||
return None
|
||||
cont = safe_dict(tr.get("continuity"))
|
||||
allowed = ("must", "prefer", "none")
|
||||
out = {
|
||||
"continuity": {k: (str(cont.get(k, "none")).strip().lower()
|
||||
if str(cont.get(k, "none")).strip().lower() in allowed else "none")
|
||||
for k in ("character", "wardrobe", "location", "expression", "props")},
|
||||
"chain_from_prev": bool(tr.get("chain_from_prev")),
|
||||
"motion_level": str(tr.get("motion_level") or "small").strip().lower(),
|
||||
"camera_difficulty": str(tr.get("camera_difficulty") or "fixed").strip().lower(),
|
||||
"needs": [str(n).strip() for n in (tr.get("needs") or []) if str(n).strip()],
|
||||
}
|
||||
if out["motion_level"] not in ("static", "small", "medium", "large"):
|
||||
out["motion_level"] = "small"
|
||||
if out["camera_difficulty"] not in ("fixed", "simple", "complex"):
|
||||
out["camera_difficulty"] = "fixed"
|
||||
return out
|
||||
|
||||
|
||||
def build_shots_from_items(items: list, anchors: GlobalAnchors,
|
||||
shot_id_prefix: str = "shot") -> list[Shot]:
|
||||
"""LLM JSON 数组 → Shot 列表(统一契约)
|
||||
|
||||
各导演只需产出相同字段结构的 JSON,本函数负责解析、归一化与容错。
|
||||
"""
|
||||
from directors.base import norm_duration
|
||||
shots: list[Shot] = []
|
||||
for i, it in enumerate(items, 1):
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
text = (it.get("text") or "").strip()
|
||||
tech_req = parse_tech_requirements(it)
|
||||
chain = bool((tech_req or {}).get("chain_from_prev"))
|
||||
# 出场人物(戏内代号):只保留锚点演员表里存在的,避免 AI 写不存在的代号
|
||||
_valid_slots = {m.slot for m in (anchors.effective_cast() if hasattr(anchors, "effective_cast") else [])}
|
||||
_raw_slots = it.get("cast_in_shot") or it.get("cast_slots") or []
|
||||
if isinstance(_raw_slots, str):
|
||||
_raw_slots = [_raw_slots]
|
||||
cast_slots = [str(x).strip() for x in _raw_slots
|
||||
if str(x).strip() and (not _valid_slots or str(x).strip() in _valid_slots)]
|
||||
|
||||
shots.append(Shot(
|
||||
shot_id=f"{shot_id_prefix}_{i:02d}",
|
||||
scene_id=str(i),
|
||||
duration=norm_duration(it.get("duration"), text),
|
||||
type=norm_type(it.get("type")),
|
||||
cast_slots=cast_slots,
|
||||
character_id=anchors.character_id,
|
||||
location_id=anchors.location_id,
|
||||
continuity_mode="previous_frame_continuation" if chain else "fresh_location",
|
||||
tech_requirements=tech_req,
|
||||
visual_description=(it.get("visual_description") or it.get("description") or "").strip(),
|
||||
shot_contract=ShotContract(
|
||||
shot_size=(it.get("shot_size") or "").strip(),
|
||||
angle=(it.get("angle") or "").strip(),
|
||||
camera_move=(it.get("camera_move") or "").strip(),
|
||||
lens_feel=(it.get("lens_feel") or "").strip(),
|
||||
depth_of_field=(it.get("depth_of_field") or "").strip(),
|
||||
frame_rate_feel=(it.get("frame_rate_feel") or "").strip(),
|
||||
),
|
||||
text=text or None,
|
||||
sentiment=(it.get("sentiment") or "").strip() or None,
|
||||
expression_detail=parse_expression(it),
|
||||
action=(it.get("action") or "").strip() or None,
|
||||
audio=parse_audio(it),
|
||||
transition_in=parse_transition(it.get("transition_in")),
|
||||
transition_out=parse_transition(it.get("transition_out")),
|
||||
speed=parse_speed(it.get("speed")),
|
||||
status="pending",
|
||||
))
|
||||
return shots
|
||||
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
directors.base — 导演系统基类(替换点①)
|
||||
==========================================
|
||||
|
||||
任何导演系统只需实现 `generate(story, anchors, options) -> Storyboard`,
|
||||
即可接入视频工厂,下游(编排/能力/执行)完全不感知用的是哪个导演。
|
||||
|
||||
接入新导演(3 步):
|
||||
1. 在 directors/ 加一个文件,实现 BaseDirector
|
||||
2. 在 directors/__init__.py 的 DIRECTORS 注册
|
||||
3. (可选)填 options_schema,前端自动生成该导演的选项面板
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SCRIPTS = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
|
||||
from schemas import GlobalAnchors, Storyboard # noqa: E402
|
||||
|
||||
|
||||
class BaseDirector:
|
||||
"""导演系统基类"""
|
||||
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
kind: str = "skill" # skill(提示词包)| service(HTTP 服务)| legacy(本地既有)
|
||||
description: str = ""
|
||||
features: list[str] = [] # ["visual_concept", "two_pass", "consistency", "auto_pipeline"]
|
||||
status: str = "ready" # ready | partial | planned
|
||||
source: str = "" # 来源说明(如 ai-film-skills v5.2 / 本地既有)
|
||||
options_schema: dict = {} # 该导演的专属选项(自动生成 UI 表单)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
story: str,
|
||||
anchors: GlobalAnchors | None = None,
|
||||
options: dict | None = None,
|
||||
) -> Storyboard:
|
||||
"""故事 → 统一分镜 Schema(**唯一契约**)"""
|
||||
raise NotImplementedError
|
||||
|
||||
def revise_shot(self, storyboard: Storyboard, shot_id: str,
|
||||
instruction: str = "") -> Storyboard:
|
||||
"""单镜头重生成(默认不支持,子类可覆写)"""
|
||||
raise NotImplementedError(f"{self.name} 不支持单镜头重生成")
|
||||
|
||||
def info(self) -> dict:
|
||||
"""给前端的能力描述"""
|
||||
return {
|
||||
"id": self.id, "name": self.name, "kind": self.kind,
|
||||
"description": self.description, "features": self.features,
|
||||
"status": self.status, "source": self.source,
|
||||
"options_schema": self.options_schema,
|
||||
}
|
||||
|
||||
|
||||
# 通用选项(各导演共享的基础参数)
|
||||
COMMON_OPTIONS = {
|
||||
# ★ 推荐用法:给「目标总时长」,镜头数与每镜时长交给导演自行决定
|
||||
"total_duration": {"type": "number", "min": 0, "max": 1800, "default": 0,
|
||||
"label": "目标总时长(秒)",
|
||||
"hint": "推荐用法:60 = 一分钟短片 · 180 = 三分钟 · 300 = 五分钟。"
|
||||
"导演据此自由决定镜头数与每镜时长(充分解放创作)。填 0 = 不限制"},
|
||||
# 以下两项**仅作参考**(导演可采纳可不采纳;填 0 = 不干涉)
|
||||
"shot_count": {"type": "number", "min": 0, "max": 30, "default": 0,
|
||||
"label": "镜头数(仅参考)",
|
||||
"hint": "仅作参考:导演可采纳也可不采纳。填 0 = 让导演按内容自行决定(推荐)"},
|
||||
"shot_duration": {"type": "number", "min": 0, "max": 15, "default": 0,
|
||||
"label": "单镜时长(仅参考)",
|
||||
"hint": "仅作参考:导演按内容与节奏自行决定。填 0 = 不限制"},
|
||||
"shooting_style": {
|
||||
"type": "enum",
|
||||
"options": ["documentary_realism", "cinematic", "vlog_handheld",
|
||||
"studio_clean", "selfie_pov", "interview_doc",
|
||||
"anime_2d", "anime_3d", "product_ad"],
|
||||
# 中文标签(前端显示,值保持英文以兼容后端)
|
||||
"option_labels": {
|
||||
"documentary_realism": "🎥 伪纪录片 · 真实自然(像随手拍的生活记录)",
|
||||
"cinematic": "🎞️ 电影感 · 考究构图(有设计感的画面)",
|
||||
"vlog_handheld": "🚶 手持 Vlog · 第一视角(边走边拍)",
|
||||
"studio_clean": "💡 商业棚拍 · 高清干净(打光讲究、背景简洁)",
|
||||
"selfie_pov": "📱 手机自拍 · 第一人称(举着手机自己拍自己)",
|
||||
"interview_doc": "📺 访谈纪实 · 半身对镜(像接受采访)",
|
||||
"anime_2d": "🎨 2D 动画 · 漫剧(手绘/动漫风格)",
|
||||
"anime_3d": "🧸 3D 动画 · 卡通渲染(三维风格化)",
|
||||
"product_ad": "🧊 产品广告 · 精致特写(微距/质感)",
|
||||
},
|
||||
"default": "documentary_realism", "label": "拍摄风格",
|
||||
},
|
||||
"realism_boost": {
|
||||
"type": "enum",
|
||||
"options": ["off", "mild", "strong"],
|
||||
"option_labels": {
|
||||
"off": "关闭(不做额外处理)",
|
||||
"mild": "轻度(追加真实质感:噪点/毛孔/不匀光)",
|
||||
"strong": "强力(↑ 并自动剔除「4K/超清/无瑕/大师作品」等反真实词)",
|
||||
},
|
||||
"default": "mild", "label": "反 AI 感(真实感增强)",
|
||||
},
|
||||
"director_style": {
|
||||
"type": "enum",
|
||||
"options": ["", "spielberg", "hitchcock", "kubrick", "kurosawa", "scorsese",
|
||||
"fellini", "bergman", "tarkovsky", "wong_kar_wai", "nolan",
|
||||
"villeneuve", "fincher", "refn", "bi_gan", "zhang_yimou",
|
||||
"hou_hsiao_hsien", "park_chan_wook", "malick",
|
||||
"michael_mann", "coen_brothers"],
|
||||
"option_labels": {
|
||||
"": "(不使用导演风格)",
|
||||
"spielberg": "斯皮尔伯格 · 奇观与亲密", "hitchcock": "希区柯克 · 悬念与信息差",
|
||||
"kubrick": "库布里克 · 几何与秩序", "kurosawa": "黑泽明 · 群像与天气",
|
||||
"scorsese": "斯科塞斯 · 都市与旁白", "fellini": "费里尼 · 狂欢与记忆",
|
||||
"bergman": "伯格曼 · 凝视与沉默", "tarkovsky": "塔可夫斯基 · 雕刻时间",
|
||||
"wong_kar_wai": "王家卫 · 都市孤独", "nolan": "诺兰 · 高概念谜题",
|
||||
"villeneuve": "维伦纽瓦 · 宏大克制", "fincher": "芬奇 · 外科精确",
|
||||
"refn": "雷弗恩 · 霓虹停滞", "bi_gan": "毕赣 · 梦境长镜",
|
||||
"zhang_yimou": "张艺谋 · 单色仪式", "hou_hsiao_hsien": "侯孝贤 · 固定远观",
|
||||
"park_chan_wook": "朴赞郁 · 巴洛克反讽", "malick": "马力克 · 自然光诗意",
|
||||
"michael_mann": "迈克尔·曼 · 夜色与专业", "coen_brothers": "科恩兄弟 · 冷峻荒诞",
|
||||
},
|
||||
"default": "", "label": "🎥 导演风格(镜头语言与叙事逻辑)",
|
||||
},
|
||||
"lang": {"type": "enum", "options": ["zh", "en"], "default": "zh", "label": "提示词语言"},
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 公共工具:时长归一化(各导演共用,保证内容驱动语义一致)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def shot_brief(opts: dict) -> str:
|
||||
"""把「目标总时长 / 镜头数 / 单镜时长」组装成一句给导演的说明
|
||||
|
||||
设计原则(用户 2026-10-04 明确):
|
||||
· **总时长 = 主驱动**("一分钟短片"/"五分钟短片")
|
||||
· **镜头数与单镜时长仅作参考** —— 导演可采纳可不采纳
|
||||
· 硬约束会削弱创作有效性 → 一律用"参考"语气,不写"必须"
|
||||
|
||||
让导演按题材/内容/目的自由裁镜头数,短片长篇皆可。
|
||||
"""
|
||||
def _int(v):
|
||||
try:
|
||||
return int(float(v or 0))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
total = _int((opts or {}).get("total_duration"))
|
||||
n = _int((opts or {}).get("shot_count"))
|
||||
d = _int((opts or {}).get("shot_duration"))
|
||||
|
||||
out = ""
|
||||
if total:
|
||||
out = f"目标总时长:约 {total} 秒({total / 60:.1f} 分钟)。"
|
||||
refs = []
|
||||
if n:
|
||||
refs.append(f"约 {n} 个镜头")
|
||||
if d:
|
||||
refs.append(f"每镜约 {d} 秒")
|
||||
if refs:
|
||||
out += (f"参考:{'、'.join(refs)} —— 可采纳可不采纳,"
|
||||
f"请按内容与叙事节奏自行决定要分多少个镜头、每个镜头多长。")
|
||||
return out or "镜头数量与每镜时长由你按内容与叙事节奏自由决定。"
|
||||
|
||||
|
||||
def norm_duration(d, text: str = "") -> float:
|
||||
"""时长归一化:内容驱动 + 台词兜底 + 上下限(2~10 秒)
|
||||
|
||||
· 台词兜底:中文口播约 4.5 字/秒 → 时长至少能容纳台词
|
||||
· 上限 10 秒(生成模型单镜约束;超长需接续能力,由技术优选层识别)
|
||||
"""
|
||||
try:
|
||||
v = float(d)
|
||||
except Exception:
|
||||
v = 5.0
|
||||
if text:
|
||||
need = len(str(text).strip()) / 4.5
|
||||
if need > v:
|
||||
v = need
|
||||
return round(max(2.0, min(10.0, v)), 1)
|
||||
|
||||
|
||||
def cast_or_character(anchors) -> str:
|
||||
"""角色/演员表 → 提示词文本(**演员表优先**,含出场标注要求)
|
||||
|
||||
用户 2026-10-03 要求:短片常有多人物(主角/配角/人物一),
|
||||
演员表(cast)比单角色字段更贴合;逐镜头出场由 cast_in_shot 标注。
|
||||
"""
|
||||
cc = getattr(anchors, "cast_context", "") or ""
|
||||
if cc:
|
||||
return (cc + '\n(多人物戏:请在每个镜头的 JSON 里用 "cast_in_shot": ["主角"] '
|
||||
'标注谁出场;代号只能取自上面【】里的)')
|
||||
return (getattr(anchors, "character_context", "") or getattr(anchors, "character_id", "")
|
||||
or "(未指定)")
|
||||
|
||||
|
||||
def style_pack_block(anchors) -> str:
|
||||
"""拍摄风格包 → 导演可执行的「拍摄说明」块
|
||||
|
||||
作用:让导演在写分镜时就知道"这戏按什么风格拍"(设备/光/机位/质感/情绪),
|
||||
从而产出风格一致的镜头语言,而不是每部片都写一样的通用描述。
|
||||
"""
|
||||
try:
|
||||
from composer.realism import STYLE_PACKS
|
||||
except Exception:
|
||||
return ""
|
||||
style = (getattr(anchors, "shooting_style", "") or "").strip()
|
||||
pack = STYLE_PACKS.get(style) or {}
|
||||
if not pack:
|
||||
return ""
|
||||
lines = [
|
||||
f"【本次拍摄风格:{pack.get('label', style)}】",
|
||||
f"· 设备:{pack.get('device', '')}",
|
||||
f"· 光线:{pack.get('lighting', '')}",
|
||||
f"· 机位与运镜:{pack.get('camera', '')}",
|
||||
f"· 质感与瑕疵:{pack.get('flaws', '')}",
|
||||
f"· 情绪表达:{pack.get('emotion', '')}",
|
||||
"→ 每个镜头的画面描述都要符合以上设定;不要写与风格冲突的元素。",
|
||||
]
|
||||
boost = (getattr(anchors, "realism_boost", "") or "").strip()
|
||||
if boost == "strong":
|
||||
lines.append("→ 真实感要求高:避免「完美/无瑕/超清」这类修饰,多写生活化的不完美细节。")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def director_style_hint(anchors) -> str:
|
||||
"""导演风格 → 提示词块(空 key 返回空串)
|
||||
|
||||
与 style_pack_block(拍摄风格)**互补**:
|
||||
· 拍摄风格 = 用什么设备/什么质感拍(自拍/手持/电影感…)
|
||||
· 导演风格 = 用什么镜头语言/叙事逻辑(斯皮尔伯格/王家卫…)
|
||||
"""
|
||||
key = (getattr(anchors, "director_style", "") or "").strip()
|
||||
if not key:
|
||||
return ""
|
||||
try:
|
||||
from composer.director_styles import director_style_block
|
||||
return director_style_block(key)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def realism_block() -> str:
|
||||
"""「真实感纪律」提示块(注入导演提示词)
|
||||
|
||||
来源:anti-ai-checklist.md(外部开源研究,原文存 docs/realism/)
|
||||
内容:非完美事件分配 + 2 秒节奏 + 禁止慢动作 + 抽象词黑名单
|
||||
"""
|
||||
try:
|
||||
from composer.realism import imperfect_events_prompt
|
||||
return imperfect_events_prompt()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
# ── 拍摄风格 → 设备缺陷包映射(让导演按真实设备的物理缺陷描述画面)──
|
||||
_DEVICE_MAP = {
|
||||
"selfie_pov": "smartphone_vertical",
|
||||
"vlog_handheld": "smartphone_vertical",
|
||||
"documentary_realism": "dv_2000s",
|
||||
"interview_doc": "doc_handheld",
|
||||
# cinematic / studio_clean:刻意干净,不套缺陷包
|
||||
}
|
||||
|
||||
|
||||
def device_pack_hint(anchors) -> str:
|
||||
"""拍摄风格 → 设备缺陷参数包(研究落地:缺陷即真实)
|
||||
|
||||
来源:camera-aesthetics.md(9 个设备包,原文存 docs/realism/)
|
||||
"""
|
||||
style = (getattr(anchors, "shooting_style", "") or "").strip()
|
||||
key = _DEVICE_MAP.get(style, "")
|
||||
if not key:
|
||||
return ""
|
||||
try:
|
||||
from composer.realism import device_pack_block
|
||||
return device_pack_block(key)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def realism_discipline() -> str:
|
||||
"""去 AI 感的**提示词纪律**(供导演写分镜时遵守)
|
||||
|
||||
来源:anti-ai-checklist.md + atmosphere-dictionary.md(研究落地)
|
||||
"""
|
||||
return (
|
||||
"【去 AI 感纪律(★重要,直接决定像不像真拍的)】\n"
|
||||
"1. **禁用抽象氛围词**:不要写「温馨」「紧张」「孤独」这类词 —— "
|
||||
"把它们翻译成**具体可拍的现象**(例:不写「温馨」,写「午后阳光透过树叶在地面"
|
||||
"投下斑驳光影、杯口冒着细微热气」)。\n"
|
||||
"2. **每镜头一个「非完美事件」**(全片不重复,缺陷类型也要换):\n"
|
||||
" · 摄影师失误:自动对焦在主体与背景间搜索 / 构图偏离中心 / 慢了半拍才跟上\n"
|
||||
" · 环境介入:微风吹动发丝 / 云影掠过导致曝光波动 / 场外有人打招呼\n"
|
||||
" · 主体自然行为:注意到镜头露出真诚微笑后移开视线 / 说话被环境音打断笑场\n"
|
||||
"3. **对焦要写路径**(不要笼统写「自动对焦搜索」):\n"
|
||||
" ❌ 自动对焦反复搜索\n"
|
||||
" ✅ 自动对焦在她脸部和她手中的杯子之间来回切换\n"
|
||||
"4. **只用现场真实光源**(窗光/顶灯/手机屏幕光),禁影棚词(柔光箱/主光/轮廓光)。\n"
|
||||
"5. **不要写慢动作**(除非明确设定为手机慢动作模式)——真实拍摄默认实时速度。"
|
||||
)
|
||||
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
directors.legacy_scene_planner — 本地导演(现有能力的包装)
|
||||
==============================================================
|
||||
|
||||
把「故事文本 → 分镜」用 LLM 直接完成(不依赖本地 LLM / 脚本 YAML 中转),
|
||||
输出**统一分镜 Schema**。这是 v5.2 接入前的默认可⽤导演。
|
||||
|
||||
特点:
|
||||
· 支持单镜头重生成(revise_shot)
|
||||
· 输出含镜头契约(景别/机位/运动/景深)—— 用简化版专业摄影语言
|
||||
· 无外部依赖,只调 DeepSeek
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SCRIPTS = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
|
||||
from schemas import GlobalAnchors, Shot, ShotContract, Storyboard # noqa: E402
|
||||
from directors.base import COMMON_OPTIONS, BaseDirector, norm_duration, cast_or_character, style_pack_block, director_style_hint, shot_brief # noqa: E402
|
||||
from directors.llm import DirectorLLM # noqa: E402
|
||||
|
||||
|
||||
def _sc_val(v) -> int:
|
||||
"""镜头数:0 / 空 = 自动(交给导演决定)"""
|
||||
try:
|
||||
return int(v or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
log = logging.getLogger("directors.legacy")
|
||||
|
||||
_SYSTEM = "你是专业的影视分镜师,擅长把故事拆解成可执行的镜头列表。"
|
||||
|
||||
_PROMPT_TMPL = """请把下面的故事拍成一支短片。
|
||||
|
||||
【故事】
|
||||
{story}
|
||||
|
||||
【整体设定】
|
||||
{style_pack}
|
||||
{director_style}
|
||||
|
||||
【镜头数与时长参考】
|
||||
{shot_brief}
|
||||
|
||||
【资产设定(★必须严格使用这些设定,不要自己另编场景/人物;同一资产在所有镜头中保持一致)】
|
||||
- 角色:{character}
|
||||
- 场地:{location}
|
||||
- 服装:{wardrobe}
|
||||
- 产品 / 道具:{props}
|
||||
|
||||
【要求】
|
||||
1. 每个镜头要具体可拍:写清"谁在哪做什么、画面里有什么、光线如何"
|
||||
2. 镜头语言要专业:景别(特写/近景/中景/全景)+ 机位角度 + 摄影机运动
|
||||
3. 保持整体连贯:镜头之间是同一场景/同一人物,风格统一
|
||||
4. 有台词就写台词(像真人口语),没台词留空
|
||||
5. 镜头类型从这几类里选:action(动作/场景)、talking_head(对镜头说话)、background(环境空镜)
|
||||
|
||||
【输出格式】严格输出 JSON 数组,不要任何解释文字:
|
||||
[
|
||||
{{
|
||||
"duration": 5,
|
||||
"type": "action",
|
||||
"visual_description": "画面描述(谁在哪做什么,光线/环境细节)",
|
||||
"shot_size": "景别(特写/近景/中景/全景)",
|
||||
"angle": "机位角度(平视/俯视/仰视/过肩)",
|
||||
"camera_move": "摄影机运动(固定/推/拉/摇/跟/环绕)",
|
||||
"depth_of_field": "景深(浅/中/深)",
|
||||
"text": "台词或旁白(没有就空字符串)",
|
||||
"sentiment": "情绪(如:平静/焦虑/欣慰)",
|
||||
"action": "人物动作",
|
||||
"cast_slots": ["这一镜**出场**的角色名(必须与上面【资产设定】里的角色名完全一致;
|
||||
空镜/无人物镜头给空数组 [])"],
|
||||
"location_id": "这一镜所在场地(必须与上面【资产设定】的场地名一致)"
|
||||
}}
|
||||
]
|
||||
|
||||
★ cast_slots 非常重要:它决定生成关键帧时**用哪几个角色的参考图做多图融合**
|
||||
(特写要保脸、全景要保环境)。漏填会导致人物不一致,请如实填写。
|
||||
"""
|
||||
|
||||
_REVISE_TMPL = """下面是一个分镜表里的第 {idx} 个镜头,请按用户要求重新生成这一个镜头。
|
||||
|
||||
【整片故事】
|
||||
{story}
|
||||
|
||||
【当前镜头】
|
||||
{current}
|
||||
|
||||
【修改要求】
|
||||
{instruction}
|
||||
|
||||
【要求】保持与整片风格一致,只改这一个镜头。输出格式:单个 JSON 对象(不要数组),字段同上。
|
||||
"""
|
||||
|
||||
|
||||
class LegacyScenePlanner(BaseDirector):
|
||||
"""本地导演(LLM 直出分镜)"""
|
||||
|
||||
id = "legacy_scene_planner"
|
||||
name = "本地分镜(内置)"
|
||||
kind = "legacy"
|
||||
description = "用 LLM 把故事直接拆成专业分镜(含景别/机位/运镜),无需额外依赖"
|
||||
features = ["storyboard", "revise_shot"]
|
||||
status = "ready"
|
||||
source = "09_ave/scripts/directors/legacy_scene_planner.py"
|
||||
options_schema = dict(COMMON_OPTIONS)
|
||||
|
||||
def __init__(self):
|
||||
self.llm = DirectorLLM()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.llm.available
|
||||
|
||||
# ── 生成 ─────────────────────────────────────────
|
||||
|
||||
def generate(self, story: str, anchors: GlobalAnchors | None = None,
|
||||
options: dict | None = None) -> Storyboard:
|
||||
opts = options or {}
|
||||
anchors = anchors or GlobalAnchors()
|
||||
shot_count = int(opts.get("shot_count") or 6)
|
||||
shot_duration = float(opts.get("shot_duration") or 5)
|
||||
shooting_style = opts.get("shooting_style") or "documentary_realism"
|
||||
realism_boost = opts.get("realism_boost") or "mild"
|
||||
director_style = opts.get("director_style") or ""
|
||||
|
||||
prompt = _PROMPT_TMPL.format(
|
||||
shot_brief=shot_brief({"shot_count": shot_count, "shot_duration": shot_duration}),
|
||||
story=(story or "").strip()[:2000],
|
||||
shooting_style=shooting_style,
|
||||
shot_duration=shot_duration,
|
||||
# ★ 资产注入:有详情用详情,否则退回 ID
|
||||
character=cast_or_character(anchors),
|
||||
style_pack=style_pack_block(anchors),
|
||||
director_style=director_style_hint(anchors),
|
||||
location=anchors.location_context or anchors.location_id or "(未指定)",
|
||||
wardrobe=anchors.wardrobe or "(未指定)",
|
||||
props=anchors.props_context or "(无)",
|
||||
)
|
||||
raw = self.llm.chat(prompt, system=_SYSTEM, temperature=0.7, max_tokens=3000)
|
||||
items = self.llm.parse_json(raw)
|
||||
if isinstance(items, dict): # 容错:AI 返回 {"shots": [...]}
|
||||
items = items.get("shots") or items.get("data") or []
|
||||
if not isinstance(items, list) or not items:
|
||||
log.warning(" ⚠️ legacy 导演未产出有效分镜")
|
||||
return Storyboard(project_id=_pid(), story=story, director_id=self.id,
|
||||
global_anchors=anchors, shots=[])
|
||||
|
||||
shots: list[Shot] = []
|
||||
for i, it in enumerate(items, 1):
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
# ★ 出场角色(决定关键帧用哪几张角色参考图做多图融合)
|
||||
# LLM 输出优先(但要过滤掉演员表里不存在的代号,防 AI 乱写);
|
||||
# 没给就兜底用演员表的全部 slot(单角色场景即主角)
|
||||
# 注意 anchors.cast 里是 CastMember 对象(不是 dict)——
|
||||
# 照 _shot_builder 的写法用 effective_cast()
|
||||
try:
|
||||
_members = anchors.effective_cast() if hasattr(anchors, "effective_cast") else []
|
||||
except Exception:
|
||||
_members = []
|
||||
_valid = {str(getattr(m, "slot", "") or "") for m in _members}
|
||||
_valid.discard("")
|
||||
_raw = it.get("cast_slots") or it.get("cast_in_shot") or []
|
||||
if isinstance(_raw, str):
|
||||
_raw = [_raw]
|
||||
_cs = [str(x).strip() for x in _raw
|
||||
if str(x).strip() and (not _valid or str(x).strip() in _valid)]
|
||||
if not _cs:
|
||||
_cs = sorted(_valid) # 兜底:演员表的全部 slot
|
||||
shots.append(Shot(
|
||||
shot_id=f"shot_{i:02d}",
|
||||
scene_id=str(i),
|
||||
duration=norm_duration(it.get("duration"), (it.get("text") or "").strip()),
|
||||
type=_norm_type(it.get("type")),
|
||||
cast_slots=_cs,
|
||||
character_id=anchors.character_id,
|
||||
location_id=(it.get("location_id") or "").strip() or anchors.location_id,
|
||||
visual_description=(it.get("visual_description") or "").strip(),
|
||||
shot_contract=ShotContract(
|
||||
shot_size=(it.get("shot_size") or "").strip(),
|
||||
angle=(it.get("angle") or "").strip(),
|
||||
camera_move=(it.get("camera_move") or "").strip(),
|
||||
lens_feel=(it.get("lens_feel") or "").strip(),
|
||||
depth_of_field=(it.get("depth_of_field") or "").strip(),
|
||||
),
|
||||
text=(it.get("text") or "").strip() or None,
|
||||
sentiment=(it.get("sentiment") or "").strip() or None,
|
||||
action=(it.get("action") or "").strip() or None,
|
||||
status="pending",
|
||||
))
|
||||
|
||||
sb = Storyboard(
|
||||
project_id=_pid(),
|
||||
title=(story or "")[:40],
|
||||
story=story,
|
||||
content_type=opts.get("content_type") or "short_drama",
|
||||
director_id=self.id,
|
||||
global_anchors=anchors,
|
||||
shots=shots,
|
||||
)
|
||||
log.info(" 🎬 legacy 导演产出 %d 个镜头(%.1f 秒)", len(shots), sb.total_duration())
|
||||
return sb
|
||||
|
||||
# ── 单镜头重生成 ──────────────────────────────────
|
||||
|
||||
def revise_shot(self, sb: Storyboard, shot_id: str,
|
||||
instruction: str = "") -> Storyboard:
|
||||
target = sb.shot_by_id(shot_id)
|
||||
if not target:
|
||||
raise ValueError(f"镜头不存在: {shot_id}")
|
||||
idx = sb.shots.index(target) + 1
|
||||
prompt = _REVISE_TMPL.format(
|
||||
idx=idx,
|
||||
story=(sb.story or "")[:800],
|
||||
current=json.dumps({
|
||||
"duration": target.duration,
|
||||
"type": target.type,
|
||||
"visual_description": target.visual_description,
|
||||
"shot_size": target.shot_contract.shot_size,
|
||||
"camera_move": target.shot_contract.camera_move,
|
||||
"text": target.text,
|
||||
"sentiment": target.sentiment,
|
||||
}, ensure_ascii=False, indent=1),
|
||||
instruction=instruction or "让这一幕更自然、更有真实感",
|
||||
)
|
||||
raw = self.llm.chat(prompt, system=_SYSTEM, temperature=0.8, max_tokens=800)
|
||||
it = self.llm.parse_json(raw)
|
||||
if isinstance(it, list) and it:
|
||||
it = it[0]
|
||||
if not isinstance(it, dict):
|
||||
raise RuntimeError("重生成失败:LLM 未返回有效结果")
|
||||
|
||||
target.duration = float(it.get("duration") or target.duration)
|
||||
target.type = _norm_type(it.get("type") or target.type)
|
||||
target.visual_description = (it.get("visual_description") or target.visual_description).strip()
|
||||
target.shot_contract = ShotContract(
|
||||
shot_size=(it.get("shot_size") or "").strip(),
|
||||
angle=(it.get("angle") or "").strip(),
|
||||
camera_move=(it.get("camera_move") or "").strip(),
|
||||
lens_feel=(it.get("lens_feel") or "").strip(),
|
||||
depth_of_field=(it.get("depth_of_field") or "").strip(),
|
||||
)
|
||||
target.text = (it.get("text") or "").strip() or None
|
||||
target.sentiment = (it.get("sentiment") or "").strip() or None
|
||||
target.action = (it.get("action") or "").strip() or None
|
||||
log.info(" 🔄 镜头重生成: %s", shot_id)
|
||||
return sb
|
||||
|
||||
|
||||
# ── 工具 ──────────────────────────────────────────────
|
||||
|
||||
def _norm_type(t: Any) -> str:
|
||||
t = (t or "").strip().lower()
|
||||
aliases = {
|
||||
"action": "action", "talking_head": "talking_head", "talk": "talking_head",
|
||||
"background": "background", "b_roll": "background", "vfx": "vfx",
|
||||
"comfyui": "comfyui", "3d_character": "3d_character",
|
||||
}
|
||||
return aliases.get(t, "action")
|
||||
|
||||
|
||||
def _pid() -> str:
|
||||
from datetime import datetime
|
||||
return "vid_" + datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
directors.llm — 导演层共用的轻量 LLM 客户端
|
||||
=============================================
|
||||
|
||||
配置优先级:环境变量 DEEPSEEK_* > agent-local local.yaml(llm) > 09_ave/config.yaml(llm)
|
||||
(与评论生成 AIGenerator 一致的取配置方式,但独立实现,避免跨模块耦合)
|
||||
|
||||
仅提供最简 chat 调用 + JSON 解析,供各导演实现使用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from lib.http import urlopen as _noproxy_urlopen # 禁代理+重试
|
||||
|
||||
log = logging.getLogger("directors.llm")
|
||||
|
||||
_SCRIPTS = Path(__file__).resolve().parent.parent # 09_ave/scripts/
|
||||
_AVE_DIR = _SCRIPTS.parent # 09_ave/
|
||||
_AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL") or (_AVE_DIR.parents[2] / "agent-local"))
|
||||
_LOCAL_CFG = _AGENT_LOCAL / "tools" / "ave" / "config" / "local.yaml"
|
||||
_AVE_CFG = _AVE_DIR / "config.yaml"
|
||||
|
||||
|
||||
def load_llm_config() -> dict:
|
||||
"""三级优先级读取 LLM 配置"""
|
||||
cfg: dict = {}
|
||||
# ③ 09_ave/config.yaml
|
||||
if _AVE_CFG.exists():
|
||||
try:
|
||||
raw = yaml.safe_load(_AVE_CFG.read_text(encoding="utf-8")) or {}
|
||||
cfg.update(raw.get("llm") or {})
|
||||
except Exception:
|
||||
pass
|
||||
# ② 配置中心 local.yaml → llm
|
||||
if _LOCAL_CFG.exists():
|
||||
try:
|
||||
raw = yaml.safe_load(_LOCAL_CFG.read_text(encoding="utf-8")) or {}
|
||||
cfg.update(raw.get("llm") or {})
|
||||
except Exception:
|
||||
pass
|
||||
# ① 环境变量
|
||||
if os.environ.get("DEEPSEEK_API_KEY"):
|
||||
cfg["api_key"] = os.environ["DEEPSEEK_API_KEY"]
|
||||
if os.environ.get("DEEPSEEK_BASE_URL"):
|
||||
cfg["base_url"] = os.environ["DEEPSEEK_BASE_URL"]
|
||||
if os.environ.get("DEEPSEEK_MODEL"):
|
||||
cfg["model"] = os.environ["DEEPSEEK_MODEL"]
|
||||
cfg.setdefault("base_url", "https://api.deepseek.com/v1")
|
||||
cfg.setdefault("model", "deepseek-v4-flash")
|
||||
return cfg
|
||||
|
||||
|
||||
class DirectorLLM:
|
||||
"""导演层 LLM 客户端(单次同步调用)"""
|
||||
|
||||
def __init__(self, timeout: int = 90):
|
||||
cfg = load_llm_config()
|
||||
self.api_key = (cfg.get("api_key") or "").strip()
|
||||
self.base_url = (cfg.get("base_url") or "https://api.deepseek.com/v1").rstrip("/")
|
||||
self.model = cfg.get("model") or "deepseek-v4-flash"
|
||||
self.timeout = timeout
|
||||
self._fails = 0
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return bool(self.api_key) and self._fails < 3
|
||||
|
||||
def chat(self, prompt: str, system: str = "", temperature: float = 0.7,
|
||||
max_tokens: int = 3000) -> str:
|
||||
"""单次对话(失败返回空串,连续 3 次失败熔断)"""
|
||||
if not self.available:
|
||||
log.warning(" ⚠️ LLM 不可用(未配 key 或已熔断)")
|
||||
return ""
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
body = json.dumps({
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
# 关思考模式:分镜生成不需要长推理,省 token 且更快
|
||||
"thinking": {"type": "disabled"},
|
||||
}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{self.base_url}/chat/completions", data=body,
|
||||
headers={"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}"})
|
||||
try:
|
||||
with _noproxy_urlopen(req, timeout=self.timeout) as r:
|
||||
data = json.loads(r.read().decode("utf-8"))
|
||||
content = (data.get("choices") or [{}])[0].get("message", {}).get("content", "")
|
||||
self._fails = 0
|
||||
return (content or "").strip()
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode("utf-8", errors="ignore")[:200]
|
||||
log.error(" ❌ LLM HTTP %s: %s", e.code, detail)
|
||||
self._fails += 1
|
||||
return ""
|
||||
except Exception as e:
|
||||
log.error(" ❌ LLM 调用失败: %s", e)
|
||||
self._fails += 1
|
||||
return ""
|
||||
|
||||
# ── JSON 解析(容错)──────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def parse_json(text: str) -> Any:
|
||||
"""从 LLM 输出里提取 JSON(容忍 markdown 围栏 / 前后废话)"""
|
||||
if not text:
|
||||
return None
|
||||
t = text.strip()
|
||||
# 去 markdown 围栏
|
||||
m = re.search(r"```(?:json)?\s*(.+?)\s*```", t, re.S)
|
||||
if m:
|
||||
t = m.group(1).strip()
|
||||
# 直接解析
|
||||
try:
|
||||
return json.loads(t)
|
||||
except Exception:
|
||||
pass
|
||||
# 找第一个 { 或 [ 到最后一个 } 或 ]
|
||||
for open_c, close_c in (("{", "}"), ("[", "]")):
|
||||
i, j = t.find(open_c), t.rfind(close_c)
|
||||
if i >= 0 and j > i:
|
||||
try:
|
||||
return json.loads(t[i:j + 1])
|
||||
except Exception:
|
||||
continue
|
||||
log.warning(" ⚠️ LLM 输出无法解析为 JSON(前 120 字: %s)", t[:120])
|
||||
return None
|
||||
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
directors.seedance_director — Seedance 导演方法论(skill 型导演)
|
||||
|
||||
来源:Seedance-Director-4.0(SCAI)导演方法论,去人设/去平台命令后精编移植,
|
||||
见 09_ave/skills/seedance-director/SKILL.md
|
||||
|
||||
特点(与 v5.2 的差异):
|
||||
· 强调「分轨 / 场一个职 / 条一个目的 / 镜头必须干活」
|
||||
· 时长跟内容与气口走(容量不是目标;超限则切条并声明接续需求)
|
||||
· 产出后自检「五类病」(矛盾/断链/走样/模型雷/红线)
|
||||
· 输出同一套 Shot Schema(复用 _shot_builder),下游无感知
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SCRIPTS = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
|
||||
from schemas import GlobalAnchors, Storyboard # noqa: E402
|
||||
from directors.base import COMMON_OPTIONS, BaseDirector, cast_or_character, style_pack_block, realism_block, director_style_hint, device_pack_hint, realism_discipline, shot_brief # noqa: E402
|
||||
from directors._shot_builder import build_shots_from_items, norm_type # noqa: E402
|
||||
from directors.llm import DirectorLLM # noqa: E402
|
||||
|
||||
|
||||
def _sc_val(v) -> int:
|
||||
"""镜头数:0 / 空 = 自动(交给导演按叙事需要决定)"""
|
||||
try:
|
||||
return int(v or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
log = logging.getLogger("directors.seedance")
|
||||
|
||||
_AVE_DIR = _SCRIPTS.parent
|
||||
SKILL_PATHS = [
|
||||
_AVE_DIR / "skills" / "seedance-director" / "SKILL.md",
|
||||
]
|
||||
_SKILL_MAX_CHARS = 13000
|
||||
|
||||
_PROMPT_TMPL = """请按「Seedance 导演方法论」把下面的故事拍成一支短片。
|
||||
{shot_brief}
|
||||
|
||||
【故事】
|
||||
{story}
|
||||
|
||||
【整体设定】
|
||||
- 拍摄风格:{shooting_style}
|
||||
- {shot_brief}
|
||||
|
||||
【资产设定(★严格使用,不要另编;同一资产在所有镜头保持一致)】
|
||||
- 角色:{character}
|
||||
- 场地:{location}
|
||||
- 服装:{wardrobe}
|
||||
- 产品/道具:{props}
|
||||
|
||||
【场景真实感纪律(★重要,直接影响"像不像真拍的")】
|
||||
**禁止影棚语言**——生活场景里不该出现专业影棚的词汇,写了画面立刻变假:
|
||||
- ❌ 禁止:柔光箱 / 布光("柔光箱均匀布光""侧面打光")/ 纯色背景 / 纯灰背景 /
|
||||
中焦 85mm / 压缩空间 / 影棚 / 摄影棚 / 反光板 / 灯架
|
||||
- ✅ 只用**场景里真实存在的光源**,并说清它从哪来:
|
||||
窗户自然光 / 室内顶灯 / 床头灯 / 台灯 / 手机屏幕的光 / 走廊灯 / 电视光
|
||||
- ❌ 禁止把背景写成"纯色/虚化背景"——真实房间的背景就是**杂乱的真实物件**
|
||||
(输液架、床头柜、水杯、被子褶皱、墙上的挂钩)
|
||||
|
||||
【拍摄者的动作逻辑(★自拍/Vlog 必检)】
|
||||
画面里"手机是谁拿的"必须说得通,否则一眼假:
|
||||
- **人物自己举着**:那他的**双手被占用**,不能再做"抓床单/换药/双手撑床"这类动作
|
||||
- **他人帮拍**(护士/家属/路人):必须写明"他人在拍",并允许这些真实特征:
|
||||
画面晃动 / 对焦慢半拍 / 构图歪 / 短暂拍到地面或天花板 / 拍摄者在画面外说话
|
||||
- **手机放置**(立在床头柜、靠着水杯、半躺在床上):允许角度歪、中途滑落调整、
|
||||
只拍到半个身子
|
||||
- ⚠️ **手被占住的场景**(换药、剧痛抓床单、双手抱头)**不能**写成"自己举着手机自拍"
|
||||
|
||||
【慌乱/失控场景的真实特征(★疼、急、乱的时候必带)】
|
||||
真实的手持拍摄在慌乱中一定会露出这些"瑕疵",它们才是真实感的来源:
|
||||
- 镜头晃动加剧、跟不住主体
|
||||
- 焦点来回漂移、短暂失焦
|
||||
- 构图来不及调整(人物时而偏出画面)
|
||||
- 呼吸声/喘气声进麦克风
|
||||
- 镜头短暂甩到别处(墙、天花板、被子)再拉回来
|
||||
|
||||
【镜头时长纪律(★硬约束)】
|
||||
生成模型单镜头**最长 10 秒**(可灵只有 5 秒 / 10 秒两档),这是硬限制。因此:
|
||||
- 单镜时长写 **2~10 秒**(整数或 .5,按内容与气口定,不要凑数)
|
||||
- 超过 10 秒的连续动作**必须主动拆成多个镜头**,拆法:
|
||||
· **按动作阶段**:起势 → 发力 → 收势(如"慢慢坐下"→"落座瞬间的表情"→"坐定后的长舒一口气")
|
||||
· **按景别递进**:中景交代 → 特写强调 → 反应镜头(观众看不出断点)
|
||||
· **按台词气口**:一句话说完整,下一句换镜头
|
||||
- ❌ 不要写「一个 15 秒的长镜头」「持续 20 秒的连续动作」—— 生成不出来
|
||||
- ✅ 拆分后在每个镜头里写清它负责哪个阶段,让观众自然接上
|
||||
|
||||
【镜头描述纪律(★ 来自专业导演方法论,直接决定生成质量)】
|
||||
|
||||
**① 禁止「形容词堆砌」** —— 这是外行与专业的分水岭:
|
||||
❌ 「电影感、戏剧性、大师作品、8K、唯美、氛围感」
|
||||
这些词不告诉模型:机位在哪、身体做什么、动作在哪结束、什么不能变
|
||||
✅ 用**可观察的物理描述**替代:机位 / 景别 / 身体具体动作 / 环境反应 / 收尾状态
|
||||
|
||||
**② 一个镜头 = 一个主导运动**(或刻意静止)
|
||||
❌ 「镜头旋转、变焦、平移、跟拍、晃动」
|
||||
✅ 「固定机位,极缓慢地向前推;除此之外没有其他运动」
|
||||
|
||||
**③ 每个动作必须有「起止状态」**(最重要、我们过去最缺):
|
||||
✅ 「他单膝跪地开始 → 缓缓抬头到湿发滑落露出眼睛 → 结束时半起身、重心在前膝、头抬起」
|
||||
(只说"他挣扎着"没有任何起止 = 模型自由发挥 = 不可控)
|
||||
|
||||
**④ 加**一个**环境反应**(让画面"活"起来):
|
||||
✅ 「雨点不断打破水洼表面;远处闪电短暂照亮街道尽头」
|
||||
|
||||
**⑤ 不变量逐字重复**(跨镜头锁定):
|
||||
同一场戏里,人物的脸 / 发型 / 服装 / 场地 / 光线,在每个镜头提示词里
|
||||
**用同样的词原样重复** —— 不要换说法("齐肩直发" 每次都必须写"齐肩直发")
|
||||
|
||||
**⑥ 负面词只列本镜头**真实风险**,且必须是**具体实例**:
|
||||
❌ 「不要现代物品」(模型无法解析"类别")
|
||||
✅ 「不要手表、不要印刷 logo、不要橡胶鞋底」
|
||||
|
||||
**⑦ 竖屏(9:16)不是横屏裁切**:
|
||||
· 用**纵深**调度(前后景分层),而不是横向铺开
|
||||
· **主体放大**(竖屏里人物占比要更大)
|
||||
· **每个全景镜头都要重新设计**(横屏的构图逻辑在竖屏里不成立)
|
||||
|
||||
**⑧ 画面必须提供「配音之外的信息」**:
|
||||
如果画面只是把台词演一遍 = 观众没有看的理由。画面要承载:状态变化 / 关系 / 潜台词。
|
||||
|
||||
【台词口语化纪律(★重要,直接决定"像不像真人说话")】
|
||||
真实的人开口**不是写作文**。台词必须像真人说话,而不是念稿:
|
||||
|
||||
❌ 书面:「我住院第三天了,真没想象中那么可怕。」
|
||||
✅ 口语:「住院第三天了……说实话,没想象中那么可怕。」
|
||||
|
||||
**七条要求**:
|
||||
1. **口语词**:用「嗯、啊、就、那个、说实话、其实、反正、怎么样」这类真实语气词
|
||||
2. **停顿与迟疑**:用「……」表示停顿,用「(停顿)」「(轻声)」标注语气
|
||||
3. **允许不完美**:可以重复(「我、我是说」)、改口、没说完(被打断)
|
||||
4. **短句为主**:一句尽量 ≤15 字;长句要拆开,像真人一口气说不完
|
||||
5. **禁书面词**:不写「因此 / 然而 / 致使 / 综上所述 / 未免」
|
||||
6. **贴合人物**:老人、孩子、医生、病人的说话方式不同(用词、语速感要区分)
|
||||
7. **一句一个意思**:一句话不做两件事(先说完这个,再接着说下个)
|
||||
|
||||
**额外**:如果这个镜头里有人物开口,必须在画面描述里同时写**说话时的身体状态**
|
||||
(呼吸急促 / 嘴角抽动 / 眼神游移 / 手不自觉地攥紧),台词和身体不能分开设计。
|
||||
|
||||
【画面描述的纪律(★重要,直接影响生成质量)】
|
||||
"visual_description" 只写**镜头里能看到什么**,严禁混入制作意图:
|
||||
❌ 禁止:「方便出结尾字幕」「保持2秒让字幕显示」「便于后期剪辑」「留白给标题」「方便加标签」
|
||||
✅ 只写:人物动作 / 表情 / 镜头运动 / 环境细节
|
||||
(字幕、时长、后期、剪辑都属于制作层,不是画面内容 —— 写进提示词会污染生成)
|
||||
|
||||
【非语言发声必须标 sfx(★重要)】
|
||||
人物发出的**非语言声音**要写进 sfx,**不要塞进台词 text**:
|
||||
· 惨叫 / 闷哼 / 痛呼 / 倒吸一口气 / 叹气 / 干笑 / 清嗓 / 吞咽 / 哽咽
|
||||
写法:{{"at_second": 1.5, "desc": "痛苦的闷哼"}}
|
||||
(text 只放"说出来的话";发声类走 sfx —— 后期能配真实音效,比 TTS 念字自然得多)
|
||||
例:换药镜头 → text 可以是空,sfx 写 [{{"at_second": 0.8, "desc": "倒吸一口气"}}, {{"at_second": 2.0, "desc": "痛苦的闷哼"}}]
|
||||
|
||||
【空间与走位纪律(★重要,直接决定画面是否"看得懂")】
|
||||
每个镜头必须说清「**人在空间的哪儿、朝哪、怎么动**」——三者缺一,观众就看不懂空间关系:
|
||||
- **位置**:人物处在场景的哪个锚点(如:病床右侧 / 窗前 / 门口 / 走廊中段)
|
||||
- **朝向**:面朝哪个方向(如:面向窗户 / 背对镜头 / 侧向病床 / 半侧身朝门口)
|
||||
- **移动**:这一步是静止不动 / 小幅位移 / 转身;**并写清方位变化**
|
||||
(如:从床边向窗户走两步 / 缓缓转身背对镜头)
|
||||
|
||||
对照写法:
|
||||
❌ 「他在病房里说话」(观众不知道人和空间的关系 → 换机位就乱)
|
||||
✅ 「他坐在病床右侧,面朝窗户,说到一半慢慢站起来转向门口」
|
||||
|
||||
⚠️ **单镜头内不要写长距离移动**(走 5 米这种,模型画不出来,会穿模/瞬移):
|
||||
· 3 步以内的小幅位移 → 可以(如起身、上前一步、转身)
|
||||
· 超过 3 步 / 跨越整个空间 → **必须拆成多个镜头**:
|
||||
镜1 在床边起身(中景)→ 镜2 走向窗户(全景,能看出距离)→ 镜3 站在窗前(近景)
|
||||
且**每个镜头用对应机位的场景参考图**,空间才连贯。
|
||||
|
||||
【拍摄风格与镜头视角纪律(★重要)】
|
||||
拍摄风格决定"谁在看",镜头语言必须与之一致,不要写错视角:
|
||||
- 📱 **手机自拍·第一人称(selfie_pov)**:镜头 = 人物自己举着的手机。
|
||||
· 画面里是"他自己"(近脸、一臂距离、轻微广角畸变)
|
||||
· 允许"看镜头说话"(对镜自述)
|
||||
· ❌ 不要写第三人称观察视角(如"镜头从侧面拍他")
|
||||
· ⚠️ **全程统一**:包括开场/过场镜,也只能是"人物自己的视角"
|
||||
(如:举着手机照路 / 低头看自己脚下的鞋 / 手机屏幕反射中的自己),
|
||||
不要出现"从身后跟拍脚步"这类旁人视角
|
||||
- 🚶 **手持 Vlog·第一视角(vlog_handheld)**:镜头 = 拍摄者的眼睛。
|
||||
· 画面里是"他看到的"(前方、手中物、同行的人)
|
||||
· 拍摄者本人通常不入画,或只有手/手臂入画
|
||||
- 📺 **访谈纪实(interview_doc)**:机位固定,人物半身对镜说话,可有停顿与思考
|
||||
- 🎬 **电影感 / 伪纪录片**:第三人称观察视角,可用正反打 / 过肩镜头
|
||||
|
||||
{style_pack}
|
||||
{device_pack}
|
||||
{realism_discipline}
|
||||
{realism}
|
||||
{director_style}
|
||||
|
||||
【多角色一致性纪律(★重要,直接影响成片质量)】
|
||||
- **同框限制**:图生视频只能喂「一张」角色参考图 → 多角色同框的近景/特写会让次要角色人脸随机漂移。
|
||||
因此:
|
||||
· 避免「两个以上角色同框且都要清晰露脸(近景/特写)」
|
||||
· 双人戏优先用:**正反打**(单人镜头交替)· **过肩镜头**(一方背对或只露侧影)·
|
||||
**中远景**(脸小、容错高)· **背影 / 手部 / 局部**
|
||||
· 确需同框时:让"主角"占据画面视觉中心,次要角色可侧身、偏离焦平面或虚化处理
|
||||
- **出场标注**:每个镜头用 `cast_in_shot` 精确标注**实际出场**的人物代号(没出场不要写)。
|
||||
单人镜头只写一个人 —— 这是生成时绑定正确参考图(人脸一致)的前提。
|
||||
|
||||
【拆条纪律】
|
||||
1. 先分轨(叙事轨要转 / 表演轨要成立 / 展示轨要让观众看懂一件事),再拆条。
|
||||
2. **条一个目的**:一次生成一件事。一条里要用「并且」才说得全 = 该切条。
|
||||
3. **镜头必须干活**(改情绪/推进动作/加压),否则删;运镜必须回答「什么变了」。
|
||||
4. 时长按内容与气口:台词按中文字数(约 4.5 字/秒)估算,情绪停顿/呼吸/凝视要留时间;
|
||||
单镜 2~10 秒;超过 10 秒的内容 → 切成两条,前一条 transition_out 与后一条 chain_from_prev 呼应。
|
||||
|
||||
【输出格式】严格输出 JSON 数组,不要任何解释:
|
||||
[
|
||||
{{
|
||||
"duration": 4,
|
||||
"type": "action | talking_head | background | vfx",
|
||||
"cast_in_shot": ["主角"],
|
||||
"visual_description": "这一条观众看见什么(具体到可拍)",
|
||||
"shot_size": "景别", "angle": "机位角度", "camera_move": "运动(须回答什么变了)",
|
||||
"depth_of_field": "浅/中/深", "lens_feel": "光学质感", "frame_rate_feel": "常规/升格/降格",
|
||||
"text": "台词(逐字保留,不改写不润色;无则空)",
|
||||
"sentiment": "情绪",
|
||||
"expression_detail": {{"eyes": "眼神", "mouth": "嘴部", "brow": "眉宇"}},
|
||||
"action": "可见行为(不写情绪形容词)",
|
||||
"audio_cue": "环境音(有因果)",
|
||||
"sfx": [{{"at_second": 1.2, "desc": "音效"}}],
|
||||
"transition_in": {{"type": "cut", "duration": 0}},
|
||||
"transition_out": {{"type": "cut", "duration": 0}},
|
||||
"speed": {{"factor": 1.0, "ramp": ""}},
|
||||
"tech_requirements": {{
|
||||
"continuity": {{"character": "must", "wardrobe": "must", "location": "must", "expression": "prefer", "props": "none"}},
|
||||
"chain_from_prev": false,
|
||||
"motion_level": "small",
|
||||
"camera_difficulty": "simple",
|
||||
"needs": []
|
||||
}}
|
||||
}}
|
||||
]
|
||||
|
||||
产出前自检(五类病):矛盾 / 断链 / 走样 / 模型雷 / 红线。
|
||||
前四类自己改;模型雷与红线写进对应镜头的 tech_requirements.needs,不要硬写做不到的画面。
|
||||
"""
|
||||
|
||||
_REVISE_TMPL = """按 Seedance 导演方法论重生成这一个镜头(保持整片统一,镜头必须干活)。
|
||||
【整片故事】{story}
|
||||
【当前镜头(第 {idx} 个)】{current}
|
||||
【修改要求】{instruction}
|
||||
【要求】只改这一个镜头,输出单个 JSON 对象(字段同上)。
|
||||
"""
|
||||
|
||||
|
||||
def skill_available() -> tuple[bool, str]:
|
||||
for p in SKILL_PATHS:
|
||||
if p.exists() and p.stat().st_size > 500:
|
||||
return True, str(p)
|
||||
return False, "技能文档未就位(需 09_ave/skills/seedance-director/SKILL.md)"
|
||||
|
||||
|
||||
def load_skill_doc() -> str:
|
||||
ok, path = skill_available()
|
||||
if not ok:
|
||||
return ""
|
||||
try:
|
||||
return Path(path).read_text(encoding="utf-8")[:_SKILL_MAX_CHARS]
|
||||
except Exception as e:
|
||||
log.warning(" ⚠️ Seedance 技能文档读取失败: %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
class SeedanceDirector(BaseDirector):
|
||||
"""Seedance 导演方法论(分轨 / 条一个目的 / 五类病自检)"""
|
||||
|
||||
id = "seedance_director"
|
||||
name = "Seedance 导演方法论"
|
||||
kind = "skill"
|
||||
description = "Seedance 方法论移植:分轨拆条、时长跟内容与气口走、镜头必须干活、五类病自检"
|
||||
features = ["storyboard", "track_split", "shot_contract", "self_check", "tech_requirements"]
|
||||
status = "ready"
|
||||
source = "Seedance-Director-4.0(SCAI)· 精编移植(去人设)"
|
||||
options_schema = dict(COMMON_OPTIONS)
|
||||
|
||||
def __init__(self):
|
||||
self.llm = DirectorLLM(timeout=120)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
ok, _ = skill_available()
|
||||
return ok and self.llm.available
|
||||
|
||||
def generate(self, story: str, anchors: GlobalAnchors | None = None,
|
||||
options: dict | None = None) -> Storyboard:
|
||||
opts = options or {}
|
||||
anchors = anchors or GlobalAnchors()
|
||||
skill = load_skill_doc()
|
||||
if not skill:
|
||||
raise RuntimeError("Seedance 技能文档未就位,无法生成")
|
||||
|
||||
prompt = _PROMPT_TMPL.format(
|
||||
shot_brief=shot_brief(opts),
|
||||
story=(story or "").strip()[:2000],
|
||||
shooting_style=opts.get("shooting_style") or "documentary_realism",
|
||||
character=cast_or_character(anchors),
|
||||
style_pack=style_pack_block(anchors),
|
||||
device_pack=device_pack_hint(anchors),
|
||||
realism_discipline=realism_discipline(),
|
||||
realism=realism_block(),
|
||||
director_style=director_style_hint(anchors),
|
||||
location=anchors.location_context or anchors.location_id or "(未指定)",
|
||||
wardrobe=anchors.wardrobe or "(未指定)",
|
||||
props=anchors.props_context or "(无)",
|
||||
)
|
||||
raw = self.llm.chat(prompt, system=skill, temperature=0.7, max_tokens=3500)
|
||||
items = self.llm.parse_json(raw)
|
||||
if isinstance(items, dict):
|
||||
items = items.get("shots") or items.get("data") or items.get("storyboard") or []
|
||||
if not isinstance(items, list) or not items:
|
||||
log.warning(" ⚠️ Seedance 导演未产出有效分镜")
|
||||
return Storyboard(project_id=_pid(), story=story, director_id=self.id,
|
||||
global_anchors=anchors, shots=[])
|
||||
|
||||
shots = build_shots_from_items(items, anchors)
|
||||
durations = [s.duration for s in shots]
|
||||
if len(set(durations)) <= 1 and len(shots) > 1:
|
||||
log.warning(" ⚠️ Seedance 产出镜头时长全部相同(%ss) — 未按气口差异化", durations[:1])
|
||||
|
||||
sb = Storyboard(
|
||||
project_id=_pid(), title=(story or "")[:40], story=story,
|
||||
content_type=opts.get("content_type") or "short_drama",
|
||||
director_id=self.id, global_anchors=anchors, shots=shots,
|
||||
)
|
||||
log.info(" 🎬 Seedance 导演产出 %d 个镜头(%.1f 秒)", len(shots), sb.total_duration())
|
||||
return sb
|
||||
|
||||
def revise_shot(self, sb: Storyboard, shot_id: str,
|
||||
instruction: str = "") -> Storyboard:
|
||||
target = sb.shot_by_id(shot_id)
|
||||
if not target:
|
||||
raise ValueError(f"镜头不存在: {shot_id}")
|
||||
skill = load_skill_doc()
|
||||
import json as _json
|
||||
prompt = _REVISE_TMPL.format(
|
||||
story=(sb.story or "")[:800],
|
||||
idx=sb.shots.index(target) + 1,
|
||||
current=_json.dumps({
|
||||
"visual_description": target.visual_description,
|
||||
"shot_size": target.shot_contract.shot_size,
|
||||
"camera_move": target.shot_contract.camera_move,
|
||||
"text": target.text, "sentiment": target.sentiment,
|
||||
"tech_requirements": target.tech_requirements,
|
||||
}, ensure_ascii=False, indent=1),
|
||||
instruction=instruction or "更有导演感,镜头必须干活",
|
||||
)
|
||||
raw = self.llm.chat(prompt, system=skill[:2500] if skill else "", temperature=0.8, max_tokens=900)
|
||||
it = self.llm.parse_json(raw)
|
||||
if isinstance(it, list) and it:
|
||||
it = it[0]
|
||||
if not isinstance(it, dict):
|
||||
raise RuntimeError("重生成失败:LLM 未返回有效结果")
|
||||
|
||||
# 复用公共构建器解析单条
|
||||
one = build_shots_from_items([it], sb.global_anchors)
|
||||
if one:
|
||||
n = one[0]
|
||||
target.duration = n.duration
|
||||
target.type = n.type
|
||||
target.visual_description = n.visual_description
|
||||
target.shot_contract = n.shot_contract
|
||||
target.text = n.text
|
||||
target.sentiment = n.sentiment
|
||||
target.expression_detail = n.expression_detail
|
||||
target.action = n.action
|
||||
target.audio = n.audio
|
||||
target.tech_requirements = n.tech_requirements
|
||||
return sb
|
||||
|
||||
|
||||
def _pid() -> str:
|
||||
from datetime import datetime
|
||||
return "vid_" + datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
directors.storyboard_v52 — AI Storyboard Director v5.2(技能型导演)
|
||||
=====================================================================
|
||||
|
||||
来源:ai-film-skills(Apache-2.0)
|
||||
`skills/ai-film-skills/docs/skills/zh-CN/ai-storyboard-director.md`
|
||||
|
||||
v5.2 的核心特点(相对本地导演):
|
||||
① **先锁视觉概念,再拆镜头**(两遍交付:设计稿 → 生产稿)
|
||||
② 镜头契约更专业:景别 / 机位 / 光学 / 运动 / 声音 / 切点
|
||||
③ 强调"可执行":每个镜头能直接交给生成模型
|
||||
|
||||
实现方式:把技能文档作为**系统提示词**注入,让 LLM 按 v5.2 方法论产出分镜,
|
||||
再转换为统一分镜 Schema(下游无感知)。
|
||||
|
||||
技能文档缺失时本导演自动标记为「不可用」,前端下拉会置灰。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SCRIPTS = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
|
||||
from schemas import (GlobalAnchors, Shot, ShotContract, Storyboard, # noqa: E402
|
||||
Transition, Speed, AudioConfig, SFX, ExpressionDetail)
|
||||
from directors.base import COMMON_OPTIONS, BaseDirector, norm_duration, cast_or_character, style_pack_block, realism_block, director_style_hint, device_pack_hint, realism_discipline, shot_brief # noqa: E402
|
||||
from directors._shot_builder import (build_shots_from_items, # noqa: E402
|
||||
norm_type as _norm_type)
|
||||
from directors.llm import DirectorLLM # noqa: E402
|
||||
|
||||
|
||||
def _sc_val(v) -> int:
|
||||
"""镜头数:0 / 空 = 自动(交给导演按叙事需要决定)"""
|
||||
try:
|
||||
return int(v or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
log = logging.getLogger("directors.v52")
|
||||
|
||||
# 技能文档位置(ai-film-skills 下载到 09_ave/skills/)
|
||||
_AVE_DIR = _SCRIPTS.parent
|
||||
SKILL_PATHS = [
|
||||
_AVE_DIR / "skills" / "ai-film-skills" / "docs" / "skills" / "zh-CN" / "ai-storyboard-director.md",
|
||||
_AVE_DIR / "skills" / "ai-film-skills" / "docs" / "skills" / "ai-storyboard-director.md",
|
||||
]
|
||||
|
||||
# 技能文档注入上限(避免 prompt 过长)
|
||||
_SKILL_MAX_CHARS = 6000
|
||||
|
||||
_PROMPT_TMPL = """请按「导演级分镜」标准,把下面的故事拍成一支短片。
|
||||
{shot_brief}
|
||||
|
||||
【故事】
|
||||
{story}
|
||||
|
||||
【整体设定】
|
||||
- 拍摄风格:{shooting_style}
|
||||
- {shot_brief}
|
||||
|
||||
★【时长规则(必须遵守,按内容算)】
|
||||
· 短对白(≤12字) / 快切反应 / 插入细节 → 2~3 秒
|
||||
· 中长对白(13~30字) → 4~6 秒(按台词字数估算:中文约 4~5 字/秒)
|
||||
· 动作 / 走位调度 → 3~5 秒
|
||||
· 情绪停顿 / 凝视 / 环境空镜 → 3~5 秒
|
||||
· 需要连续表演的长镜头 → 6~10 秒(同时把 tech_requirements.chain_from_prev 设为 true)
|
||||
· 硬约束:时长必须能容纳台词(台词字数 ÷ 4.5 = 最少秒数);单镜不超过 10 秒
|
||||
|
||||
【资产设定(★必须严格使用这些设定,不要自己另编场景/人物;同一资产在所有镜头中保持一致)】
|
||||
- 角色:{character}
|
||||
- 场地:{location}
|
||||
- 服装:{wardrobe}
|
||||
- 产品 / 道具:{props}
|
||||
|
||||
【场景真实感纪律(★重要,直接影响"像不像真拍的")】
|
||||
**禁止影棚语言**——生活场景里不该出现专业影棚的词汇,写了画面立刻变假:
|
||||
- ❌ 禁止:柔光箱 / 布光("柔光箱均匀布光""侧面打光")/ 纯色背景 / 纯灰背景 /
|
||||
中焦 85mm / 压缩空间 / 影棚 / 摄影棚 / 反光板 / 灯架
|
||||
- ✅ 只用**场景里真实存在的光源**,并说清它从哪来:
|
||||
窗户自然光 / 室内顶灯 / 床头灯 / 台灯 / 手机屏幕的光 / 走廊灯 / 电视光
|
||||
- ❌ 禁止把背景写成"纯色/虚化背景"——真实房间的背景就是**杂乱的真实物件**
|
||||
(输液架、床头柜、水杯、被子褶皱、墙上的挂钩)
|
||||
|
||||
【拍摄者的动作逻辑(★自拍/Vlog 必检)】
|
||||
画面里"手机是谁拿的"必须说得通,否则一眼假:
|
||||
- **人物自己举着**:那他的**双手被占用**,不能再做"抓床单/换药/双手撑床"这类动作
|
||||
- **他人帮拍**(护士/家属/路人):必须写明"他人在拍",并允许这些真实特征:
|
||||
画面晃动 / 对焦慢半拍 / 构图歪 / 短暂拍到地面或天花板 / 拍摄者在画面外说话
|
||||
- **手机放置**(立在床头柜、靠着水杯、半躺在床上):允许角度歪、中途滑落调整、
|
||||
只拍到半个身子
|
||||
- ⚠️ **手被占住的场景**(换药、剧痛抓床单、双手抱头)**不能**写成"自己举着手机自拍"
|
||||
|
||||
【慌乱/失控场景的真实特征(★疼、急、乱的时候必带)】
|
||||
真实的手持拍摄在慌乱中一定会露出这些"瑕疵",它们才是真实感的来源:
|
||||
- 镜头晃动加剧、跟不住主体
|
||||
- 焦点来回漂移、短暂失焦
|
||||
- 构图来不及调整(人物时而偏出画面)
|
||||
- 呼吸声/喘气声进麦克风
|
||||
- 镜头短暂甩到别处(墙、天花板、被子)再拉回来
|
||||
|
||||
【镜头时长纪律(★硬约束)】
|
||||
生成模型单镜头**最长 10 秒**(可灵只有 5 秒 / 10 秒两档),这是硬限制。因此:
|
||||
- 单镜时长写 **2~10 秒**(整数或 .5,按内容与气口定,不要凑数)
|
||||
- 超过 10 秒的连续动作**必须主动拆成多个镜头**,拆法:
|
||||
· **按动作阶段**:起势 → 发力 → 收势(如"慢慢坐下"→"落座瞬间的表情"→"坐定后的长舒一口气")
|
||||
· **按景别递进**:中景交代 → 特写强调 → 反应镜头(观众看不出断点)
|
||||
· **按台词气口**:一句话说完整,下一句换镜头
|
||||
- ❌ 不要写「一个 15 秒的长镜头」「持续 20 秒的连续动作」—— 生成不出来
|
||||
- ✅ 拆分后在每个镜头里写清它负责哪个阶段,让观众自然接上
|
||||
|
||||
【镜头描述纪律(★ 来自专业导演方法论,直接决定生成质量)】
|
||||
|
||||
**① 禁止「形容词堆砌」** —— 这是外行与专业的分水岭:
|
||||
❌ 「电影感、戏剧性、大师作品、8K、唯美、氛围感」
|
||||
这些词不告诉模型:机位在哪、身体做什么、动作在哪结束、什么不能变
|
||||
✅ 用**可观察的物理描述**替代:机位 / 景别 / 身体具体动作 / 环境反应 / 收尾状态
|
||||
|
||||
**② 一个镜头 = 一个主导运动**(或刻意静止)
|
||||
❌ 「镜头旋转、变焦、平移、跟拍、晃动」
|
||||
✅ 「固定机位,极缓慢地向前推;除此之外没有其他运动」
|
||||
|
||||
**③ 每个动作必须有「起止状态」**(最重要、我们过去最缺):
|
||||
✅ 「他单膝跪地开始 → 缓缓抬头到湿发滑落露出眼睛 → 结束时半起身、重心在前膝、头抬起」
|
||||
(只说"他挣扎着"没有任何起止 = 模型自由发挥 = 不可控)
|
||||
|
||||
**④ 加**一个**环境反应**(让画面"活"起来):
|
||||
✅ 「雨点不断打破水洼表面;远处闪电短暂照亮街道尽头」
|
||||
|
||||
**⑤ 不变量逐字重复**(跨镜头锁定):
|
||||
同一场戏里,人物的脸 / 发型 / 服装 / 场地 / 光线,在每个镜头提示词里
|
||||
**用同样的词原样重复** —— 不要换说法("齐肩直发" 每次都必须写"齐肩直发")
|
||||
|
||||
**⑥ 负面词只列本镜头**真实风险**,且必须是**具体实例**:
|
||||
❌ 「不要现代物品」(模型无法解析"类别")
|
||||
✅ 「不要手表、不要印刷 logo、不要橡胶鞋底」
|
||||
|
||||
**⑦ 竖屏(9:16)不是横屏裁切**:
|
||||
· 用**纵深**调度(前后景分层),而不是横向铺开
|
||||
· **主体放大**(竖屏里人物占比要更大)
|
||||
· **每个全景镜头都要重新设计**(横屏的构图逻辑在竖屏里不成立)
|
||||
|
||||
**⑧ 画面必须提供「配音之外的信息」**:
|
||||
如果画面只是把台词演一遍 = 观众没有看的理由。画面要承载:状态变化 / 关系 / 潜台词。
|
||||
|
||||
【台词口语化纪律(★重要,直接决定"像不像真人说话")】
|
||||
真实的人开口**不是写作文**。台词必须像真人说话,而不是念稿:
|
||||
|
||||
❌ 书面:「我住院第三天了,真没想象中那么可怕。」
|
||||
✅ 口语:「住院第三天了……说实话,没想象中那么可怕。」
|
||||
|
||||
**七条要求**:
|
||||
1. **口语词**:用「嗯、啊、就、那个、说实话、其实、反正、怎么样」这类真实语气词
|
||||
2. **停顿与迟疑**:用「……」表示停顿,用「(停顿)」「(轻声)」标注语气
|
||||
3. **允许不完美**:可以重复(「我、我是说」)、改口、没说完(被打断)
|
||||
4. **短句为主**:一句尽量 ≤15 字;长句要拆开,像真人一口气说不完
|
||||
5. **禁书面词**:不写「因此 / 然而 / 致使 / 综上所述 / 未免」
|
||||
6. **贴合人物**:老人、孩子、医生、病人的说话方式不同(用词、语速感要区分)
|
||||
7. **一句一个意思**:一句话不做两件事(先说完这个,再接着说下个)
|
||||
|
||||
**额外**:如果这个镜头里有人物开口,必须在画面描述里同时写**说话时的身体状态**
|
||||
(呼吸急促 / 嘴角抽动 / 眼神游移 / 手不自觉地攥紧),台词和身体不能分开设计。
|
||||
|
||||
【画面描述的纪律(★重要,直接影响生成质量)】
|
||||
"visual_description" 只写**镜头里能看到什么**,严禁混入制作意图:
|
||||
❌ 禁止:「方便出结尾字幕」「保持2秒让字幕显示」「便于后期剪辑」「留白给标题」「方便加标签」
|
||||
✅ 只写:人物动作 / 表情 / 镜头运动 / 环境细节
|
||||
(字幕、时长、后期、剪辑都属于制作层,不是画面内容 —— 写进提示词会污染生成)
|
||||
|
||||
【非语言发声必须标 sfx(★重要)】
|
||||
人物发出的**非语言声音**要写进 sfx,**不要塞进台词 text**:
|
||||
· 惨叫 / 闷哼 / 痛呼 / 倒吸一口气 / 叹气 / 干笑 / 清嗓 / 吞咽 / 哽咽
|
||||
写法:{{"at_second": 1.5, "desc": "痛苦的闷哼"}}
|
||||
(text 只放"说出来的话";发声类走 sfx —— 后期能配真实音效,比 TTS 念字自然得多)
|
||||
例:换药镜头 → text 可以是空,sfx 写 [{{"at_second": 0.8, "desc": "倒吸一口气"}}, {{"at_second": 2.0, "desc": "痛苦的闷哼"}}]
|
||||
|
||||
【空间与走位纪律(★重要,直接决定画面是否"看得懂")】
|
||||
每个镜头必须说清「**人在空间的哪儿、朝哪、怎么动**」——三者缺一,观众就看不懂空间关系:
|
||||
- **位置**:人物处在场景的哪个锚点(如:病床右侧 / 窗前 / 门口 / 走廊中段)
|
||||
- **朝向**:面朝哪个方向(如:面向窗户 / 背对镜头 / 侧向病床 / 半侧身朝门口)
|
||||
- **移动**:这一步是静止不动 / 小幅位移 / 转身;**并写清方位变化**
|
||||
(如:从床边向窗户走两步 / 缓缓转身背对镜头)
|
||||
|
||||
对照写法:
|
||||
❌ 「他在病房里说话」(观众不知道人和空间的关系 → 换机位就乱)
|
||||
✅ 「他坐在病床右侧,面朝窗户,说到一半慢慢站起来转向门口」
|
||||
|
||||
⚠️ **单镜头内不要写长距离移动**(走 5 米这种,模型画不出来,会穿模/瞬移):
|
||||
· 3 步以内的小幅位移 → 可以(如起身、上前一步、转身)
|
||||
· 超过 3 步 / 跨越整个空间 → **必须拆成多个镜头**:
|
||||
镜1 在床边起身(中景)→ 镜2 走向窗户(全景,能看出距离)→ 镜3 站在窗前(近景)
|
||||
且**每个镜头用对应机位的场景参考图**,空间才连贯。
|
||||
|
||||
【拍摄风格与镜头视角纪律(★重要)】
|
||||
拍摄风格决定"谁在看",镜头语言必须与之一致,不要写错视角:
|
||||
- 📱 **手机自拍·第一人称(selfie_pov)**:镜头 = 人物自己举着的手机。
|
||||
· 画面里是"他自己"(近脸、一臂距离、轻微广角畸变)
|
||||
· 允许"看镜头说话"(对镜自述)
|
||||
· ❌ 不要写第三人称观察视角(如"镜头从侧面拍他")
|
||||
· ⚠️ **全程统一**:包括开场/过场镜,也只能是"人物自己的视角"
|
||||
(如:举着手机照路 / 低头看自己脚下的鞋 / 手机屏幕反射中的自己),
|
||||
不要出现"从身后跟拍脚步"这类旁人视角
|
||||
- 🚶 **手持 Vlog·第一视角(vlog_handheld)**:镜头 = 拍摄者的眼睛。
|
||||
· 画面里是"他看到的"(前方、手中物、同行的人)
|
||||
· 拍摄者本人通常不入画,或只有手/手臂入画
|
||||
- 📺 **访谈纪实(interview_doc)**:机位固定,人物半身对镜说话,可有停顿与思考
|
||||
- 🎬 **电影感 / 伪纪录片**:第三人称观察视角,可用正反打 / 过肩镜头
|
||||
|
||||
{style_pack}
|
||||
{device_pack}
|
||||
{realism_discipline}
|
||||
{realism}
|
||||
{director_style}
|
||||
|
||||
【多角色一致性纪律(★重要,直接影响成片质量)】
|
||||
- **同框限制**:图生视频只能喂「一张」角色参考图 → 多角色同框的近景/特写会让次要角色人脸随机漂移。
|
||||
因此:
|
||||
· 避免「两个以上角色同框且都要清晰露脸(近景/特写)」
|
||||
· 双人戏优先用:**正反打**(单人镜头交替)· **过肩镜头**(一方背对或只露侧影)·
|
||||
**中远景**(脸小、容错高)· **背影 / 手部 / 局部**
|
||||
· 确需同框时:让"主角"占据画面视觉中心,次要角色可侧身、偏离焦平面或虚化处理
|
||||
- **出场标注**:每个镜头用 `cast_in_shot` 精确标注**实际出场**的人物代号(没出场不要写)。
|
||||
单人镜头只写一个人 —— 这是生成时绑定正确参考图(人脸一致)的前提。
|
||||
|
||||
【工作方法(先锁概念,再拆镜头)】
|
||||
第一步:先在心里锁定整片的视觉概念(色调、光线基调、镜头语言、质感),保证所有镜头统一。
|
||||
第二步:再按叙事顺序拆镜头,每个镜头要写清:
|
||||
· 画面描述:谁在哪做什么、环境里有什么、光线方向与质感(要具体到可拍)
|
||||
· 镜头契约:景别(特写/近景/中景/全景/远景)、机位角度(平视/俯视/仰视/过肩)、
|
||||
摄影机运动(固定/推/拉/摇/跟/环绕)、景深(浅/中/深)、光学质感、帧率感(常规/升格/降格)
|
||||
· 表演与情绪:情绪 + **微表情**(眼神/嘴部/眉宇各写一句)+ 人物动作
|
||||
· 台词/旁白:像真人口语(没有就空)
|
||||
· 声音设计:环境音提示(audio_cue);需要强调的音效点(sfx,写清第几秒+什么声音)
|
||||
· 镜头衔接:与前后镜的转场(cut 硬切 / dissolve 溶接 / fade 淡变)
|
||||
· **技术需求声明 tech_requirements**(★重要):只声明"这个镜头需要什么技术条件",不要指定用什么模型/工具:
|
||||
- continuity:角色/服装/场景/表情/道具 各自要求(must 必须一致 / prefer 尽量 / none 不要求)
|
||||
- chain_from_prev:是否要求画面承接上一镜的最后一帧(长镜头接续/无缝延续 → true)
|
||||
- motion_level:动作幅度(static 静止 / small 小幅 / medium 中等 / large 大幅)
|
||||
- camera_difficulty:运镜难度(fixed 固定 / simple 推拉摇 / complex 环绕跟拍等复杂调度)
|
||||
- needs:其它特殊需求(自由文本,如"需要子弹时间特效""需要手部特写")
|
||||
|
||||
第三步:检查镜头之间是否连贯(同一场景/人物/风格),有无跳轴或突兀切换。
|
||||
|
||||
【输出格式】严格输出 JSON 数组,不要任何解释:
|
||||
[
|
||||
{{
|
||||
"duration": 4,
|
||||
"type": "action | talking_head | background | vfx",
|
||||
"cast_in_shot": ["主角"],
|
||||
"visual_description": "画面描述(具体到可拍)",
|
||||
"shot_size": "景别", "angle": "机位角度", "camera_move": "摄影机运动",
|
||||
"depth_of_field": "浅/中/深", "lens_feel": "光学质感", "frame_rate_feel": "常规/升格/降格",
|
||||
"text": "台词或旁白(无则空字符串)",
|
||||
"sentiment": "情绪",
|
||||
"expression_detail": {{"eyes": "眼神", "mouth": "嘴部", "brow": "眉宇"}},
|
||||
"action": "人物动作",
|
||||
"audio_cue": "环境音提示(无则空字符串)",
|
||||
"sfx": [{{"at_second": 1.2, "desc": "关门声"}}],
|
||||
"transition_in": {{"type": "cut", "duration": 0}},
|
||||
"transition_out": {{"type": "cut", "duration": 0}},
|
||||
"speed": {{"factor": 1.0, "ramp": ""}},
|
||||
"tech_requirements": {{
|
||||
"continuity": {{"character": "must", "wardrobe": "must", "location": "must", "expression": "prefer", "props": "none"}},
|
||||
"chain_from_prev": false,
|
||||
"motion_level": "small",
|
||||
"camera_difficulty": "simple",
|
||||
"needs": []
|
||||
}}
|
||||
}}
|
||||
]
|
||||
"""
|
||||
|
||||
_REVISE_TMPL = """按导演级标准重新生成这一个镜头(保持整片视觉概念统一)。
|
||||
|
||||
【整片故事】{story}
|
||||
|
||||
【当前镜头(第 {idx} 个)】
|
||||
{current}
|
||||
|
||||
【修改要求】{instruction}
|
||||
|
||||
【要求】只改这一个镜头,输出单个 JSON 对象(字段同上)。
|
||||
"""
|
||||
|
||||
|
||||
def skill_available() -> tuple[bool, str]:
|
||||
"""技能文档是否就位 → (可用, 路径或原因)"""
|
||||
for p in SKILL_PATHS:
|
||||
if p.exists() and p.stat().st_size > 500:
|
||||
return True, str(p)
|
||||
return False, "技能文档未下载(需 ai-film-skills/docs/skills/zh-CN/ai-storyboard-director.md)"
|
||||
|
||||
|
||||
def load_skill_doc() -> str:
|
||||
ok, path = skill_available()
|
||||
if not ok:
|
||||
return ""
|
||||
try:
|
||||
txt = Path(path).read_text(encoding="utf-8")
|
||||
return txt[:_SKILL_MAX_CHARS]
|
||||
except Exception as e:
|
||||
log.warning(" ⚠️ 技能文档读取失败: %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
class StoryboardV52(BaseDirector):
|
||||
"""AI Storyboard Director v5.2(技能型导演)"""
|
||||
|
||||
id = "storyboard_v52"
|
||||
name = "AI Storyboard Director v5.2"
|
||||
kind = "skill"
|
||||
description = "导演级分镜:先锁视觉概念再拆镜头;镜头契约含景别/机位/光学/运动"
|
||||
features = ["storyboard", "visual_concept", "shot_contract", "revise_shot"]
|
||||
status = "ready"
|
||||
source = "ai-film-skills(Apache-2.0)· docs/skills/zh-CN/ai-storyboard-director.md"
|
||||
options_schema = dict(COMMON_OPTIONS)
|
||||
|
||||
def __init__(self):
|
||||
self.llm = DirectorLLM(timeout=120)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
ok, _ = skill_available()
|
||||
return ok and self.llm.available
|
||||
|
||||
# ── 生成 ─────────────────────────────────────────
|
||||
|
||||
def generate(self, story: str, anchors: GlobalAnchors | None = None,
|
||||
options: dict | None = None) -> Storyboard:
|
||||
opts = options or {}
|
||||
anchors = anchors or GlobalAnchors()
|
||||
skill = load_skill_doc()
|
||||
if not skill:
|
||||
raise RuntimeError("v5.2 技能文档未就位,无法生成")
|
||||
|
||||
prompt = _PROMPT_TMPL.format(
|
||||
shot_brief=shot_brief(opts),
|
||||
story=(story or "").strip()[:2000],
|
||||
shooting_style=opts.get("shooting_style") or "documentary_realism",
|
||||
# ★ 资产注入:有详情用详情,否则退回 ID(让导演能写出"具体"的场景)
|
||||
character=cast_or_character(anchors),
|
||||
style_pack=style_pack_block(anchors),
|
||||
device_pack=device_pack_hint(anchors),
|
||||
realism_discipline=realism_discipline(),
|
||||
realism=realism_block(),
|
||||
director_style=director_style_hint(anchors),
|
||||
location=anchors.location_context or anchors.location_id or "(未指定)",
|
||||
wardrobe=anchors.wardrobe or "(未指定)",
|
||||
props=anchors.props_context or "(无)",
|
||||
)
|
||||
raw = self.llm.chat(prompt, system=skill, temperature=0.7, max_tokens=3500)
|
||||
items = self.llm.parse_json(raw)
|
||||
if isinstance(items, dict):
|
||||
items = items.get("shots") or items.get("data") or items.get("storyboard") or []
|
||||
if not isinstance(items, list) or not items:
|
||||
log.warning(" ⚠️ v5.2 未产出有效分镜")
|
||||
return Storyboard(project_id=_pid(), story=story, director_id=self.id,
|
||||
global_anchors=anchors, shots=[])
|
||||
|
||||
shots = build_shots_from_items(items, anchors)
|
||||
durations = [s.duration for s in shots]
|
||||
if len(set(durations)) <= 1 and len(shots) > 1:
|
||||
log.warning(" ⚠️ 导演产出的镜头时长全部相同(%ss) — 可能未按内容差异化", durations[:1])
|
||||
|
||||
sb = Storyboard(
|
||||
project_id=_pid(), title=(story or "")[:40], story=story,
|
||||
content_type=opts.get("content_type") or "short_drama",
|
||||
director_id=self.id, global_anchors=anchors, shots=shots,
|
||||
)
|
||||
log.info(" 🎬 v5.2 产出 %d 个镜头(%.1f 秒)", len(shots), sb.total_duration())
|
||||
return sb
|
||||
|
||||
# ── 单镜头重生成 ──────────────────────────────────
|
||||
|
||||
def revise_shot(self, sb: Storyboard, shot_id: str,
|
||||
instruction: str = "") -> Storyboard:
|
||||
target = sb.shot_by_id(shot_id)
|
||||
if not target:
|
||||
raise ValueError(f"镜头不存在: {shot_id}")
|
||||
skill = load_skill_doc()
|
||||
import json as _json
|
||||
prompt = _REVISE_TMPL.format(
|
||||
story=(sb.story or "")[:800],
|
||||
idx=sb.shots.index(target) + 1,
|
||||
current=_json.dumps({
|
||||
"visual_description": target.visual_description,
|
||||
"shot_size": target.shot_contract.shot_size,
|
||||
"angle": target.shot_contract.angle,
|
||||
"camera_move": target.shot_contract.camera_move,
|
||||
"text": target.text, "sentiment": target.sentiment,
|
||||
}, ensure_ascii=False, indent=1),
|
||||
instruction=instruction or "更有真实感与导演感",
|
||||
)
|
||||
raw = self.llm.chat(prompt, system=skill[:2000] if skill else "", temperature=0.8, max_tokens=900)
|
||||
it = self.llm.parse_json(raw)
|
||||
if isinstance(it, list) and it:
|
||||
it = it[0]
|
||||
if not isinstance(it, dict):
|
||||
raise RuntimeError("重生成失败:LLM 未返回有效结果")
|
||||
|
||||
target.duration = float(it.get("duration") or target.duration)
|
||||
target.type = _norm_type(it.get("type") or target.type)
|
||||
target.visual_description = (it.get("visual_description") or target.visual_description).strip()
|
||||
target.shot_contract = ShotContract(
|
||||
shot_size=(it.get("shot_size") or "").strip(),
|
||||
angle=(it.get("angle") or "").strip(),
|
||||
camera_move=(it.get("camera_move") or "").strip(),
|
||||
lens_feel=(it.get("lens_feel") or "").strip(),
|
||||
depth_of_field=(it.get("depth_of_field") or "").strip(),
|
||||
)
|
||||
target.text = (it.get("text") or "").strip() or None
|
||||
target.sentiment = (it.get("sentiment") or "").strip() or None
|
||||
target.action = (it.get("action") or "").strip() or None
|
||||
return sb
|
||||
|
||||
|
||||
def _pid() -> str:
|
||||
from datetime import datetime
|
||||
return "vid_" + datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
Reference in New Issue
Block a user