feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 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:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -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")