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,195 @@
"""
scene_generator/generator.py — 场景图生成(单张 / 多视角套装)
复用 `character_sheet._generate_kling`(可灵图片生成,已跑通),
通过 `raw_prompt` 直接传成品提示词(不走角色模板)。
套装流程与角色套装同构:
① 广角全景(主图)→ 文生图 T2I
② 其余 4 视角 → **以主图为参考图** 图生图 I2I(保证是同一个空间)
"""
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
_SCRIPT_ROOT = Path(__file__).resolve().parent.parent
if str(_SCRIPT_ROOT) not in sys.path:
sys.path.insert(0, str(_SCRIPT_ROOT))
from lib.logger import get_logger # noqa: E402
from lib.config import load_config # noqa: E402
from scene_generator.prompt_assembler import ( # noqa: E402
SCENE_VIEWS, build_scene_prompt, build_scene_view_prompt, build_scene_set)
log = get_logger("scene_generator.generator")
# 场景图默认输出目录(与角色缓存同级)
try:
from character_sheet import CACHE_DIR as _CHAR_CACHE
SCENE_CACHE_DIR = _CHAR_CACHE.parent / "scene_sheet"
except Exception:
SCENE_CACHE_DIR = Path(os.environ.get("AGENT_LOCAL", ".")) / "tools/ave/cache/scene_sheet"
SCENE_ASPECT = "9:16" # 竖屏(与短剧成片一致)
def generate_scene_image(prompt: str, dest: str, ref_image: str = "",
seed=None) -> dict:
"""生成**单张**场景图(有参考图则走图生图)
返回 {ok, path, error?}
"""
from character_sheet import _generate_kling
try:
r = _generate_kling(
desc="", output_path=dest, lang="zh", force=True,
mode="scene", ref_image=ref_image, seed=seed,
raw_prompt=prompt, # ★ 直接用成品提示词
)
path = r.get("grid") or dest
return {"ok": bool(path and Path(path).exists()), "path": path, "raw": r}
except Exception as e:
log.error("场景图生成失败: %s", str(e)[:200])
return {"ok": False, "path": "", "error": str(e)[:300]}
def build_scene_set_images(layers: dict, scene_name: str = "", out_dir: str = "",
anchor_image: str = "", only: tuple = (),
force: bool = False, extra: str = "",
prompts: dict = None,
progress_cb=None) -> dict:
"""生成场景**多视角套装**(5 张)
★ 与角色套装同构:
① 广角全景(主图)→ T2I
② 其余视角 → **以主图为参考图** I2I(确保是同一个空间)
· 断点续跑:已存在文件默认跳过(force=True 强制重生成)
· 每张生成后自动质检(背景/构图)
· 成本约 ¥0.2/张 × 5 = ¥1/场景
返回 {anchor, views, done, skipped, cost_estimate, note, saved_to?}
"""
_cb = progress_cb or (lambda p, n: None)
base = Path(out_dir) if out_dir else (SCENE_CACHE_DIR / (scene_name or "scene_set"))
base.mkdir(parents=True, exist_ok=True)
targets = [v for v in sorted(SCENE_VIEWS, key=lambda k: SCENE_VIEWS[k]["order"])
if (not only or v in only)]
out: dict = {"anchor": anchor_image or "", "views": {}, "done": 0, "skipped": 0,
"cost_estimate": round(len(targets) * 0.2, 2)}
anchor_path = anchor_image
for idx, vid in enumerate(targets, 1):
v = SCENE_VIEWS[vid]
dst = base / f"{vid}.png"
_cb(int((idx - 1) / max(1, len(targets)) * 100), f"{v['label']}")
if dst.exists() and not force and dst.stat().st_size > 10_000:
log.info(" ♻️ 跳过已存在:%s", dst.name)
out["views"][vid] = {"path": str(dst), "ok": True, "skipped": True,
"is_anchor": v["is_anchor"]}
out["skipped"] += 1
if v["is_anchor"] and not anchor_path:
anchor_path = str(dst)
continue
# 主图:文生图(或复用指定图)
if v["is_anchor"]:
if anchor_path and Path(str(anchor_path)).exists():
import shutil
shutil.copy2(str(anchor_path), dst)
out["views"][vid] = {"path": str(dst), "ok": True, "reused": True,
"is_anchor": True}
out["anchor"] = str(dst)
log.info(" ♻️ 主图复用:%s", Path(str(anchor_path)).name)
continue
ref = ""
else:
# 其余视角:以主图为参考图(核心:保证同一空间)
ref = anchor_path or (str(base / "wide.png") if (base / "wide.png").exists() else "")
if not ref:
log.warning(" ⚠️ 无主图参考,%s 将走文生图(空间可能不一致)", v["label"])
# ★ 优先用调用方给的成品提示词(用户自带提示词 / 预设模式)
if prompts and str(prompts.get(vid) or "").strip():
prompt = str(prompts[vid]).strip()
else:
prompt = build_scene_view_prompt(vid, layers, extra=extra)
log.info(" 🏠 [%d/%d] %s(%s)", idx, len(targets), v["label"],
"图生图·锚定主图" if ref else "文生图")
r = generate_scene_image(prompt, str(dst), ref_image=ref)
ok = bool(r.get("ok"))
q = None
if ok:
try:
from character_generator.quality_check import check_reference_quality
q = check_reference_quality(r["path"]).get("edge_std")
except Exception:
pass
out["views"][vid] = {"path": r.get("path", ""), "ok": ok, "quality": q,
"is_anchor": v["is_anchor"], "used_ref": bool(ref),
"error": r.get("error", "")}
if ok:
out["done"] += 1
if v["is_anchor"]:
anchor_path = r["path"]
out["anchor"] = r["path"]
time.sleep(0.5)
_cb(100, "完成")
okn = sum(1 for x in out["views"].values() if x.get("ok"))
out["note"] = f"场景套装完成 {okn}/{len(targets)} 张(约 ¥{out['cost_estimate']})"
log.info(" 🏠 %s", out["note"])
# 自动入库(场景资产)
if scene_name:
try:
out["saved_to"] = _save_scene_to_assets(scene_name, out, base)
except Exception as e:
log.warning(" ⚠️ 场景入库失败(文件仍在 cache): %s", str(e)[:150])
out["save_error"] = str(e)[:200]
return out
def _save_scene_to_assets(scene_name: str, out: dict, base: Path) -> dict:
"""把场景套装写入「场景」资产(按名查找/创建 + 复制 5 张 + 更新 reference_images)"""
import shutil
from datetime import datetime
from assets_manager import AssetStore
st = AssetStore("locations")
it = st.get(scene_name)
if not it:
for x in st.list():
if (x.get("name") or "").strip() == scene_name.strip():
it = x
break
aid = (it or {}).get("id") or scene_name
if not it:
st.create({"id": aid, "name": scene_name,
"description": "由「🏠 场景生成器」创建", "tags": ["场景", "套装"]})
log.info(" 🆕 已创建场景: %s", scene_name)
target = Path(st.kind_dir(aid))
target.mkdir(parents=True, exist_ok=True)
refs = dict((it or {}).get("reference_images") or {})
added = []
for vid, v in (out.get("views") or {}).items():
src = Path(v.get("path") or "")
if not src.exists() or not v.get("ok"):
continue
dst = target / f"ref_{vid}.png"
shutil.copy2(src, dst)
refs[vid] = dst.name
added.append(f"{vid}={dst.name}")
if added:
st.update(aid, {"reference_images": refs,
"generation_history": list((it or {}).get("generation_history") or []) + [{
"at": datetime.now().isoformat(timespec="seconds"),
"kind": "scene_set", "files": added}]})
log.info(" 💾 已入库场景「%s」:%d 张", scene_name, len(added))
return {"aid": aid, "added": added}
@@ -0,0 +1,397 @@
"""
scene_generator/prompt_assembler.py — 场景(拍摄地)结构化提示词系统
设计参照 `character_generator/prompt_assembler.py`(角色 10 层 47 字段),
为**场景**建一套同样粒度的分层字段:**10 层 51 字段**。
## 为什么需要
原实现只有"传一张图",无法描述"这是个什么样的空间" ——
分镜时模型不知道窗在哪、门朝哪、床怎么摆 → 换机位空间就乱。
结构化字段让场景可复用、可检索、可交给 AI 扩写。
## 10 层
L0 场景身份 空间类型 / 室内外 / 尺度 / 地域
L1 空间格局 面积 / 层高 / 格局 / 进深 / 特殊结构
L2 建筑风格 年代 / 风格 / 新旧 / 材质倾向 / 地域特征
L3 表面材质 墙面 / 地面 / 顶面 / 固定设施 / 磨损
L4 家具陈设 主家具 / 次家具 / 摆放 / 数量 / 年代感 / 功能区
L5 细节道具 桌面 / 墙面装饰 / 地面 / 生活痕迹 / 电子设备
L6 光源 主光 / 色温 / 方向 / 强度 / 辅助光 / 实用光源
L7 色彩氛围 调性 / 主色 / 辅色 / 饱和度 / 氛围
L8 时间天气 时间 / 天气 / 季节 / 窗外可见 / 空气感
L9 镜头视角 常用机位 / 空间感 / 尺度参照 / 禁忌
## 多视角(5 张 · 与角色套装同一套机制)
wide(广角全景·主图)· front(正面)· side(侧面)· top(俯视/顶图)· detail(细节)
"""
from __future__ import annotations
from typing import Any
# ════════════════════════════════════════════════════════════
# 分层字段定义
# ════════════════════════════════════════════════════════════
SCENE_LAYERS: dict = {
"S0_identity": {
"name": "场景身份",
"icon": "🏠",
"fields": [
{"key": "name", "label": "场景名", "type": "text", "placeholder": "如:市一院住院部 302 病房"},
{"key": "space_type", "label": "空间类型", "type": "select", "options": [
"病房", "诊室", "手术室外", "医院走廊", "客厅", "卧室", "厨房", "卫生间",
"办公室", "会议室", "工位区", "教室", "图书馆", "餐厅", "咖啡馆", "酒吧",
"便利店", "商场", "地铁车厢", "地铁站", "公交车", "出租车内", "私家车内",
"街道", "小巷", "天桥", "地下通道", "公园", "河边", "天台", "楼梯间",
"电梯", "车库", "仓库", "工厂车间", "工地", "老式居民楼", "出租屋",
]},
{"key": "indoor_outdoor", "label": "室内 / 室外", "type": "select",
"options": ["室内", "室外", "半开放(阳台/走廊/门洞)", "车内"]},
{"key": "scale", "label": "空间尺度", "type": "select",
"options": ["狭小(<10㎡)", "小(10-30㎡)", "中等(30-100㎡)", "大(100㎡+)", "开阔无边界"]},
{"key": "region", "label": "地域风格", "type": "select",
"options": ["中国", "东亚", "东南亚", "南亚", "中东", "欧美", "拉美", "非洲", "架空/无地域"]},
],
},
"S1_layout": {
"name": "空间格局",
"icon": "📐",
"fields": [
{"key": "area", "label": "面积感", "type": "select",
"options": ["极紧凑", "紧凑", "适中", "宽敞", "空旷"]},
{"key": "ceiling", "label": "层高", "type": "select",
"options": ["低矮压抑(<2.4m)", "标准(2.6-2.8m)", "挑高(3.5m+)", "loft/通高"]},
{"key": "layout", "label": "格局", "type": "select",
"options": ["单间开敞", "一室一厅", "长条形走廊", "L 形", "回字形", "开放大厅", "隔断分区"]},
{"key": "depth", "label": "进深 / 纵深感", "type": "select",
"options": ["浅(一望到底)", "中等", "深(有纵深)", "极深(望不到头)"]},
{"key": "structure", "label": "特殊结构", "type": "select",
"options": ["无", "承重柱", "楼梯", "拱门", "横梁", "玻璃隔断", "推拉门", "天窗"]},
],
},
"S2_architecture": {
"name": "建筑风格",
"icon": "🏛️",
"fields": [
{"key": "era", "label": "年代", "type": "select",
"options": ["当代(2020s)", "10年前", "2000 年代", "90 年代", "80 年代", "民国", "古代", "未来"]},
{"key": "style", "label": "装修风格", "type": "select",
"options": ["现代简约", "医疗专业", "工业风", "中式", "日式", "欧式", "北欧",
"复古怀旧", "毛坯/未装修", "混搭"]},
{"key": "condition", "label": "新旧程度", "type": "select",
"options": ["崭新", "整洁", "正常使用", "老旧", "破败斑驳"]},
{"key": "material_tone", "label": "材质基调", "type": "select",
"options": ["木质为主", "水泥裸露", "瓷砖/石材", "金属工业", "玻璃通透", "混合材质"]},
{"key": "region_style", "label": "地域特征", "type": "select",
"options": ["无明显地域", "北方(暖气/厚墙)", "南方(潮湿/开放阳台)", "沿海", "内陆", "海外"]},
],
},
"S3_surfaces": {
"name": "表面材质",
"icon": "🧱",
"fields": [
{"key": "wall", "label": "墙面", "type": "select",
"options": ["乳白涂料墙", "浅灰墙", "瓷砖墙", "水泥墙", "木饰面", "墙纸",
"半墙瓷砖+上白墙", "玻璃幕墙"]},
{"key": "floor", "label": "地面", "type": "select",
"options": ["抛光瓷砖", "哑光瓷砖", "木地板", "复合地板", "水泥地", "地毯",
"PVC 地胶(医院)", "环氧地坪", "沥青路面"]},
{"key": "ceiling_surface", "label": "顶面", "type": "select",
"options": ["平顶白墙", "吊顶+灯槽", "裸露管线", "木梁", "格栅吊顶", "无顶(室外)"]},
{"key": "fixtures", "label": "固定设施", "type": "select",
"options": ["无", "洗手台", "管道外露", "空调出风口", "消防栓", "配电箱", "插座面板"]},
{"key": "wear", "label": "磨损程度", "type": "select",
"options": ["全新", "正常使用痕迹", "明显老旧", "污渍划痕明显", "破损"]},
],
},
"S4_furniture": {
"name": "家具陈设",
"icon": "🛏️",
"fields": [
{"key": "main_furniture", "label": "主家具", "type": "text",
"placeholder": "如:可升降病床 / 办公桌 / 三人沙发"},
{"key": "secondary", "label": "次要家具", "type": "text",
"placeholder": "如:床头柜、陪护椅 / 文件柜、转椅"},
{"key": "arrangement", "label": "摆放方式", "type": "select",
"options": ["规整对齐", "靠墙排列", "居中摆放", "围合成区", "随意散置"]},
{"key": "quantity", "label": "数量感", "type": "select",
"options": ["极少(空)", "适中", "较多", "密集拥挤"]},
{"key": "era_match", "label": "家具年代感", "type": "select",
"options": ["现代", "老式(10-20年前)", "复古(30年前+)"]},
{"key": "function_zone", "label": "功能分区", "type": "select",
"options": ["单一功能", "多功能混合", "无明显分区", "有明显的区域划分"]},
],
},
"S5_props": {
"name": "细节道具",
"icon": "🧴",
"fields": [
{"key": "tabletop", "label": "桌面/台面物件", "type": "text",
"placeholder": "如:水杯、病历夹、监护仪、纸巾"},
{"key": "wall_decor", "label": "墙面装饰", "type": "select",
"options": ["无", "挂钟", "照片/画", "白板/公告", "海报", "日历", "锦旗"]},
{"key": "floor_items", "label": "地面物件", "type": "select",
"options": ["无", "垃圾桶", "纸箱/杂物", "电线/输液管", "水渍", "地毯边角"]},
{"key": "life_traces", "label": "生活痕迹", "type": "select",
"options": ["干净无菌", "整洁有序", "有人生活的痕迹", "凌乱", "荒废感"]},
{"key": "devices", "label": "电子设备", "type": "select",
"options": ["无", "监护仪/医疗设备", "电脑/显示器", "打印机", "电视", "老旧风扇"]},
],
},
"S6_lighting": {
"name": "光源",
"icon": "💡",
"fields": [
{"key": "main_light", "label": "主光源", "type": "select",
"options": ["窗光(自然光)", "顶部日光灯", "暖色吸顶灯", "台灯/落地灯",
"霓虹/招牌光", "无明确主光", "混合多光源"]},
{"key": "color_temp", "label": "色温", "type": "select",
"options": ["暖黄(2700K)", "中性(4000K)", "冷白(6000K)", "冷暖混合"]},
{"key": "light_dir", "label": "来光方向", "type": "select",
"options": ["顶光", "侧光(左)", "侧光(右)", "逆光", "正面平光", "漫射无方向"]},
{"key": "intensity", "label": "亮度", "type": "select",
"options": ["明亮通透", "适中", "偏暗", "昏暗", "极暗(仅局部亮)"]},
{"key": "fill_light", "label": "辅助光", "type": "select",
"options": ["有环境反射补光", "无明显补光(暗部很深)", "有反光面"]},
{"key": "practical", "label": "实用光源状态", "type": "select",
"options": ["灯全开", "部分开", "灯关着(仅自然光)", "灯管闪烁/故障"]},
],
},
"S7_color_mood": {
"name": "色彩氛围",
"icon": "🎨",
"fields": [
{"key": "tone", "label": "调性", "type": "select",
"options": ["冷调", "暖调", "中性", "高对比", "低对比/灰调"]},
{"key": "primary_color", "label": "主色", "type": "select",
"options": ["白色系", "灰色系", "米黄系", "蓝色系", "绿色系", "棕色系",
"黑色系", "混合"]},
{"key": "accent_color", "label": "点缀色", "type": "select",
"options": ["无", "蓝色", "红色", "黄色", "绿色", "橙色", "粉色", "金属色"]},
{"key": "saturation", "label": "饱和度", "type": "select",
"options": ["低饱和(灰淡)", "中等", "饱和度高", "近乎单色"]},
{"key": "mood", "label": "氛围", "type": "select",
"options": ["专业冷静", "温馨日常", "压抑沉重", "冷清空旷", "热闹嘈杂",
"紧张不安", "怀旧感", "崭新疏离"]},
],
},
"S8_time_weather": {
"name": "时间天气",
"icon": "🕐",
"fields": [
{"key": "time_of_day", "label": "时间", "type": "select",
"options": ["清晨", "上午", "正午", "下午", "黄昏", "夜晚", "深夜"]},
{"key": "weather", "label": "天气", "type": "select",
"options": ["晴", "多云", "阴", "小雨", "大雨", "雪", "雾", "不适用(室内)"]},
{"key": "season", "label": "季节", "type": "select",
"options": ["春", "夏", "秋", "冬", "不明确"]},
{"key": "outdoor_visible", "label": "窗外可见", "type": "select",
"options": ["无窗", "对面建筑", "天空", "树/绿化", "街道", "夜灯/霓虹", "雨雪"]},
{"key": "atmosphere", "label": "空气感", "type": "select",
"options": ["清澈", "有灰尘/浮尘", "蒸汽/水汽", "烟雾", "雨后湿润", "干燥"]},
],
},
"S9_camera": {
"name": "镜头视角",
"icon": "🎥",
"fields": [
{"key": "common_angles", "label": "常用机位", "type": "select",
"options": ["平视为主", "俯视/顶拍", "低角度", "过肩", "广角全景", "混合多机位"]},
{"key": "spatial_feel", "label": "空间感", "type": "select",
"options": ["开阔通透", "适中", "略显局促", "幽闭压迫"]},
{"key": "scale_ref", "label": "尺度参照物", "type": "select",
"options": ["有(门/桌椅/人体)", "无(纯空镜)", "有标识物(牌/字)"]},
{"key": "avoid", "label": "需避免", "type": "text",
"placeholder": "如:不要出现人、不要镜子反光、不要现代标识"},
],
},
}
# ── 多视角定义(5 张 · 与角色套装同一套机制)──
SCENE_VIEWS: dict = {
"back": {
"order": 6,
"label": "门口 / 反向视角",
"is_anchor": False,
"shot": "Reverse angle from the opposite end: the camera looks back toward "
"the entrance/door, revealing the doorway and what is behind it. "
"Same room, same furniture, same lighting — only the viewpoint reversed.",
},
"wide": {
"order": 1, "label": "广角全景(主图)", "is_anchor": True,
"shot": "Wide establishing shot of the entire space: the camera stands at one end "
"showing the whole room at once, eye-level, 24mm wide lens, deep depth of "
"field so front and back are both readable.",
"consistency": "The same space, same furniture layout, same lighting, same color palette.",
},
"front": {
"order": 2, "label": "正面视线", "is_anchor": False,
"shot": "Facing the main wall of the same space straight on (where the room's focal "
"point is), eye-level, 35mm lens, the layout unchanged.",
"consistency": "The same space, same furniture layout, same lighting, same color palette.",
},
"side": {
"order": 3, "label": "侧面视角", "is_anchor": False,
"shot": "From the side of the same space at about 45 degrees: reveals the depth and "
"how the furniture sits against the walls, eye-level, 35mm lens.",
"consistency": "The same space, same furniture layout, same lighting, same color palette.",
},
"top": {
"order": 4, "label": "俯视 / 顶图", "is_anchor": False,
"shot": "Looking straight down at the same space from above (bird's-eye plan view): "
"shows the spatial relationship between furniture and walls, ceiling not visible.",
"consistency": "The same space, same furniture layout, same colors.",
},
"detail": {
"order": 5, "label": "细节特写", "is_anchor": False,
"shot": "Close-up of the most telling detail of the same space (the focal object / "
"surface texture), shallow depth of field.",
"consistency": "The same space, same materials, same lighting, same color palette.",
},
}
# 通用负面词(场景不该出现的东西)
SCENE_NEGATIVE = (
"people, person, human figure, character, hand, "
"text, watermark, logo, caption, subtitle, signature, "
"distorted geometry, warped perspective, fisheye distortion, "
"blurry, low resolution, jpeg artifacts, "
"场景中的人物,人影,文字,水印,logo,字幕,"
"透视扭曲,鱼眼畸变,模糊,低清,噪点,"
"现代物品(年代不符时),穿帮,重复结构"
)
# 写实质感层(研究结论:不写 4K/超清,写相机与物理细节)
LOOK_REALISM = (
"A real photograph of a real place, not a render. "
"Full-frame camera, 24-35mm lens at f/4-5.6, true-to-life white balance, "
"no HDR halo, no oversaturation, slight film grain. "
"Photographed in available light."
)
def _join(vals) -> str:
"""把字段值拼成一句(去空、去重、中英用不同分隔)"""
out, seen = [], set()
for v in vals:
s = str(v or "").strip().strip("。,, ;;")
if s and s not in seen:
seen.add(s)
out.append(s)
return ",".join(out)
def build_scene_prompt(layers: dict, extra: str = "") -> str:
"""把分层字段组装成**场景提示词**(文生图用)
layers 形如 {"S0_identity": {"space_type": "病房", ...}, ...}
"""
g = lambda lid, key: ((layers.get(lid) or {}).get(key) or "").strip()
# ① 主体(这是什么空间)—— 最重要,放最前
main = _join([
g("S0_identity", "region"),
g("S2_architecture", "era"),
g("S2_architecture", "condition"),
g("S0_identity", "space_type"),
g("S2_architecture", "style"),
])
# ② 空间(多大、什么形状)
space = _join([
g("S0_identity", "scale"), g("S1_layout", "area"), g("S1_layout", "ceiling"),
g("S1_layout", "layout"), g("S1_layout", "depth"), g("S1_layout", "structure"),
])
# ③ 材质表面
surf = _join([
g("S3_surfaces", "wall"), g("S3_surfaces", "floor"),
g("S3_surfaces", "ceiling_surface"), g("S3_surfaces", "fixtures"),
g("S3_surfaces", "wear"),
])
# ④ 家具与道具
furn = _join([
g("S4_furniture", "main_furniture"), g("S4_furniture", "secondary"),
g("S4_furniture", "arrangement"), g("S4_furniture", "quantity"),
g("S4_furniture", "era_match"), g("S4_furniture", "function_zone"),
g("S5_props", "tabletop"), g("S5_props", "wall_decor"),
g("S5_props", "floor_items"), g("S5_props", "life_traces"),
g("S5_props", "devices"),
])
# ⑤ 光与色
light = _join([
g("S6_lighting", "main_light"), g("S6_lighting", "color_temp"),
g("S6_lighting", "light_dir"), g("S6_lighting", "intensity"),
g("S6_lighting", "fill_light"), g("S6_lighting", "practical"),
])
color = _join([
g("S7_color_mood", "tone"), g("S7_color_mood", "primary_color"),
g("S7_color_mood", "accent_color"), g("S7_color_mood", "saturation"),
g("S7_color_mood", "mood"),
])
# ⑥ 时空
timew = _join([
g("S8_time_weather", "time_of_day"), g("S8_time_weather", "weather"),
g("S8_time_weather", "season"), g("S8_time_weather", "outdoor_visible"),
g("S8_time_weather", "atmosphere"),
])
parts = [
f"【空间】{main}" if main else "",
f"【格局】{space}" if space else "",
f"【材质】{surf}" if surf else "",
f"【陈设】{furn}" if furn else "",
f"【光线】{light}" if light else "",
f"【色彩】{color}" if color else "",
f"【时空】{timew}" if timew else "",
]
body = "。".join([p for p in parts if p]) + "。" if any(parts) else ""
tail = f"{LOOK_REALISM}"
if extra:
tail += " " + extra.strip()
return (body + tail).strip()
def build_scene_view_prompt(view: str, layers: dict, extra: str = "") -> str:
"""组装「场景多视角」中某一张的提示词(构图指令置首 + 空间描述)"""
v = SCENE_VIEWS.get(view) or SCENE_VIEWS["wide"]
base = build_scene_prompt(layers, extra="")
return " ".join([
v["shot"], # ★ 构图指令必须在最前
base,
v["consistency"],
LOOK_REALISM,
] + ([extra.strip()] if extra else [])).strip()
def build_scene_set(layers: dict, prompts: dict = None, extra: str = "") -> dict:
"""组装全部视角(5 张)→ {view: {order,label,is_anchor,prompt}}
prompts: 可选,{view: 成品提示词} —— 给定时优先使用(预设 / 用户自带提示词)
"""
prompts = prompts or {}
out = {}
for vid, v in sorted(SCENE_VIEWS.items(), key=lambda kv: kv[1]["order"]):
p = str(prompts.get(vid) or "").strip()
out[vid] = {
"order": v["order"], "label": v["label"], "is_anchor": v["is_anchor"],
"prompt": p or build_scene_view_prompt(vid, layers, extra=extra),
}
return out
def scene_layer_summary() -> dict:
"""层/字段统计(供前端展示与自检)"""
layers = len(SCENE_LAYERS)
fields = sum(len(L.get("fields") or []) for L in SCENE_LAYERS.values())
return {"layers": layers, "fields": fields,
"views": len(SCENE_VIEWS),
"layer_names": [L["name"] for L in SCENE_LAYERS.values()]}
def parse_prompt_schema() -> dict:
"""给 LLM 的结构化输出说明(AI 自动生成字段时用)"""
import json
spec = {}
for lid, L in SCENE_LAYERS.items():
spec[lid] = {f["key"]: (f.get("options") or "text") for f in L.get("fields") or []}
return {"target": "scene", "layers": spec,
"instruction": "根据用户描述,为每个字段选择最合适的值(只能从给定选项中选);"
"没提到的字段留空。输出 JSON:" + json.dumps({"layers": spec}, ensure_ascii=False)[:200]}