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

791 lines
27 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.
"""
AVE character_portrait — 逐帧参考链定妆照生成(v2.0 重写)
核心原则:
⚠️ 前一轮的问题:所有角度共用同一张基准图作 I2I 参考 → 角色漂移 + 方向混乱
✅ 本方案:逐帧参考链(Sequential Reference Chain)
图1(正面T2I) → 作为图2参考 → 图2(右半侧I2I) → 作为图3参考 → 图3(右侧面I2I) → 作为图4参考 → 图4(背面I2I)
每一步只做"小幅姿态变化",最大限度保持角色一致性
流程:
Phase 1: Kling T2I (kling-v3, 高质量) 生成正面全身基准图
Phase 2: 逐帧 I2I 参考链,小步幅递增
图1(正面) → 图2(右侧45°半侧面, I2I参考图1)
图2(右侧45°) → 图3(纯右侧面, I2I参考图2)
图3(右侧面) → 图4(背面, I2I参考图3)
Phase 3: 注册到 CharacterRegistry(face_anchor + 各角度图)
参考强度逐级递增:0.70 → 0.75 → 0.70(近端参考 = 更高一致性)
用法:
python character_portrait.py --character "陆晨晖" --desc "..."
API调用:
POST /api/characters/generate-portrait
{"character_name": "陆晨晖", "description": "...", "force": false}
"""
import sys
import os
import json
import time
import base64
import hashlib
from pathlib import Path
path = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, path)
from lib.config import load_config
from lib.logger import get_logger
logger = get_logger('character_portrait')
environ = os.environ.get
home = os.path.expanduser('~')
# ── 缓存目录 ──
CACHE_DIR = environ(
'AVE_CACHE_DIR',
os.path.join(home, 'workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait'),
)
os.makedirs(CACHE_DIR, exist_ok=True)
# ── Kling API ──
KLING_BASE = 'https://api-beijing.klingai.com'
BASELINE_MODEL = 'kling-v3'
ANGLE_MODEL = 'kling-v1'
DEFAULT_SEED = 42
# Phase 2 参考链强度(逐级递增保持一致性)
REF_STRENGTH_CHAIN = (0.70, 0.75, 0.70)
# Phase 1: 基准图提示词模板
BASELINE_PROMPT_TEMPLATE = (
'一张超写实全身人像定妆照,必须正面朝向镜头,如证件照构图。'
'双脚自然分开与肩同宽,双手自然垂于身体两侧,面部正对相机,双耳左右对称可见。'
'严禁身体转向侧面或半侧面,不得有角度偏移。'
'{desc}'
'干净的浅灰色无缝影棚背景,左右双柔光箱对称布光,面部无阴影,'
'超高清8k,真实皮肤纹理,织物细节,发丝清晰可见,专业人像摄影质感。'
)
# Phase 2: 逐帧参考链定义
# 每帧: key(存储key), label(显示名), ref_source(参考图来源), strength(I2I强度), prompt(生成提示词)
ANGLE_CHAIN = [
{
'key': 'right_three_quarter',
'label': '右侧45°半侧面',
'ref_source': 'baseline',
'strength': 0.70,
'prompt': (
'与参考图完全相同的人物。全身镜头,四分之三右侧面,身体转向右侧约45度。'
'面部朝向镜头右前方,展现右侧面部特征。站姿放松自然。'
'保持完全一致的着装、配饰、发型和面部特征。'
'浅灰色影棚背景,超写实,专业人像布光,真实皮肤纹理,高清细节。'
),
},
{
'key': 'right_side',
'label': '纯右侧面',
'ref_source': 'right_three_quarter',
'strength': 0.75,
'prompt': (
'与参考图完全相同的人物。全身镜头,纯右侧面视角,人物面向右侧90度。'
'镜头从人物正右侧拍摄,展现鼻梁、嘴唇和下颌线的侧面轮廓。'
'着装与参考图完全一致,发型侧面层次清晰可见。'
'浅灰色影棚背景,超写实,轮廓清晰,高清,侧光勾勒面部轮廓。'
),
},
{
'key': 'back',
'label': '背面',
'ref_source': 'right_side',
'strength': 0.70,
'prompt': (
'与参考图完全相同的人物。全身镜头,人物背对镜头,从正后方拍摄。'
'展现后脑勺的发型层次感,肩背和腰线的服装贴合线条。'
'从背面可见服装背面的褶皱纹理和鞋子的后跟细节。'
'浅灰色影棚背景,超写实,真实皮肤纹理,高清8k。'
),
},
]
# ══════════════════════════════════════════════════════
# Kling 辅助工具
# ══════════════════════════════════════════════════════
def _get_kling_credentials() -> dict:
"""从 AVE 配置加载 Kling API 凭据。
Returns:
{"access_key": str, "secret_key": str}
Raises:
ValueError: 缺少配置
"""
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 配置 (kling.access_key / kling.secret_key)')
return {'access_key': ak, 'secret_key': sk}
def _jwt_token(ak: str, sk: str) -> str:
"""生成 Kling JWT 认证 token。
Args:
ak: Access Key
sk: Secret Key
Returns:
JWT token 字符串
"""
try:
import jwt as pyjwt
except ImportError:
raise ImportError('需要 PyJWT 库: pip install pyjwt')
now = int(time.time())
return pyjwt.encode(
{'iss': ak, 'exp': now + 1800, 'nbf': now},
sk,
algorithm='HS256',
)
def _download(url: str, path: str):
"""下载文件。
Args:
url: 下载 URL
path: 保存路径
"""
import httpx
os.makedirs(os.path.dirname(path), 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)
# ══════════════════════════════════════════════════════
# Kling T2I / I2I 核心
# ══════════════════════════════════════════════════════
def _t2i(
image_path: str,
prompt: str,
seed: int = 0,
model: str = 'kling-v3',
force: bool = False,
) -> dict:
"""Kling 文生图 (T2I)。
参数:
model: 默认 BASELINE_MODEL (kling-v3),可覆盖
force: 强制重新生成,跳过缓存
返回: {"path": str, "seed": int}
"""
# 检查缓存
if not force and os.path.exists(image_path):
seed_file = f'{image_path}.seed'
used_seed = seed
if os.path.exists(seed_file):
used_seed = int(open(seed_file).read().strip())
logger.info(f'T2I 缓存命中: {image_path}')
return {'path': image_path, 'seed': used_seed}
from lib.config import load_config
import httpx
import jwt
# 鉴权
creds = _get_kling_credentials()
ak = creds['access_key']
sk = creds['secret_key']
token = _jwt_token(ak, sk)
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
# 请求
model_name = model or BASELINE_MODEL
payload = {
'model': model_name,
'prompt': prompt,
'aspect_ratio': '9:16',
'n': 1,
}
if seed:
payload['seed'] = seed
# 提交
for attempt in range(3):
try:
resp = httpx.post(
f'{KLING_BASE}/v1/images/generations',
headers=headers,
json=payload,
timeout=30,
)
if resp.status_code == 429:
wait = 15 * (attempt + 1)
logger.warning(f' 429 限流,等待 {wait}s...')
time.sleep(wait)
continue
resp.raise_for_status()
data = resp.json()
task_id = data.get('data', {}).get('task_id', '')
if not task_id:
raise RuntimeError(f'Kling T2I 提交失败: {data}')
break
except Exception as e:
if attempt == 2:
raise
time.sleep(5)
else:
raise RuntimeError('Kling T2I 提交失败(重试耗尽)')
# 轮询
for i in range(40):
time.sleep(5)
try:
q = httpx.get(
f'{KLING_BASE}/v1/images/generations/{task_id}',
headers=headers,
timeout=30,
)
q.raise_for_status()
status_data = q.json()['data']
task_status = status_data.get('task_status', '')
if task_status == 'succeed':
images = status_data.get('task_result', {}).get('images', [])
if not images:
raise RuntimeError('Kling T2I 成功但无图像')
img_url = images[0].get('url', '')
returned_seed = images[0].get('seed', seed)
# 下载
logger.info(f' T2I 完成,下载中...')
_download(img_url, image_path)
# 写种子文件
with open(f'{image_path}.seed', 'w') as f:
f.write(str(returned_seed))
logger.info(f' ✅ 基准图: {image_path} [seed={returned_seed}]')
return {'path': image_path, 'seed': returned_seed}
elif task_status == 'failed':
reason = status_data.get('fail_reason', '?')
raise RuntimeError(f'T2I 生成失败: {reason}')
except Exception as e:
if '轮询' in str(e):
logger.warning(f' {e}')
break
logger.warning(f' 轮询异常: {e}')
time.sleep(5)
raise TimeoutError('T2I 生成超时(200s)')
def _i2i(
output_path: str,
ref_image: str,
prompt: str,
seed: int = 0,
strength: float = 0.65,
model: str = 'kling-v1',
force: bool = False,
) -> dict:
"""Kling 图生图 (I2I),基于参考图生成。
参数:
strength: 参考强度,默认 0.65
model: 模型,默认 ANGLE_MODEL (kling-v1)
force: 强制重新生成
返回: {"path": str, "seed": int}
"""
# 检查缓存
if not force and os.path.exists(output_path):
seed_file = f'{output_path}.seed'
used_seed = seed
if os.path.exists(seed_file):
used_seed = int(open(seed_file).read().strip())
logger.info(f'I2I 缓存命中: {output_path}')
return {'path': output_path, 'seed': used_seed}
import httpx
import jwt
# 鉴权
creds = _get_kling_credentials()
ak = creds['access_key']
sk = creds['secret_key']
token = _jwt_token(ak, sk)
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
# 读取参考图
if not os.path.exists(ref_image):
raise FileNotFoundError(f'I2I 参考图不存在: {ref_image}')
with open(ref_image, 'rb') as f:
img_b64 = base64.b64encode(f.read()).decode('utf-8')
model_name = model or ANGLE_MODEL
ref_strength = strength or 0.7
payload = {
'model': model_name,
'prompt': prompt,
'image': img_b64,
'strength': ref_strength,
'aspect_ratio': '9:16',
'n': 1,
}
if seed:
payload['seed'] = seed
# 提交
for attempt in range(3):
try:
resp = httpx.post(
f'{KLING_BASE}/v1/images/generations',
headers=headers,
json=payload,
timeout=30,
)
if resp.status_code == 429:
wait = 15 * (attempt + 1)
logger.warning(f' 429 限流,等待 {wait}s...')
time.sleep(wait)
continue
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}')
break
except Exception as e:
if attempt == 2:
raise
time.sleep(5)
else:
raise RuntimeError('Kling I2I 提交失败(重试耗尽)')
# 轮询
for i in range(40):
time.sleep(5)
try:
q = httpx.get(
f'{KLING_BASE}/v1/images/generations/{task_id}',
headers=headers,
timeout=30,
)
q.raise_for_status()
status_data = q.json()['data']
task_status = status_data.get('task_status', '')
if task_status == 'succeed':
images = status_data.get('task_result', {}).get('images', [])
if not images:
raise RuntimeError('Kling I2I 成功但无图像')
img_url = images[0].get('url', '')
returned_seed = images[0].get('seed', seed)
# 下载
logger.info(f' I2I 完成,下载中...')
_download(img_url, output_path)
# 写种子文件
with open(f'{output_path}.seed', 'w') as f:
f.write(str(returned_seed))
logger.info(f' ✅ 角度图: {output_path} [seed={returned_seed}]')
return {'path': output_path, 'seed': returned_seed}
elif task_status == 'failed':
reason = status_data.get('fail_reason', '?')
raise RuntimeError(f'I2I 生成失败: {reason}')
except Exception as e:
if '轮询' in str(e):
logger.warning(f' {e}')
break
logger.warning(f' 轮询异常: {e}')
time.sleep(5)
raise TimeoutError('I2I 生成超时(200s)')
# ══════════════════════════════════════════════════════
# 主流程
# ══════════════════════════════════════════════════════
def generate_baseline(
description: str,
char_name: str,
force: bool = False,
seed: int = 0,
ref_image: str = '',
) -> dict:
"""Phase 1: 生成正面全身基准图。
策略(v2.2):
1. 优先用真实照片 ref_image 做 I2I(真实照片→影棚风格,强度 0.85 保留面部)
2. 无参考照时回退 T2I (kling-v3)
参数:
ref_image: 真实参考照路径。有则走 I2I,无则走 T2I
返回:
{"baseline": "path/to/baseline.png", "seed": int, "method": "i2i|t2i"}
"""
output_dir = os.path.join(CACHE_DIR, char_name)
Path(output_dir).mkdir(parents=True, exist_ok=True)
baseline_path = os.path.join(output_dir, 'baseline.png')
seed_file = f'{baseline_path}.seed'
# 缓存检查
if not force and os.path.exists(baseline_path):
cached_seed = seed
if os.path.exists(seed_file):
cached_seed = int(open(seed_file).read().strip())
logger.info(f'基准图缓存命中: {baseline_path}')
return {'baseline': baseline_path, 'seed': cached_seed, 'method': 'cache'}
if ref_image and os.path.exists(ref_image):
# 策略 1: 真实照片 I2I → 影棚风格基准图
logger.info(f'=== Phase 1: 参考照 I2I 生成基准图 ({char_name}) ===')
logger.info(f' 参考照: {ref_image}')
prompt = (
'与参考照片完全相同的人物。全身镜头,正面站立直视镜头,如证件照构图。'
'浅灰色无缝影棚背景,左右双柔光箱对称布光,面部无阴影。'
'超高清8k,真实皮肤纹理,织物细节。'
)
result = _i2i(
baseline_path,
ref_image,
prompt,
seed=seed,
strength=0.85,
model=ANGLE_MODEL,
force=force,
)
return {
'baseline': result['path'],
'seed': result['seed'],
'method': 'i2i',
}
else:
# 策略 2: 纯 T2I
logger.info(f'=== Phase 1: T2I 生成基准图 ({char_name}) [kling-v3] ===')
prompt = BASELINE_PROMPT_TEMPLATE.format(desc=description)
result = _t2i(baseline_path, prompt, seed=seed, model=BASELINE_MODEL, force=force)
return {
'baseline': result['path'],
'seed': result['seed'],
'method': 't2i',
}
def generate_sequential_chain(
baseline_path: str,
char_name: str,
force: bool = False,
seed: int = 0,
) -> dict:
"""Phase 2: 逐帧参考链生成多角度定妆照。
v2.0 核心变更(对比 v1.0):
❌ v1.0 问题: 所有角度都用同一张 baseline 作 I2I 参考
→ 从正面直接跳到90°侧面,姿态变化太大,角色容易漂移
✅ v2.0 方案: 逐帧参考链
图1(正面) → 图2(右侧45°) → 图3(纯右侧面) → 图4(背面)
每张图都用上一张图作 I2I 参考,只做小幅姿态变化
返回:
{
"right_three_quarter": "path/to/angle_right_three_quarter.png",
"right_side": "path/to/angle_right_side.png",
"back": "path/to/angle_back.png",
"chain": [步骤详情],
"seed": int
}
"""
output_dir = os.path.join(CACHE_DIR, char_name, 'angles')
Path(output_dir).mkdir(parents=True, exist_ok=True)
results = {}
chain_log = []
final_seed = seed
# 构建参考映射: 每个步骤的参考图来源
ref_map = {'baseline': baseline_path}
for step in ANGLE_CHAIN:
ref_map[step['key']] = os.path.join(output_dir, f'angle_{step["key"]}.png')
for i, step in enumerate(ANGLE_CHAIN):
output_path = ref_map[step['key']]
ref_image = ref_map[step['ref_source']]
step_strength = step['strength']
step_prompt = step['prompt']
logger.info(f'==================================================')
logger.info(f'Phase 2 Step {i+1}/{len(ANGLE_CHAIN)}: {step["label"]}')
logger.info(f' 参考图: {step["ref_source"]} → {step["key"]}')
logger.info(f' 强度: {step_strength}')
try:
result = _i2i(
output_path,
ref_image,
step_prompt,
seed=seed,
strength=step_strength,
model=ANGLE_MODEL,
force=force,
)
results[step['key']] = result['path']
final_seed = result.get('seed', seed)
logger.info(f' ✅ {step["label"]} [seed={final_seed}]')
chain_log.append({
'step': i + 1,
'label': step['label'],
'ref_source': step['ref_source'],
'strength': step_strength,
'output': result['path'],
'seed': final_seed,
})
except Exception as e:
logger.warning(f' ❌ {step["label"]} 生成失败: {e}')
chain_log.append({
'step': i + 1,
'label': step['label'],
'ref_source': step['ref_source'],
'strength': step_strength,
'output': None,
'error': str(e),
})
# 写 chain meta
meta_path = os.path.join(output_dir, '_chain_meta.json')
meta = {
'character': char_name,
'method': 'sequential_reference_chain_v2',
'baseline': baseline_path,
'seed': final_seed,
'chain': chain_log,
}
Path(meta_path).write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8')
return {
'right_three_quarter': results.get('right_three_quarter', ''),
'right_side': results.get('right_side', ''),
'back': results.get('back', ''),
'chain': chain_log,
'seed': final_seed,
}
def register_portraits(
char_name: str,
baseline_path: str,
angles: dict,
) -> bool:
"""Phase 3: 将定妆照注册到 CharacterRegistry。
参数:
char_name: 角色名
baseline_path: 基准图路径
angles: {"right_three_quarter": ..., "right_side": ..., "back": ...}
返回: 是否成功
"""
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from character_registry import CharacterRegistry
try:
registry_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
'character_registry',
'registry.yaml',
)
registry = CharacterRegistry()
# 注册 face_anchor
ref_images = {'face_anchor': [baseline_path], 'body': []}
# 注册 body 各角度
for key in ('right_three_quarter', 'right_side', 'back'):
if key in angles and angles[key]:
ref_images['body'].append({'path': angles[key], 'option': key, 'type': 'body'})
registry.update_reference_images(char_name, ref_images)
logger.info(f'✅ 已注册定妆照到 CharacterRegistry: {char_name}')
return True
except Exception as e:
logger.warning(f'注册到 CharacterRegistry 失败: {e}')
return False
def generate_and_register_portraits(
character_name: str,
description: str,
force: bool = False,
seed: int = 0,
ref_image: str = '',
) -> dict:
"""全流程: 基准图 → 多角度 → 注册。
参数:
character_name: 角色名(与 registry.yaml 一致)
description: 角色描述(自然语言)
force: 强制重新生成
seed: 固定种子
ref_image: 真实参考照路径。有则 I2I 生成基准图,无则 T2I
返回:
{
"character": "角色名",
"baseline": "path/to/baseline.png",
"angles": {"right_three_quarter": "...", "right_side": "...", "back": "..."},
"registry_updated": True/False,
"seed": 42,
}
"""
result = {
'character': character_name,
'baseline': '',
'angles': {},
'registry_updated': False,
'seed': seed,
}
logger.info(f'==================================================')
logger.info(f'🎯 定妆照全流程: {character_name}')
# Phase 1: 基准图
try:
logger.info('📷 Phase 1/2: 生成基准图(正面全身定妆照)...')
baseline_result = generate_baseline(
description=description,
char_name=character_name,
force=force,
seed=seed,
ref_image=ref_image,
)
baseline_path = baseline_result.get('baseline', '')
used_seed = baseline_result.get('seed', seed)
result['baseline'] = baseline_path
result['seed'] = used_seed
logger.info(f' 基准图: {baseline_path}')
logger.info(f' seed: {used_seed}')
except Exception as e:
logger.error(f' ❌ Phase 1 失败: {e}')
result['error'] = f'Phase 1 (基准图) 失败: {str(e)}'
return result
# Phase 2: 逐帧参考链
try:
logger.info('📸 Phase 2/2: 逐帧参考链生成多角度定妆照...')
logger.info(' 参考链: 正面 → 右侧45°半侧面 → 纯右侧面 → 背面')
logger.info(' 每张以上一张为 I2I 参考,强度 0.70→0.75→0.70')
angles = generate_sequential_chain(
baseline_path=baseline_path,
char_name=character_name,
force=force,
seed=used_seed,
)
result['angles'] = {
'right_three_quarter': angles.get('right_three_quarter', ''),
'right_side': angles.get('right_side', ''),
'back': angles.get('back', ''),
}
logger.info(f' 右侧45°半侧面: {result["angles"]["right_three_quarter"][:50] if result["angles"]["right_three_quarter"] else "失败"}')
logger.info(f' 纯右侧面: {result["angles"]["right_side"][:50] if result["angles"]["right_side"] else "失败"}')
logger.info(f' 背面: {result["angles"]["back"][:50] if result["angles"]["back"] else "失败"}')
except Exception as e:
logger.error(f' ❌ Phase 2 失败: {e}')
result['warning'] = f'Phase 2 (多角度) 部分失败: {str(e)}'
# Phase 3: 注册
try:
logger.info('📋 Phase 3/2: 注册到 CharacterRegistry...')
reg_ok = register_portraits(
char_name=character_name,
baseline_path=baseline_path,
angles=result['angles'],
)
result['registry_updated'] = reg_ok
logger.info(f' {"✅ 注册成功" if reg_ok else "⚠️ 注册失败"}')
except Exception as e:
logger.warning(f' 注册异常: {e}')
result['registry_updated'] = False
logger.info(f'✅ 定妆照生成完成: {character_name}')
return result
# ══════════════════════════════════════════════════════
# CLI 入口
# ══════════════════════════════════════════════════════
def cli(args: list):
"""CLI 入口。"""
import argparse
parser = argparse.ArgumentParser(description='两阶段定妆照生成(观鱼方案)')
parser.add_argument('--character', required=True, help='角色名')
parser.add_argument('--desc', type=str, default='', help='角色描述(自然语言)')
parser.add_argument('--force', action='store_true', default=False, help='强制重新生成')
parser.add_argument('--seed', type=int, default=0, help='固定种子')
parser.add_argument('--desc-file', type=str, default='', help='从文件读取角色描述')
parser.add_argument('--ref', type=str, default='', help='真实参考照路径(推荐,代替T2I)')
parsed = parser.parse_args(args)
desc = parsed.desc
if parsed.desc_file:
desc_file = Path(parsed.desc_file)
if desc_file.exists():
desc = desc_file.read_text(encoding='utf-8').strip()
result = generate_and_register_portraits(
character_name=parsed.character,
description=desc,
force=parsed.force,
seed=parsed.seed,
ref_image=parsed.ref,
)
print()
print('==================================================')
print('✅ 定妆照生成结果:')
print(f' 基准图: {result.get("baseline", "失败")}')
if result.get('angles'):
for key, label in [('right_three_quarter', '右侧45°半侧面'), ('right_side', '纯右侧面'), ('back', '背面')]:
print(f' {label}: {result["angles"].get(key, "失败")}')
print(f' seed: {result.get("seed", "N/A")}')
print(f' 已注册: {"✅" if result.get("registry_updated") else "❌"}')
if result.get('error'):
print(f' 错误: {result["error"]}')
if result.get('warning'):
print(f' 警告: {result["warning"]}')
if __name__ == '__main__':
import sys
cli(sys.argv[1:])