Files
MediaCrawler/mac-agent-os-main/05_tools/09_ave/scripts/character_sheet.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

1153 lines
46 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.
#!/usr/bin/env python3
"""
AVE character_sheet — 定妆照 Grid Method 自动生成
功能:
1. 角色描述 → 2x3 网格定妆照(Kling文生图 / 备选方案)
2. 从网格图裁剪单格参考图
3. 生成角色描述块 JSON(跨场景一致性注入用)
用法:
python character_sheet.py --desc "一位28岁的中国男性..." --output sheet.png
python character_sheet.py --desc "..." --output sheet.png --extract # 同时裁出6张单格
API:
Kling 文生图 (官方 API: api-beijing.klingai.com)
备选: 阿里云百炼通义万相, fal.ai
"""
import sys
import os
import json
import hashlib
import time
from pathlib import Path
# 确保能找到上级模块
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.config import load_config
from lib.logger import get_logger
logger = get_logger("character_sheet")
# ── 缓存 ──
CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet")))
CACHE_DIR.mkdir(parents=True, exist_ok=True)
# ── Kling Image API ──
KLING_BASE = "https://api-beijing.klingai.com"
DASHSCOPE_BASE = "https://dashscope.aliyuncs.com"
# ═══════════════════════════════════════════════════════════
# Prompt 构建
# ═══════════════════════════════════════════════════════════
# ── 标准 2x3 网格(日常/漫剧风格)──
GRID_PROMPT_EN = (
"A professional character design sheet for {desc}. "
"Layout is a 2x3 grid on a clean white background. "
"Top row (3 panels): full body front view, full body side profile view, full body 3/4 turn view. "
"Bottom row (3 panels): close-up of face - neutral expression, smiling expression, angry expression. "
"Consistent character design, flat shading, concept art style. --ar 3:2"
)
# ── 角色定妆照通用约束(★ 背景必须干净:带场景的参考图会污染后续图生视频)──
# ── 角色定妆照三要素(★ 背景必须干净:带场景的参考图会污染图生视频)──
# 设计要点(按提示词工程原则):
# ① 不加 markdown 符号(** 等会干扰模型理解)
# ② 背景约束**前置**(模型对提示词开头最敏感)
# ③ 全文一致,不留矛盾表述(不能一处写"白色"、一处写"冷灰")
# ④ 尽量短 —— 关键约束不被稀释
SHEET_BG_GREY_ZH = (
"纯色无缝冷灰摄影棚背景,背景上除了人物什么都没有(无墙、无家具、无道具、"
"无窗、无植物、无风景、无地面阴影),各格背景色调完全一致。"
)
SHEET_BG_WHITE_ZH = (
"纯色无缝纯白摄影棚背景,背景上除了人物什么都没有(无墙、无家具、无道具、"
"无窗、无植物、无风景、无地面阴影),各格背景色调完全一致。"
)
# 当前默认(实测对比用;改这一行即可切换)
SHEET_BG_PREFIX_ZH = SHEET_BG_GREY_ZH
# 研究结论(lanshu 工具包 §2,整合 20 个 5M+ 播放教程):
# **相机型号是写实"杀手锏"** —— 触发模型的真实摄影先验;
# `unfiltered` / 毛孔可见 是反 AI 塑料感的关键词。
SHEET_STYLE_ZH = (
"摄影棚均匀柔光,中性素净(不加影片调色)。"
"真实皮肤质感、毛孔与细小绒毛可见、不磨皮不塑料感。"
"拍摄质感:Fujifilm X100V 35mm 镜头直出,unfiltered,自然肤色。"
)
GRID_PROMPT_ZH = (
"角色定妆参考表,四格并排(左一:胸像正面;左二:左侧脸 profile;"
"右二:全身正面;右一:全身背面),四格为同一人,五官发型服装体型完全一致。"
+ SHEET_BG_PREFIX_ZH + SHEET_STYLE_ZH
+ "展示:{desc}。比例16:9。"
)
GRID_PROMPT_CONCEPT_ZH = (
"角色设计表,三格并排(左:胸像正面;中:全身正面;右:全身背面),"
"三格为同一角色,设计一致。" + SHEET_BG_PREFIX_ZH + "扁平着色概念艺术风格。"
"展示:{desc}。比例16:9。"
)
# ── 体育专用 4x2 网格(写实摄影风格,脸部优先)──
# 布局: 4列×2行,8格
# Row 0: body_front | body_side | face_front | face_3qL
# Row 1: face_profile | face_3qR | face_focus | face_intense
SPORT_GRID_PROMPT_EN = (
"A photorealistic character reference sheet for {desc}. "
"Layout is a 4x2 grid (4 columns, 2 rows) on a clean white background, thin gray dividers between panels. "
"Row 1 (upper): full body front view, full body side profile, close-up face neutral front view, close-up face 3/4 left view. "
"Row 2 (lower): close-up pure left side profile, close-up face 3/4 right view, close-up face focused determined expression, close-up face intense exertion expression. "
"All 8 panels show the exact same person with identical face, hairstyle, body shape. "
"Photorealistic, sports photography style, natural lighting, high detail, sharp focus on face panels. --ar 3:2"
)
SPORT_GRID_PROMPT_ZH = (
"写实角色参考定妆照,4列2行共8格,格间细灰线,8格为同一人。"
+ SHEET_BG_PREFIX_ZH +
"第1行:全身正面、全身侧面、面部正面特写、面部3/4左侧特写。"
"第2行:面部左侧轮廓、面部3/4右侧、专注眼神特写、坚定表情特写。"
+ SHEET_STYLE_ZH + "展示:{desc}。比例3:2。"
)
# ── 体育 I2I 专用 Prompt(基于参考照生成)──
SPORT_GRID_I2I_PROMPT_EN = (
"Based on the reference photo, create a photorealistic 4x2 character reference sheet (8 panels). "
"The character is {desc}. "
"Layout: 4 columns x 2 rows, clean white background, thin gray dividers. "
"Row 1: full body front view, full body side profile, close-up face neutral front, close-up face 3/4 left. "
"Row 2: close-up pure left side profile, close-up face 3/4 right, close-up face focused, close-up face intense exertion. "
"MUST maintain exact same face, hairstyle, body shape as the reference photo person. "
"Photorealistic sports photography, natural lighting, high detail, sharp focus on face panels. --ar 3:2"
)
GRID_ASPECT_RATIO = "3:2" # 网格图(多格横向布局,需要宽画幅)
PORTRAIT_ASPECT_RATIO = "9:16" # 单视角半身/定妆照(**竖屏** —— 短剧主拍竖屏)
# Kling 合法值:16:9 / 9:16 / 1:1 / 4:3 / 3:4 / 3:2 / 2:3 / 21:9 / 9:20
# ⚠️ 可灵图片模型(2026-10-04 实测):kling-v1 / v1-5 / v2 **已停用**
# 可用:kling-v3 ✅ / kling-v2-1 ✅
KLING_IMAGE_MODEL = "kling-v3"
def _ar_for_mode(mode: str) -> str:
"""按模式选画幅
· portrait(角色套装)· scene(场景套装)→ 9:16 竖屏
· 其他(网格定妆表)→ 3:2 横版
"""
return PORTRAIT_ASPECT_RATIO if mode in ("portrait", "scene") else GRID_ASPECT_RATIO
GRID_WIDTH = 1536 # 目标网格宽度
GRID_HEIGHT = 1024 # 标准 2x3 网格高度
SPORT_GRID_HEIGHT = 1536 # 体育 3x3 网格高度
DEFAULT_SEED = 42 # 默认固定种子,保证可复现
def build_character_block(desc: str) -> str:
"""构建角色描述块(全片复用的特征锚点)"""
return desc # 用户输入的描述即为角色块
def build_grid_prompt(desc: str, lang: str = "zh", mode: str = "standard", use_ref: bool = False) -> str:
"""
构建网格定妆照 Prompt
mode: "standard" → 2x3 概念艺术(日常/漫剧)
"sport" → 4x2 写实摄影(8格,脸部优先)
use_ref: True → 使用 I2I 参考图模式 Prompt(sport mode 专用)
"""
if mode == "sport":
if use_ref:
return SPORT_GRID_I2I_PROMPT_EN.format(desc=desc)
if lang == "zh":
return SPORT_GRID_PROMPT_ZH.format(desc=desc)
return SPORT_GRID_PROMPT_EN.format(desc=desc)
# standard mode
if lang == "zh":
return GRID_PROMPT_ZH.format(desc=desc)
return GRID_PROMPT_EN.format(desc=desc)
# ═══════════════════════════════════════════════════════════
# 生成引擎: Kling 文生图
# ═══════════════════════════════════════════════════════════
def generate_grid(
desc: str,
output_path: str = "",
lang: str = "zh",
do_extract: bool = False,
force: bool = False,
mode: str = "standard",
ref_image: str = "",
seed: int | None = None,
view: str = "",
raw_prompt: str = "",
) -> dict:
"""
生成网格定妆照
参数:
desc: 角色描述
output_path: 输出路径 (.png/.jpg)
lang: zh/en
do_extract: 是否裁出单格图
force: 强制重新生成
mode: "standard"=2x3概念艺术 / "sport"=4x2写实体育(8格,脸部优先)
ref_image: 参考图路径(sport mode 下用于 I2I,提升人脸一致性)
seed: 固定随机种子(int),传入后 Kling 生成结果可复现;
为 None 时不传 seed,由 Kling 随机生成(并在返回中记录)
返回:
{"grid": str, "cells": [...], "seed": int|None} — grid=网格图路径, cells=单格路径列表, seed=本次使用的种子
"""
if not output_path:
cache_key = hashlib.md5(f"{desc}{mode}".encode()).hexdigest()[:12]
output_path = str(CACHE_DIR / f"grid_{cache_key}.png")
cfg = load_config()
provider = cfg.get("character_sheet", {}).get("provider", "kling")
result = {"grid": "", "cells": [], "seed": seed}
if provider == "kling":
result = _generate_kling(desc, output_path, lang, force, mode=mode,
ref_image=ref_image, seed=seed, view=view)
else:
raise ValueError(f"未知 provider: {provider},支持: kling")
grid_path = result.get("grid", "")
logger.info(f"定妆照生成完成: grid={grid_path}, seed={result.get('seed')}")
# ★ 生成后质检(零成本,纯本地计算)—— 背景是否干净直接决定图生视频的一致性
try:
from character_generator.quality_check import check_sheet_quality, clean_reference_background
q = check_sheet_quality(grid_path)
result["quality"] = q
med = q.get("median_cell_std", -1)
if q.get("ok"):
logger.info(" ✅ 背景质检通过(各格中位标准差 %.1f,越低越干净)", med)
else:
logger.warning(" ⚠️ 背景不够干净(中位标准差 %.1f,阈值 <25):%s",
med, (q.get("issues") or [""])[0][:70])
for c in (q.get("per_cell") or [])[:8]:
logger.warning(" %s: %.1f", c.get("cell"), c.get("edge_std", -1))
# 自动兜底:抠图换纯色底(rembg 可用时;不可用则优雅跳过)
cleaned = clean_reference_background(grid_path, bg="cool_grey")
if cleaned.get("ok"):
logger.info(" 🧹 已自动白底清理 → %s(边缘标准差 %.1f → %.1f)",
os.path.basename(cleaned["output"]),
cleaned["before"]["edge_std"], cleaned["after"]["edge_std"])
result["grid"] = cleaned["output"]
result["cleaned"] = cleaned
elif cleaned.get("skipped"):
logger.info(" (跳过自动清理:%s)", cleaned.get("reason", "")[:60])
except Exception as e:
logger.debug("质检跳过: %s", e)
# 裁格
cells_result = {}
if do_extract and grid_path and os.path.exists(grid_path):
cells_result = extract_cells(grid_path, os.path.dirname(grid_path) or CACHE_DIR, mode=mode)
result["cells"] = cells_result
logger.info(f" 已裁出 {len(cells_result)} 张单格图")
elif do_extract:
logger.warning(" do_extract=True 但网格图路径无效,跳过裁格")
# 生成 face_anchor.png(从 face_neutral 提取)
face_anchor_path = ""
if cells_result:
face_anchor_path = _export_face_anchor(cells_result, os.path.dirname(grid_path) or CACHE_DIR)
if face_anchor_path:
result["face_anchor"] = face_anchor_path
return result
def build_portrait_set(
desc: str,
character_name: str = "",
out_dir: str = "",
anchor_image: str = "",
only: tuple = (),
force: bool = False,
progress_cb=None,
) -> dict:
"""生成短剧角色参考图**套装**(5 视角 · 分开生成 + 参考图锚定五官)
★ 关键流程(业界短剧角色卡做法):
① 生成【正面半身主定妆】(front_half) —— 文生图
② 以主定妆图为**参考图**,逐张生成其余 4 个视角 —— 图生图(I2I)
「把主定妆当参考图」是跨角度五官不崩的关键
· **逐张分开生成**(不是网格图)—— 每张全分辨率,五官清晰
· 断点续跑:已存在的文件默认跳过(force=True 强制重生成)
· 每张生成后自动质检背景纯净度
· anchor_image:直接指定已满意的主定妆图(跳过第①步)
成本:约 ¥0.2/张 × 5 = ¥1(可灵图片计费,与视频额度分开)
返回:{anchor, views: {vid: {path, ok, quality, is_anchor}}, done, skipped,
cost_estimate, note}
"""
from character_generator.portrait_set import PORTRAIT_VIEWS, VIEW_ORDER
import time as _t
_cb = progress_cb or (lambda p, n: None)
base = Path(out_dir) if out_dir else (CACHE_DIR / (character_name or "portrait_set"))
base.mkdir(parents=True, exist_ok=True)
targets = [v for v in VIEW_ORDER if (not only or v in only)]
out: dict = {"anchor": "", "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 = PORTRAIT_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:
# ★ 关键:若本张的生成时间**早于主定妆**,说明它是"无锚定"产物
# (首轮主定妆失败 → 后续降级文生图)→ 必须重跑,否则五官不锚定
_anchor_f = base / "front_half.png"
_stale = (not v["is_anchor"]) and _anchor_f.exists() \
and dst.stat().st_mtime < _anchor_f.stat().st_mtime
if not _stale:
logger.info(" ♻️ 跳过已存在:%s", dst.name)
_q = None
try:
from character_generator.quality_check import check_reference_quality
_q = check_reference_quality(str(dst)).get("edge_std")
except Exception:
pass
out["views"][vid] = {"path": str(dst), "ok": True, "skipped": True,
"quality": _q, "is_anchor": v["is_anchor"]}
out["skipped"] += 1
if v["is_anchor"] and not anchor_path:
anchor_path = str(dst)
continue
logger.info(" 🔁 %s 早于主定妆生成(未锚定五官)→ 重新生成", dst.name)
if v["is_anchor"]:
if anchor_path and os.path.exists(str(anchor_path)):
import shutil as _sh
_sh.copy2(str(anchor_path), dst)
logger.info(" ♻️ 主定妆复用指定参考图:%s", Path(str(anchor_path)).name)
out["views"][vid] = {"path": str(dst), "ok": True, "reused": True,
"is_anchor": True}
out["anchor"] = str(dst)
continue
ref = "" # 无参考图 → 文生图
else:
ref = anchor_path or (str(base / "front_half.png")
if (base / "front_half.png").exists() else "")
if not ref:
logger.warning(" ⚠️ 无主定妆参考图,%s 将走文生图(五官可能漂移)", v["label"])
logger.info(" 🎨 [%d/%d] %s(%s)", idx, len(targets), v["label"],
"图生图·锚定主定妆" if ref else "文生图")
try:
r = generate_grid(desc, str(dst), lang="zh", mode="portrait",
ref_image=ref, view=vid, force=True)
path = r.get("grid") or str(dst)
ok = bool(path and os.path.exists(path))
q = None
if ok:
try:
from character_generator.quality_check import check_reference_quality
q = check_reference_quality(path).get("edge_std")
except Exception:
q = None
out["views"][vid] = {"path": path, "ok": ok, "quality": q,
"is_anchor": v["is_anchor"], "used_ref": bool(ref)}
if ok:
out["done"] += 1
if v["is_anchor"]:
anchor_path = path
out["anchor"] = path
except Exception as e:
logger.error(" ❌ %s 生成失败: %s", v["label"], str(e)[:120])
out["views"][vid] = {"path": "", "ok": False, "error": str(e)[:200],
"is_anchor": v["is_anchor"]}
_t.sleep(0.5)
# ★ 自动入库:把 5 张写进角色资产(否则用户只能在缓存目录里找,看不到)
if character_name:
try:
out["saved_to"] = _save_set_to_assets(character_name, out, base)
except Exception as e:
logger.warning(" ⚠️ 套装入库失败(文件仍在 cache 目录): %s", e)
out["save_error"] = str(e)[:200]
_cb(100, "完成")
okn = sum(1 for x in out["views"].values() if x.get("ok"))
out["note"] = (f"套装完成 {okn}/{len(targets)} 张"
+ ("(含已有文件)" if out["skipped"] else "")
+ (f";已入库「{character_name}」" if out.get("saved_to") else "")
+ ";参考图只用于锁定人物,环境交给分镜描述")
logger.info(" 📸 %s", out["note"])
return out
def _save_set_to_assets(character_name: str, out: dict, base: Path) -> dict:
"""把套装产物写入角色资产库(按名称查找/创建角色 + 复制 5 张 + 更新 reference_images)
返回 {aid, added: [...]}
"""
import shutil as _sh
from datetime import datetime as _dt
import sys as _sys
_root = str(Path(__file__).resolve().parent.parent)
if _root not in _sys.path:
_sys.path.insert(0, _root)
from assets_manager import AssetStore
st = AssetStore("characters")
it = st.get(character_name) # 先按 id 找
if not it: # 再按 name 找
for x in st.list():
if (x.get("name") or "").strip() == character_name.strip():
it = x
break
aid = it.get("id") if it else character_name
if not it: # 都没有 → 新建
st.create({"id": aid, "name": character_name,
"description": "由「📸 生成套装」创建的短剧角色",
"tags": ["套装", "短剧"]})
it = st.get(aid) or {"id": aid}
logger.info(" 🆕 已创建角色: %s", character_name)
target = Path(st.kind_dir(aid))
target.mkdir(parents=True, exist_ok=True)
refs = dict((it or {}).get("reference_images") or {})
SLOT = {"front_half": "front", "side_45": "side_45", "profile": "profile",
"face_closeup": "face_closeup", "shoulder_look": "shoulder_look",
"full_body_front": "full_body_front", "full_body_3q": "full_body_3q"}
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
slot = SLOT.get(vid, vid)
if slot == "front" and refs.get("front"): # 覆盖 front 前备份
old = target / Path(str(refs["front"])).name
if old.exists():
bak = old.with_name(f"{old.stem}_orig{old.suffix}")
if not bak.exists():
_sh.copy2(old, bak)
dst = target / f"ref_{vid}.png"
_sh.copy2(src, dst)
refs[slot] = dst.name
added.append(f"{slot}={dst.name}")
if added:
hist = list((it or {}).get("generation_history") or [])
hist.append({"at": _dt.now().isoformat(timespec="seconds"),
"kind": "portrait_set",
"note": "五视角套装(主定妆锚定)",
"files": added})
st.update(aid, {"reference_images": refs, "generation_history": hist})
logger.info(" 💾 已入库角色「%s」:%d 张", character_name, len(added))
return {"aid": aid, "added": added}
def _generate_kling(
desc: str,
output_path: str,
lang: str = "zh",
force: bool = False,
mode: str = "standard",
ref_image: str = "",
seed: int | None = None,
view: str = "",
raw_prompt: str = "",
) -> dict:
"""
通过 Kling API 生成定妆照
mode="standard": 文生图 2x3 网格
mode="sport": I2I 4x2(ref_image 锚定人脸,8格脸部优先)
mode="portrait": **单张半身视角**(短剧角色卡套装用)
· view 指定视角:front_half / side_45 / profile / face_closeup / shoulder_look
· 传 ref_image → I2I(★ 以主定妆为参考,五官锁定)
· 不传 → 文生图
"""
import httpx
import jwt
import base64
# 缓存检查
if os.path.exists(output_path) and not force:
logger.info(f"缓存命中: {output_path}")
return {"grid": output_path, "cells": []}
cfg = load_config()
kling_cfg = cfg.get("kling", {})
api_key = (kling_cfg.get("api_key") or "").strip()
ak = kling_cfg.get("access_key", "")
sk = kling_cfg.get("secret_key", "")
base_url = (kling_cfg.get("base_url") or KLING_BASE).rstrip("/")
if not api_key and not (ak and sk):
raise ValueError("缺少 Kling API 配置(kling.api_key 或 kling.access_key+secret_key)")
# ★ 放开 I2I 限制:任何 mode 只要给了有效参考图就走图生图
# (原来写死 mode=="sport" → 套装流程的其余 4 张无法用主定妆锚定五官)
use_ref = bool(ref_image and os.path.exists(ref_image))
if raw_prompt: # ★ 直接使用调用方给的成品提示词(场景生成等)
prompt = raw_prompt
elif mode == "portrait":
from character_generator.portrait_set import build_view_prompt
prompt = build_view_prompt(view or "front_half", desc)
else:
prompt = build_grid_prompt(desc, lang, mode=mode, use_ref=use_ref)
logger.info(f"Kling 生成 [{mode}{'|I2I' if use_ref else '|T2I'}]"
f"{'|' + view if view else ''}: {prompt[:80]}...")
# 认证:优先 Bearer api-key(新版/第三方 key),回退官方 AK/SK → JWT
if api_key:
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
logger.info(" Kling 鉴权: Bearer api-key")
else:
now = int(time.time())
token = jwt.encode(
{"iss": ak, "exp": now + 1800, "nbf": now - 5},
sk, algorithm="HS256",
)
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
logger.info(" Kling 鉴权: AK/SK JWT")
if use_ref:
# ── I2I 模式:参考图 + prompt,最大化人脸一致性 ──
with open(ref_image, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
payload = {
"model": KLING_IMAGE_MODEL,
"prompt": prompt,
"image": img_b64,
"strength": 0.65, # 0.6-0.7: 保持人脸同时允许姿态变换
"aspect_ratio": _ar_for_mode(mode),
"n": 1,
}
if seed is not None:
payload["seed"] = seed
endpoint = f"{base_url}/v1/images/generations"
logger.info(f" 使用参考图 I2I: {ref_image}")
else:
# ── T2I 模式:纯文字生成 ──
payload = {
"model": KLING_IMAGE_MODEL,
"prompt": prompt,
"aspect_ratio": _ar_for_mode(mode),
"n": 1,
}
if seed is not None:
payload["seed"] = seed
endpoint = f"{base_url}/v1/images/generations"
# 带退避重试的 POST
for attempt in range(3):
resp = httpx.post(endpoint, headers=headers, json=payload, timeout=30, trust_env=False)
if resp.status_code == 429:
wait = 2 ** attempt * 15 # 15s, 30s, 60s
logger.warning(f" 429 限流,等待 {wait}s 后重试...")
time.sleep(wait)
continue
resp.raise_for_status()
break
data = resp.json()
task_id = data.get("data", {}).get("task_id", "")
if not task_id:
raise RuntimeError(f"Kling 提交失败: {data}")
logger.info(f" 任务: {task_id}, 轮询中...")
# 轮询
for i in range(40):
time.sleep(5)
q = httpx.get(f"{base_url}/v1/images/generations/{task_id}", headers=headers, timeout=15, trust_env=False)
q.raise_for_status()
status_data = q.json().get("data", {})
task_status = status_data.get("task_status", "")
if task_status == "succeed":
images = status_data.get("task_result", {}).get("images", [])
img_url = images[0].get("url", "") if images else ""
# 读取 Kling 返回的 seed(如果 API 返回了的话)
returned_seed = status_data.get("seed", seed)
if img_url:
logger.info(f" 下载中...")
_download(img_url, output_path)
# 保存 seed 到 .seed 文件,下次可直接复用
seed_file = output_path + ".seed"
with open(seed_file, "w") as f:
f.write(str(returned_seed))
logger.info(f" ✅ 网格图: {output_path} [seed={returned_seed}]")
return {"grid": output_path, "cells": [], "seed": returned_seed}
elif task_status == "failed":
raise RuntimeError(f"Kling 生成失败: {status_data.get('fail_reason', '?')}")
raise TimeoutError("Kling 生成超时(200s)")
def _download(url: str, path: str):
"""下载文件"""
import httpx
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with httpx.stream("GET", url, timeout=600) as r:
r.raise_for_status()
with open(path, "wb") as f:
for chunk in r.iter_bytes(8192):
f.write(chunk)
# ═══════════════════════════════════════════════════════════
# Grid 裁剪: 2x3 → 6 张单格图 / 3x3 → 9 张单格图
# ═══════════════════════════════════════════════════════════
# 标准 2x3 格名(行优先排列)
CELL_NAMES_2X3 = [
"fullbody_front", # R0C0 全身正面
"fullbody_side", # R0C1 全身侧面
"fullbody_threeq", # R0C2 全身 3/4
"face_neutral", # R1C0 面部-中性
"face_smile", # R1C1 面部-微笑
"face_angry", # R1C2 面部-愤怒
]
# 体育 4x2 格名(行优先排列,8格)
# Row 0: 全身正面 | 全身侧面 | 脸部正面中性 | 四分之三左侧
# Row 1: 纯左侧轮廓 | 四分之三右侧 | 脸部正面专注 | 脸部正面发力
CELL_NAMES_4X2 = [
"body_front", # R0C0 全身正面
"body_side", # R0C1 全身侧面
"face_front", # R0C2 正面中性
"face_3qL", # R0C3 四分之三左侧
"face_profile", # R1C0 纯左侧轮廓
"face_3qR", # R1C1 四分之三右侧
"face_focus", # R1C2 正面专注
"face_intense", # R1C3 正面发力
]
# 向后兼容旧名称映射
CELL_NAMES_3X3 = [ # deprecated,保留兼容
"fullbody_front",
"fullbody_side",
"fullbody_threeq",
"face_neutral",
"face_focused",
"face_intense",
"sport_run",
"sport_jump",
"sport_stance",
]
# 向后兼容
CELL_NAMES = CELL_NAMES_2X3
def extract_cells(grid_path: str, output_dir: str = "", mode: str = "standard") -> dict[str, str]:
"""
从网格图裁剪出单格参考图
参数:
grid_path: 网格图路径
output_dir: 输出目录(默认与网格图同目录)
mode: "standard"=2x3(6格) / "sport"=4x2(8格,脸部优先) / "face4x2"=4x2(8格)
返回:
{"body_front": str, ..., "face_intense": str, ...}
"""
try:
from PIL import Image
except ImportError:
logger.warning("PIL 未安装,跳过 cells 裁剪")
return {}
if not os.path.exists(grid_path):
logger.error(f"网格图不存在: {grid_path}")
return {}
if not output_dir:
output_dir = os.path.dirname(grid_path) or CACHE_DIR
os.makedirs(output_dir, exist_ok=True)
img = Image.open(grid_path)
w, h = img.size
# "sport" 和 "face4x2" 均使用 4x2 布局(8格,脸部优先)
if mode in ("face4x2", "sport"):
rows, cols = 2, 4
cell_names = CELL_NAMES_4X2
else:
# 2 行 3 列(standard)
rows, cols = 2, 3
cell_names = CELL_NAMES_2X3
col_w = w // cols
row_h = h // rows
base_name = os.path.splitext(os.path.basename(grid_path))[0]
cells = {}
for idx, cell_name in enumerate(cell_names):
col = idx % cols
row = idx // cols
left = col * col_w
upper = row * row_h
right = left + col_w
lower = upper + row_h
cell_img = img.crop((left, upper, right, lower))
cell_path = os.path.join(output_dir, f"{base_name}_{cell_name}.png")
cell_img.save(cell_path, "PNG")
cells[cell_name] = cell_path
logger.info(f" 裁剪: [{row},{col}] {cell_name} → {cell_path}")
logger.info(f" 共裁剪 {len(cells)} 格 ({mode} mode)")
return cells
def _export_face_anchor(cells: dict[str, str], output_dir: str) -> str:
"""
从已裁剪的 cells 中提取 face_front,生成 face_anchor.png
face_anchor.png 是后续所有场景 I2I 的脸部固定锚点
返回: face_anchor.png 路径,未找到 face_front 时返回空字符串
"""
face_key = "face_front"
if face_key not in cells:
logger.warning(f" face_anchor: 未找到 {face_key} cell,跳过")
return ""
import shutil
src = cells[face_key]
dst = os.path.join(output_dir, "face_anchor.png")
try:
shutil.copy2(src, dst)
logger.info(f" ✅ face_anchor: {dst}")
return dst
except Exception as e:
logger.warning(f" face_anchor 复制失败: {e}")
return ""
# ═══════════════════════════════════════════════════════════
# Kling I2I 变体生成
# ═══════════════════════════════════════════════════════════
def generate_variations(
source_image: str,
output_dir: str = "",
variations: int = 3,
prompt: str = "",
force: bool = False,
) -> list[str]:
"""
基于参考图通过 Kling I2I 生成多个变体
参数:
source_image: 参考图路径
output_dir: 输出目录
variations: 变体数量
prompt: 可选,变体 prompt 描述
force: 强制重新生成
返回:
变体图片路径列表
"""
try:
import httpx
import jwt
from PIL import Image
except ImportError:
logger.warning("PIL 未安装,跳过变体生成")
return []
if not os.path.exists(source_image):
logger.error(f"参考图不存在: {source_image}")
return []
if not output_dir:
output_dir = os.path.dirname(source_image) or CACHE_DIR
os.makedirs(output_dir, exist_ok=True)
base_name = os.path.splitext(os.path.basename(source_image))[0]
# 缓存检查
existing = []
for v in range(variations):
vpath = os.path.join(output_dir, f"{base_name}_var{v+1}.png")
if os.path.exists(vpath) and not force:
existing.append(vpath)
if len(existing) == variations:
logger.info(f"变体缓存命中: {existing}")
return existing
cfg = load_config()
kling_cfg = cfg.get("kling", {})
ak = kling_cfg.get("access_key", "")
sk = kling_cfg.get("secret_key", "")
if not ak or not sk:
raise ValueError("缺少 Kling API 配置")
# 读取并 base64 编码参考图
with open(source_image, "rb") as f:
import base64
img_b64 = base64.b64encode(f.read()).decode()
prompt_text = prompt or f"Character {base_name} in different pose, consistent design, same style"
now = int(time.time())
token = jwt.encode(
{"iss": ak, "exp": now + 1800, "nbf": now - 5},
sk, algorithm="HS256",
)
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
results = []
# Kling I2I 单次只能生成一张
for v in range(variations):
vpath = os.path.join(output_dir, f"{base_name}_var{v+1}.png")
if os.path.exists(vpath) and not force:
results.append(vpath)
continue
payload = {
"model": "kling-v1",
"prompt": prompt_text,
"image": img_b64,
"strength": 0.7, # 与原图的相似度
}
resp = httpx.post(f"{KLING_BASE}/v1/images/generations",
headers=headers, json=payload, timeout=30, trust_env=False)
resp.raise_for_status()
data = resp.json()
task_id = data.get("data", {}).get("task_id", "")
if not task_id:
raise RuntimeError(f"Kling I2I 提交失败: {data}")
# 轮询
for i in range(30):
time.sleep(5)
q = httpx.get(f"{KLING_BASE}/v1/images/generations/{task_id}", headers=headers, timeout=15, trust_env=False)
q.raise_for_status()
status_data = q.json().get("data", {})
task_status = status_data.get("task_status", "")
if task_status == "succeed":
images = status_data.get("task_result", {}).get("images", [])
img_url = images[0].get("url", "") if images else ""
if img_url:
_download(img_url, vpath)
results.append(vpath)
logger.info(f" ✅ 变体 {v+1}: {vpath}")
break
elif task_status == "failed":
raise RuntimeError(f"Kling I2I 失败: {status_data.get('fail_reason', '?')}")
return results
# ═══════════════════════════════════════════════════════════
# 全流程: 从角色描述到注册中心
# ═══════════════════════════════════════════════════════════
def generate_and_register(
character_name: str,
description: str,
registry_path: str = "",
force: bool = False,
mode: str = "standard",
ref_image: str = "",
seed: int | None = DEFAULT_SEED,
) -> dict:
"""
全流程: 生成定妆照 → 裁剪 → 注册到 CharacterRegistry
参数:
character_name: 角色名(与 registry.yaml 一致)
description: 角色描述
registry_path: registry.yaml 路径(可选)
force: 强制重新生成
mode: "standard"=2x3 / "sport"=4x2(脸部优先) / "face4x2"=4x2
ref_image: I2I 参考图路径(可选)
seed: 固定种子(默认 42)
返回:
{
"character": "...",
"grid": "path/to/grid.png",
"cells": {"body_front": "...", ...},
"registry_updated": True
}
"""
# 1. 生成网格图
output_dir = str(CACHE_DIR / character_name)
os.makedirs(output_dir, exist_ok=True)
grid_path = os.path.join(output_dir, f"{character_name}_grid.png")
grid_result = generate_grid(
desc=description,
output_path=grid_path,
force=force,
do_extract=True,
mode=mode,
ref_image=ref_image,
seed=seed,
)
grid_path = grid_result["grid"]
used_seed = grid_result.get("seed")
cells = grid_result.get("cells", {})
face_anchor = grid_result.get("face_anchor", "")
# 3. 同步到 CharacterRegistry
try:
from character_registry import CharacterRegistry
if registry_path:
registry = CharacterRegistry(registry_path)
else:
# 查找项目 registry.yaml
registry = CharacterRegistry(
os.path.join(os.path.dirname(__file__), "character_registry", "registry.yaml")
)
# 构建参考图字典
ref_images = {
"grid": grid_path,
"face_anchor": face_anchor, # 场景 I2I 专用脸部锚点(从 face_front 提取)
"portrait": cells.get("face_front", ""),
"seed": used_seed,
}
# 体型参考
body = {}
for cell_name, cell_path in cells.items():
if cell_name.startswith("body_"):
body[cell_name.replace("body_", "")] = cell_path
if body:
ref_images["body"] = body
# 表情参考(自动捕获所有 face_* 格)
expressions = {}
for cell_name, cell_path in cells.items():
if cell_name.startswith("face_"):
emotion = cell_name.replace("face_", "")
expressions[emotion] = cell_path
# 表情参考
expressions = {}
for cell_name, cell_path in cells.items():
if cell_name.startswith("face_"):
emotion = cell_name.replace("face_", "")
expressions[emotion] = cell_path
if expressions:
ref_images["expressions"] = expressions
# 更新 registry
registry.update_reference_images(character_name, ref_images)
logger.info(f"✅ 已同步到 CharacterRegistry: {character_name}")
registry_updated = True
except Exception as e:
logger.warning(f"同步到 CharacterRegistry 失败: {e}(不影响定妆照生成)")
registry_updated = False
return {
"character": character_name,
"grid": grid_path,
"cells": cells,
"registry_updated": registry_updated,
}
# ═══════════════════════════════════════════════════════════
# 持久化角色描述块
# ═══════════════════════════════════════════════════════════
CHARACTER_DB_DIR = CACHE_DIR / "characters"
CHARACTER_DB_DIR.mkdir(parents=True, exist_ok=True)
def save_character(name: str, desc: str, grid_path: str = "") -> str:
"""保存角色描述到本地库"""
record = {
"name": name,
"description": desc,
"grid_path": grid_path,
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
safe_name = name.replace(" ", "_").replace("/", "_")
path = CHARACTER_DB_DIR / f"{safe_name}.json"
path.write_text(json.dumps(record, indent=2, ensure_ascii=False), encoding="utf-8")
logger.info(f"角色已保存: {path}")
return str(path)
def load_character(name: str) -> dict | None:
"""加载角色描述"""
safe_name = name.replace(" ", "_").replace("/", "_")
path = CHARACTER_DB_DIR / f"{safe_name}.json"
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
return None
def list_characters() -> list[dict]:
"""列出所有保存的角色"""
results = []
for f in sorted(CHARACTER_DB_DIR.glob("*.json")):
try:
data = json.loads(f.read_text(encoding="utf-8"))
results.append(data)
except (json.JSONDecodeError, OSError):
pass
return results
# ═══════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════
def cli(args: list[str] | None = None):
"""CLI 入口"""
import argparse
parser = argparse.ArgumentParser(description="定妆照 Grid Method 生成")
parser.add_argument("--desc", required=True, help="角色描述")
parser.add_argument("--output", default="", help="输出路径")
parser.add_argument("--lang", default="zh", choices=["zh", "en"], help="语言")
parser.add_argument("--extract", action="store_true", help="裁出 6 张单格图")
parser.add_argument("--save", default="", help="保存到角色库 (指定角色名)")
parser.add_argument("--list", action="store_true", help="列出所有已保存角色")
parser.add_argument("--load", default="", help="加载已保存角色的描述")
parser.add_argument("--force", action="store_true", help="强制重新生成")
parser.add_argument("--register", default="", help="同步到 CharacterRegistry (指定角色名)")
parser.add_argument("--char-name", default="", help="角色名(用于注册时)")
parser.add_argument("--seed", type=int, default=None,
help="固定 Kling 随机种子(如 42),确保结果可复现;不指定则随机生成")
parser.add_argument("--mode", default="standard",
choices=["standard", "sport", "face4x2"],
help="布局模式: standard=2x3, sport=4x2(脸部优先), face4x2=4x2")
parser.add_argument("--ref", dest="ref_image", default="",
help="I2I 参考图路径(sport/face4x2 模式下使用,提升人脸一致性)")
parsed = parser.parse_args(args)
if parsed.list:
chars = list_characters()
if not chars:
print("暂无已保存角色")
return
print(f"已保存角色 ({len(chars)}):")
for c in chars:
print(f" {c['name']:20s} → {c.get('grid_path', '无网格图')}")
return
if parsed.load:
char = load_character(parsed.load)
if char:
print(f"角色: {char['name']}")
print(f"描述: {char['description']}")
if char.get("grid_path"):
print(f"网格图: {char['grid_path']}")
parsed.desc = char["description"]
if not parsed.char_name:
parsed.char_name = char["name"]
if parsed.register:
# 全流程: 生成 + 裁剪 + 注册
char_name = parsed.register
result = generate_and_register(
character_name=char_name,
description=parsed.desc,
force=parsed.force,
mode=parsed.mode,
ref_image=parsed.ref_image,
seed=parsed.seed,
)
print(f"\n✅ 定妆照: {result['grid']}")
for cell_name, cell_path in result.get("cells", {}).items():
print(f" {cell_name}: {cell_path}")
if result.get("registry_updated"):
print(f"✅ 已同步到 CharacterRegistry: {char_name}")
else:
print(f"⚠️ 定妆照已生成但未同步到注册中心(检查 registry.yaml 路径)")
elif parsed.desc:
result = generate_grid(
desc=parsed.desc,
output_path=parsed.output,
lang=parsed.lang,
do_extract=parsed.extract,
force=parsed.force,
mode=parsed.mode,
ref_image=parsed.ref_image,
seed=parsed.seed,
)
print(f"\n✅ 定妆照: {result['grid']}")
if result.get("seed") is not None:
print(f" seed: {result['seed']} (可复现,存入 {result['grid']}.seed)")
if parsed.extract:
# 实际执行裁剪
output_dir = os.path.dirname(result['grid']) or CACHE_DIR
cells = extract_cells(result['grid'], output_dir)
for cell_name, cell_path in cells.items():
print(f" {cell_name}: {cell_path}")
# face_anchor 已在 generate_grid 内生成,这里单独提示
face_anchor = result.get("face_anchor", "")
if face_anchor:
print(f" face_anchor: {face_anchor} ← 场景 I2I 专用锚点")
# 保存到角色库
save_name = parsed.save or parsed.char_name or \
f"char_{hashlib.md5(parsed.desc.encode()).hexdigest()[:8]}"
save_character(save_name, parsed.desc, result["grid"])
print(f"✅ 角色已保存: {save_name}")
if __name__ == "__main__":
cli()
def build_reference_line(name: str = "") -> str:
"""生成「角色引用线」—— 在使用定妆照作为参考图时**必须**附上
⚠️ 研究结论(nigo-studio):必须明确告诉模型"不要用参考图的背景和分格布局",
否则背景与格子会被带进生成结果 —— 这正是我们之前"背景嵌入"的机制之一。
"""
n = (name or "该角色").strip()
return (f"@图1 定义 {n} 的面部特征、发型与服装。"
f"**不要使用图中的灰色背景,也不要使用分格布局**。")
def build_positive_state(remove_items: str = "") -> str:
"""把「移除某物」改写成**正向状态描述**(研究结论:用正向,不用否定)
反例:"不要戴耳机" → 模型可能反而画出耳机
正例:"光着头,只有自然头发"
"""
m = {
"耳机": "光着头,只有自然头发",
"帽子": "光着头,只有自然头发",
"眼镜": "裸眼,无任何镜片遮挡",
"手套": "双手裸露无遮挡",
"口罩": "面部完全露出,无遮挡",
"护目镜": "裸眼,无护目装备",
}
for k, v in m.items():
if k in (remove_items or ""):
return v
return ""