移植自 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 带网关原话 / 正常返回)。
1153 lines
46 KiB
Python
1153 lines
46 KiB
Python
#!/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 ""
|