移植自 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 带网关原话 / 正常返回)。
305 lines
14 KiB
Python
305 lines
14 KiB
Python
"""
|
||
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. **不要写慢动作**(除非明确设定为手机慢动作模式)——真实拍摄默认实时速度。"
|
||
)
|