Files
MediaCrawler/mac-agent-os-main/05_tools/09_ave/scripts/schemas.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
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 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

419 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
schemas — 视频工厂统一分镜 Schema(标准接口)
================================================
**所有模块的"普通话"**:任何导演系统输出它、分镜编辑器编辑它、执行器消费它。
换了导演系统 / 能力供应商 / 模型,下游全不用改 —— 这是解耦的中枢。
来源:DCS 说明书 v2.0 第四节(导演创作系统统一分镜 Schema)
用法:
from schemas import Storyboard, Shot, GlobalAnchors
sb = Storyboard(project_id="vid_001", global_anchors=GlobalAnchors(...), shots=[...])
sb.to_dict() # → JSON 可传输
Storyboard.from_dict(data) # ← 从 JSON 还原
sb.save("path.json") / Storyboard.load("path.json")
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field
# ═══════════════════════════════════════════════════════════
# 全局锚点(跨镜头一致性)
# ═══════════════════════════════════════════════════════════
class CastMember(BaseModel):
"""演员表成员:**戏内代号 + 绑定的角色库资产**
设计(用户拍板):
· `slot` 戏内代号(主角 / 配角 / 人物一…)—— 分镜里用它指代"谁出场"
· `asset_id` / `name` 指向角色库资产 —— 实际生成时取参考图(保人脸一致)
· `note` 补充要求(年龄/外貌)—— 资产未绑定或不够贴合时,给导演的提示
两者兼备:代号管"戏里是谁",资产管"长相是谁"。
"""
slot: str = "" # 戏内代号
asset_id: str = "" # 角色库资产 id(可为空 = 暂未绑定)
name: str = "" # 资产名(展示用)
note: str = "" # 补充要求(年龄/外貌/气质)
voice_id: str = "" # 配音音色(CosyVoice voice 参数,如 longanyang)
# 空 = 按代号从路人音色池稳定分配(同一角色全片同音色)
class GlobalAnchors(BaseModel):
"""全局锚点:整个片子的统一设定(角色/场地/产品/光线/镜头语言)
★ **资产注入机制**(即"提前喂给导演"):
`*_id` 指向资产库;`*_context` 是该资产的**详情文本**(名称 + 描述 + 特征字段),
由接口在生成分镜**之前**从资产库查出并填入
→ 导演系统据此写出**具体的**画面描述,而不是只拿到一个 ID 靠猜。
★ **演员表(cast)**:短片常有多个人物。`cast` 声明全片的角色(代号 + 资产),
分镜用 `Shot.cast_slots` 标注"这一镜谁出场"。逐镜头指定优先于全局。
"""
character_id: str = "" # ← 保留(单角色场景 / 向后兼容)
character_3d_id: Optional[str] = None
location_id: str = "" # 主场地(短片≈一场戏,全片默认)
product_id: Optional[str] = None
wardrobe: str = "" # 服装(可写"第1套/第2套"或具体描述)
lighting_state: str = "" # 光线状态
camera_lens: str = "" # 镜头语言
shooting_style: str = "documentary_realism" # 拍摄风格(伪纪录片)
realism_boost: str = "mild" # 反 AI 感强度: off/mild/strong
director_style: str = "" # 导演风格 key(如 wong_kar_wai / nolan),空=不用
consistency_seed: int = 42 # 一致性种子(跨镜头固定)
# ── ★ 演员表(多角色;用户 2026-10-03 要求)──
cast: list[CastMember] = Field(default_factory=list)
# ── 资产详情(由接口从资产库注入,供导演提示词直接引用)──
character_context: str = "" # 角色详情(名称 · 年龄 · 外貌 · 服装)
location_context: str = "" # 场地详情(名称 · 空间类型 · 光线 · 材质)
product_context: str = "" # 产品详情
props_context: str = "" # 道具详情
cast_context: str = "" # ★ 演员表详情(逐人:代号 · 资产名 · 外貌特征)
def effective_cast(self) -> list["CastMember"]:
"""向后兼容:cast 为空但设了 character_id → 视为单个「主角」"""
if self.cast:
return list(self.cast)
if self.character_id:
return [CastMember(slot="主角", asset_id=self.character_id, name="(未命名)")]
return []
# ═══════════════════════════════════════════════════════════
# 镜头契约(景别/角度/运动/光学)
# ═══════════════════════════════════════════════════════════
class ShotContract(BaseModel):
"""镜头契约:专业摄影语言(v5.2 导演技能的核心产出)"""
shot_size: str = "" # 景别(特写/近景/中景/全景/远景)
angle: str = "" # 机位角度(平视/俯视/仰视/过肩)
camera_move: str = "" # 运动(固定/推/拉/摇/移/跟/环绕)
lens_feel: str = "" # 光学质感(广角/标准/长焦/浅景深)
depth_of_field: str = "" # 景深
frame_rate_feel: str = "" # 帧率感(常规/升格/降格)
# ═══════════════════════════════════════════════════════════
# 特效 / 表情 / 音效
# ═══════════════════════════════════════════════════════════
class VFXConfig(BaseModel):
"""特效配置(子弹时间/环绕运镜/慢动作/焦点锁定)"""
vfx_id: Optional[str] = None
vfx_type: Optional[Literal["bullet_time", "orbit", "slow_motion",
"focus_lock", "custom"]] = None
provider: Optional[str] = None
params: Optional[dict] = None
class ExpressionDetail(BaseModel):
"""微表情控制(面部细节)"""
eyes: Optional[str] = None
mouth: Optional[str] = None
brow: Optional[str] = None
class SFX(BaseModel):
"""音效点(某秒插入某音效)"""
id: str = "" # 素材 id(执行时匹配到素材后回填;导演只给 desc)
at_second: float = 0.0
volume: float = 0.4
desc: str = "" # 音效描述(导演产出,执行时据此选素材)
class AudioConfig(BaseModel):
"""音频配置(人声/BGM/音效)"""
voice_volume: float = 1.0
bgm_id: Optional[str] = None
bgm_volume: float = 0.15
bgm_fade_in: float = 1.0
bgm_fade_out: float = 1.0
sfx: list[SFX] = Field(default_factory=list)
audio_cue: Optional[str] = None # 声音提示(环境音等)
denoise: bool = False
class Subtitle(BaseModel):
"""字幕"""
enabled: bool = True
text: str = ""
style: str = "手机系统默认"
position: str = "bottom_center"
# ═══════════════════════════════════════════════════════════
# 转场 / 速度 / 调色 / 后期
# ═══════════════════════════════════════════════════════════
class Transition(BaseModel):
"""转场"""
type: Literal["cut", "dissolve", "fade", "wipe"] = "cut"
duration: float = 0.0
class Speed(BaseModel):
"""变速"""
factor: float = 1.0
ramp: Optional[str] = None
class ColorConfig(BaseModel):
"""调色(去 AI 化链的一环)"""
lut: Optional[str] = None
saturation_offset: float = -0.12
temperature_shift: float = 0.03
class PostProcess(BaseModel):
"""后期处理(去 AI 化:模糊 + 颗粒)"""
grain_intensity: int = 12
blur_sigma: float = 0.4
frame_interpolation: bool = False
class OutputConfig(BaseModel):
"""输出规格"""
resolution: str = "1080x1920"
fps: int = 30
bitrate: str = "8M"
# ═══════════════════════════════════════════════════════════
# 镜头 / 分镜表
# ═══════════════════════════════════════════════════════════
SHOT_TYPES = Literal["talking_head", "action", "background",
"comfyui", "vfx", "3d_character"]
CONTINUITY_MODES = Literal["fresh_location",
"previous_frame_continuation",
"location_reappearance"]
class Shot(BaseModel):
"""单个镜头(统一分镜 Schema 的核心单元)"""
shot_id: str
scene_id: str = ""
duration: float = 5.0 # 时长(秒)
type: SHOT_TYPES = "action" # 镜头类型 → 决定调哪个能力
# 生成能力(**手动覆盖**自动判定)
# "" = 自动(按 type + 是否绑角色判定)
# text_to_video / image_to_video / vfx / comfyui = 手动指定
generation_capability: str = ""
# 资产引用(按 id 去资产库取)
character_id: str = "" # 单角色(向后兼容;多角色看 cast_slots)
character_3d_id: Optional[str] = None
location_id: str = "" # 为空则用全局主场地(短片常为同一场景)
product_id: Optional[str] = None
product_focus: bool = False
product_placement: Optional[str] = None
props: list[str] = Field(default_factory=list)
# ★ 出场人物(戏内代号,对应 GlobalAnchors.cast[].slot)
# 逐镜头指定 **优先于** 全局演员表:空 = 未指定(导演/AI 自行判断)
cast_slots: list[str] = Field(default_factory=list)
# 连续性
continuity_mode: CONTINUITY_MODES = "fresh_location"
# ★ 技术需求声明(创作层 → 技术优选层的输入)
# 导演只声明"需要什么技术条件",不指定用什么工具/模型;
# 由技术优选层(capability_matcher)据此匹配能力并产出缺口报告。
# 结构示例:
# {
# "continuity": {"character": "must|prefer|none", "wardrobe": ..., "location": ...,
# "expression": ..., "props": ...},
# "chain_from_prev": false, # 是否要求承接上一镜尾帧
# "motion_level": "static|small|medium|large",
# "camera_difficulty": "fixed|simple|complex",
# "needs": ["特殊需求自由文本"]
# }
tech_requirements: Optional[dict] = None
# 摄影契约
shot_contract: ShotContract = Field(default_factory=ShotContract)
# 内容
vfx: Optional[VFXConfig] = None
visual_description: str = "" # 画面描述(提示词主体)
text: Optional[str] = None # 台词/旁白
sentiment: Optional[str] = None # 情绪
expression_detail: Optional[ExpressionDetail] = None
action: Optional[str] = None # 动作描述
# 音频/字幕/转场/后期
audio: Optional[AudioConfig] = None
subtitle: Optional[Subtitle] = None
transition_in: Transition = Field(default_factory=Transition)
transition_out: Transition = Field(default_factory=Transition)
speed: Speed = Field(default_factory=Speed)
color: ColorConfig = Field(default_factory=ColorConfig)
post_process: PostProcess = Field(default_factory=PostProcess)
output: OutputConfig = Field(default_factory=OutputConfig)
# 运行时状态(不进 Schema 契约,供 UI 展示执行进度)
status: Optional[str] = None # pending/generating/done/failed
artifact: Optional[str] = None # 产物路径
error: Optional[str] = None
class Storyboard(BaseModel):
"""分镜表(完整片子)—— 导演系统 / 编辑器 / 执行器的共同契约"""
project_id: str
schema_version: str = "2.0"
title: str = ""
story: str = "" # 原始故事/文案
content_type: str = "short_drama" # 内容类型
director_id: str = "legacy_scene_planner" # 用了哪个导演系统
global_anchors: GlobalAnchors = Field(default_factory=GlobalAnchors)
shots: list[Shot] = Field(default_factory=list)
# 运行时状态
status: Optional[str] = None
output_path: Optional[str] = None
created_at: Optional[str] = None
# 交付状态(三级诚实标注,见 produce-ai-video 验收方法论)
# 合格成片 —— 全部镜头成功 + 已完整播放审查
# 候选成片 —— 可播放,但有镜头失败或未经完整播放审查
# 未完成/待验证 —— 未生成 / 拼接失败
delivery_status: Optional[str] = None
delivery_note: Optional[str] = None
# ── 便捷方法 ──────────────────────────────────────
def total_duration(self) -> float:
"""总时长(秒)"""
return round(sum(s.duration for s in self.shots), 2)
def shot_by_id(self, shot_id: str) -> Optional[Shot]:
for s in self.shots:
if s.shot_id == shot_id:
return s
return None
def reindex(self) -> None:
"""重排镜头序号(删/插后调用)"""
for i, s in enumerate(self.shots, 1):
s.shot_id = f"shot_{i:02d}"
def to_dict(self) -> dict:
"""→ JSON 可传输结构"""
return json.loads(self.model_dump_json())
@classmethod
def from_dict(cls, data: dict) -> "Storyboard":
"""← 从 JSON 还原(容错:忽略多余字段)"""
return cls.model_validate(data or {})
def save(self, path: str | Path) -> Path:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(self.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
return p
@classmethod
def load(cls, path: str | Path) -> "Storyboard":
return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
def summary(self) -> dict:
"""给 UI 用的概览"""
by_type: dict[str, int] = {}
for s in self.shots:
by_type[s.type] = by_type.get(s.type, 0) + 1
return {
"project_id": self.project_id,
"title": self.title,
"director_id": self.director_id,
"shot_count": len(self.shots),
"total_duration": self.total_duration(),
"by_type": by_type,
"status": self.status,
"output_path": self.output_path,
}
# ═══════════════════════════════════════════════════════════
# 辅助:分镜 ↔ 现有 scene_planner 场景格式互转
# ═══════════════════════════════════════════════════════════
def scenes_to_storyboard(scenes: list[dict], project_id: str, story: str = "",
anchors: GlobalAnchors | None = None,
director_id: str = "legacy_scene_planner") -> Storyboard:
"""把 story_director/scene_planner 的场景列表 → 统一分镜 Schema
scene_planner 的场景字段(兼容多种命名):scene_id/index/description/prompt/
duration/text/sentiment/character/action
"""
shots: list[Shot] = []
for i, sc in enumerate(scenes or [], 1):
desc = (sc.get("description") or sc.get("prompt") or sc.get("visual") or "").strip()
shots.append(Shot(
shot_id=f"shot_{i:02d}",
scene_id=str(sc.get("scene_id") or sc.get("index") or i),
duration=float(sc.get("duration") or 5),
type=sc.get("type") or "action",
character_id=str(sc.get("character") or (anchors.character_id if anchors else "")),
location_id=str(sc.get("location") or (anchors.location_id if anchors else "")),
visual_description=desc,
text=sc.get("text") or sc.get("dialogue") or sc.get("narration"),
sentiment=sc.get("sentiment") or sc.get("emotion"),
action=sc.get("action"),
))
return Storyboard(
project_id=project_id,
title=story[:40] if story else "",
story=story,
director_id=director_id,
global_anchors=anchors or GlobalAnchors(),
shots=shots,
)
def storyboard_to_scenes(sb: Storyboard) -> list[dict]:
"""统一分镜 Schema → scene_planner 格式(供现有 batch_generator 消费)"""
return [
{
"scene_id": s.scene_id or s.shot_id,
"index": i,
"description": s.visual_description,
"prompt": s.visual_description,
"duration": s.duration,
"type": s.type,
"text": s.text,
"sentiment": s.sentiment,
"action": s.action,
"character": s.character_id,
"location": s.location_id,
}
for i, s in enumerate(sb.shots, 1)
]