Files
abot/scripts/generate_dota2_douyin_images.py
liuwei 889afecde6 调整Dota2抖音生图脚本超时为180秒
- 新增脚本级默认超时常量,统一使用 180 秒\n- 修改 --timeout 默认值为 180 秒\n- 调整图片后端 timeout_seconds 的脚本兜底值为 180 秒\n- 补充中文注释,说明超时策略与行为
2026-04-30 09:11:46 +08:00

618 lines
22 KiB
Python
Raw Permalink 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
# -*- coding: utf-8 -*-
"""
批量生成 Dota2 英雄抖音图片脚本。
设计目标:
1. 这是一个一次性本地脚本,不依赖机器人运行时,直接走 HTTP 调用 OpenAI 兼容图片接口。
2. 脚本会自动从 OpenDota 拉取全部英雄列表,避免手工维护英雄名称。
3. 每个英雄默认生成 4 张图片,其中 2 张中文排版、2 张日文排版,统一使用相同的画面结构与风格模板,尽量保证成片风格一致。
4. 脚本支持断点续跑:如果目标文件已经存在,则默认跳过,避免重复计费。
"""
from __future__ import annotations
import argparse
import base64
from concurrent.futures import ThreadPoolExecutor, as_completed
import json
import os
import re
import sys
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import requests
import yaml
# 这里固定使用 OpenDota 的公开英雄接口,避免在脚本里硬编码整份英雄名单。
OPENDOTA_HERO_STATS_URL = "https://api.opendota.com/api/heroStats"
# 这里给出一个稳定的 9:16 尺寸,尽量兼容常见 OpenAI 兼容图片网关。
DEFAULT_IMAGE_SIZE = "1024x1792"
# 这里统一定义输出根目录,方便后续在一个目录里筛图、剪辑、上传抖音。
DEFAULT_OUTPUT_DIR = Path("temp") / "dota2_douyin_images"
# 这里固定每个英雄的 4 张图的语言分布,避免每次运行时还要手动指定。
DEFAULT_LANGUAGE_VARIANTS = ["zh", "zh", "ja", "ja"]
# 这里把默认并发数固定为 4满足你“开 4 个线程跑”的诉求。
DEFAULT_MAX_WORKERS = 4
# 这里把图片生成默认超时统一收敛到 180 秒:
# 1. 300 秒过长时,失败反馈会比较慢,不利于批量任务及时发现问题;
# 2. 60 秒又可能不足以覆盖部分高峰期图片生成耗时;
# 3. 180 秒作为脚本级默认值,更适合当前这条 Dota2 抖音批量生图链路。
DEFAULT_REQUEST_TIMEOUT_SECONDS = 180
def parse_args() -> argparse.Namespace:
"""解析命令行参数。"""
parser = argparse.ArgumentParser(
description="批量为 Dota2 全英雄生成抖音竖版图片。"
)
parser.add_argument(
"--config",
default="config.yaml",
help="项目根目录下的配置文件路径,默认读取 config.yaml。",
)
parser.add_argument(
"--scene",
default="image.generate",
help="LLM 场景名,默认使用 config.yaml 中的 image.generate。",
)
parser.add_argument(
"--output-dir",
default=str(DEFAULT_OUTPUT_DIR),
help="图片输出目录。",
)
parser.add_argument(
"--count-per-hero",
type=int,
default=4,
help="每个英雄生成的图片数量,默认 4 张2 张中文、2 张日文)。",
)
parser.add_argument(
"--size",
default=DEFAULT_IMAGE_SIZE,
help="图片尺寸,默认 1024x17929:16",
)
parser.add_argument(
"--quality",
default="high",
help="图片质量参数,默认 high。",
)
parser.add_argument(
"--timeout",
type=int,
default=DEFAULT_REQUEST_TIMEOUT_SECONDS,
help="单次请求超时时间(秒),默认 180 秒。",
)
parser.add_argument(
"--delay",
type=float,
default=1.5,
help="每次成功生成后的等待时间(秒),默认 1.5 秒,避免打满网关。",
)
parser.add_argument(
"--max-retries",
type=int,
default=3,
help="单张图片失败后的最大重试次数,默认 3 次。",
)
parser.add_argument(
"--hero-limit",
type=int,
default=0,
help="仅生成前 N 个英雄0 表示全部生成,便于先小范围试跑。",
)
parser.add_argument(
"--hero-filter",
default="",
help="只生成英雄名中包含该关键字的英雄,便于单独补图。",
)
parser.add_argument(
"--max-workers",
type=int,
default=DEFAULT_MAX_WORKERS,
help="并发线程数,默认 4。",
)
parser.add_argument(
"--force",
action="store_true",
help="即使目标文件已存在,也强制重新生成。",
)
return parser.parse_args()
def load_yaml_config(config_path: str) -> Dict[str, Any]:
"""读取 YAML 配置文件。"""
with open(config_path, "r", encoding="utf-8") as file_obj:
return yaml.safe_load(file_obj) or {}
def resolve_image_backend(config_data: Dict[str, Any], scene_name: str) -> Dict[str, Any]:
"""
根据 scene 解析图片后端配置。
这里故意只实现本脚本需要的最小能力:
1. 先用 llm.scenes 把 scene 映射到 backend 名;
2. 再从 llm.backends 里取出接口配置;
3. 保持脚本简单直接,不引入项目运行时数据库逻辑。
"""
llm_config = config_data.get("llm", {}) or {}
scenes = llm_config.get("scenes", {}) or {}
backends = llm_config.get("backends", {}) or {}
backend_name = str(scenes.get(scene_name) or "").strip()
if not backend_name:
raise ValueError(f"未在 config.yaml 的 llm.scenes 中找到场景: {scene_name}")
backend_config = backends.get(backend_name, {}) or {}
if not backend_config:
raise ValueError(f"未在 config.yaml 的 llm.backends 中找到后端: {backend_name}")
return {
"backend_name": backend_name,
"provider": str(backend_config.get("provider") or "").strip(),
"api_base_url": str(backend_config.get("api_base_url") or backend_config.get("base_url") or "").strip(),
"api_key": str(backend_config.get("api_key") or "").strip(),
"model": str(backend_config.get("model") or "gpt-image-1").strip(),
"endpoint": "images/generations",
# 这里给脚本自己的后端超时兜底值也同步改成 180 秒,
# 避免配置文件里没写 timeout_seconds 时又悄悄回退到旧的 300 秒。
"timeout_seconds": int(backend_config.get("timeout_seconds") or DEFAULT_REQUEST_TIMEOUT_SECONDS),
}
def build_request_url(api_base_url: str, endpoint: str) -> str:
"""拼接图片接口 URL。"""
return f"{api_base_url.rstrip('/')}/{endpoint.lstrip('/')}"
def build_auth_header(api_key: str) -> str:
"""生成 Bearer 鉴权头。"""
normalized_api_key = str(api_key or "").strip()
if normalized_api_key.lower().startswith("bearer "):
return normalized_api_key
return f"Bearer {normalized_api_key}"
def sanitize_filename(value: str) -> str:
"""
清理文件名中的非法字符。
这里保留中英文、数字、下划线、连字符,避免 Windows 路径报错。
"""
cleaned = re.sub(r"[\\/:*?\"<>|]+", "_", value.strip())
cleaned = re.sub(r"\s+", "_", cleaned)
return cleaned or "unknown"
def fetch_dota2_heroes() -> List[Dict[str, str]]:
"""
从 OpenDota 拉取英雄信息。
返回字段说明:
1. localized_name更适合放进中文提示词里
2. english_name更适合做英文辅助描述和文件夹命名
3. hero_id方便后续写入清单或排查问题。
"""
response = requests.get(OPENDOTA_HERO_STATS_URL, timeout=60)
response.raise_for_status()
hero_rows = response.json() or []
heroes: List[Dict[str, str]] = []
for hero_row in hero_rows:
localized_name = str(hero_row.get("localized_name") or "").strip()
internal_name = str(hero_row.get("name") or "").strip()
english_name = internal_name.replace("npc_dota_hero_", "").replace("_", " ").title()
if not localized_name:
continue
heroes.append(
{
"hero_id": str(hero_row.get("id") or "").strip(),
"localized_name": localized_name,
"english_name": english_name,
}
)
# 这里按照英雄英文名排序,保证多次运行时输出顺序稳定。
heroes.sort(key=lambda item: item["english_name"])
return heroes
def resolve_text_language(image_index: int) -> str:
"""
根据图片序号确定当前文案语言。
约定规则:
1. 第 1、2 张固定走中文排版;
2. 第 3、4 张固定走日文排版;
3. 如果用户把 count 调大,则从头循环复用这套语言分布。
"""
variant_index = (image_index - 1) % len(DEFAULT_LANGUAGE_VARIANTS)
return DEFAULT_LANGUAGE_VARIANTS[variant_index]
def build_consistent_prompt(hero: Dict[str, str], image_index: int) -> str:
"""
构造统一风格的提示词。
提示词策略:
1. 固定所有英雄共用的版式、镜头语言、色彩、文字排版、雷达图要求;
2. 只替换英雄身份信息,尽量让最终成片拥有统一系列感;
3. 用“偏 JOJO 气质、夸张漫画表现”来强化目标风格。
"""
hero_name_cn = hero["localized_name"]
hero_name_en = hero["english_name"]
text_language = resolve_text_language(image_index)
if text_language == "zh":
text_language_desc = "画面中的标题、副标题、能力说明文字统一使用中文排版,字体要有热血漫画海报感,禁止出现日文。"
text_language_label = "中文"
else:
text_language_desc = "画面中的标题、副标题、能力说明文字统一使用日文排版,字体要有热血漫画海报感,禁止出现中文。"
text_language_label = "日文"
return f"""
请为短视频封面创作一张高完成度竖版插画,主体是 Dota2 英雄 {hero_name_cn}{hero_name_en})。
核心要求:
1. 角色设定明确为 Dota2 的风格体系下的“至宝级华丽皮肤质感”,但角色身份必须是 {hero_name_cn} 本人,不要画成别的英雄。
2. 画面整体要强烈偏向 JOJO 气质:夸张肌肉与体块、强烈明暗对比、戏剧化姿势、锐利线条、张力十足的漫画分镜感、厚重阴影、速度线、压迫感构图。
3. 需要比普通日漫更偏 JOJO 风,风格统一、成熟、硬朗、华丽,视觉冲击力强。
4. 画面左下角固定放一个“能力雷达图”,用日式游戏 UI 风格表现,半透明发光面板,结构清晰。
5. 画面中加入醒目的文字排版,像热血漫画标题与角色名字幕,排版要高级,不能乱码。
6. 构图固定为 9:16 竖版海报,适合抖音封面,角色居中偏上,保留底部与左下角的信息区。
7. 背景使用史诗感能量、替身感氛围、漫画速度线、粒子、光效,但不要遮挡主体脸和武器。
8. 质感统一为高细节、高完成度、商业海报、收藏级插画。
9. {text_language_desc}
稳定性要求:
1. 全系列都保持相同的版式语言、相同的信息层级、相同的雷达图位置、相同的标题风格。
2. 当前是同一英雄的第 {image_index} 张候选图,本张必须输出{text_language_label}版本;请只在姿势、镜头角度、背景能量流向上做有限变化,不要改变整体系列风格。
3. 不要出现水印、签名、Logo、拼贴、多角色、手部崩坏、脸部畸形、文字糊成乱码。
""".strip()
def extract_image_bytes(response_json: Dict[str, Any], timeout_seconds: int) -> bytes:
"""
从 OpenAI 兼容响应中提取图片字节。
兼容两种常见返回格式:
1. b64_json直接解码
2. url再补一次下载。
"""
data_list = response_json.get("data") or []
if not data_list:
raise ValueError(f"接口返回里没有 data 字段: {json.dumps(response_json, ensure_ascii=False)[:500]}")
first_item = data_list[0] or {}
b64_content = (
first_item.get("b64_json")
or first_item.get("image_base64")
or first_item.get("base64")
or ""
)
if b64_content:
return base64.b64decode(b64_content)
image_url = str(first_item.get("url") or first_item.get("image_url") or "").strip()
if image_url:
download_response = requests.get(image_url, timeout=timeout_seconds)
download_response.raise_for_status()
return download_response.content
raise ValueError(f"无法从响应中提取图片内容: {json.dumps(first_item, ensure_ascii=False)[:500]}")
def generate_one_image(
request_url: str,
api_key: str,
model: str,
prompt: str,
image_size: str,
image_quality: str,
timeout_seconds: int,
) -> bytes:
"""调用 OpenAI 兼容图片接口生成单张图片。"""
headers = {
"Content-Type": "application/json",
"Authorization": build_auth_header(api_key),
}
payload = {
"model": model,
"prompt": prompt,
"n": 1,
"size": image_size,
"quality": image_quality,
"response_format": "b64_json",
"user": "dota2_douyin_batch_generator",
}
response = requests.post(
request_url,
headers=headers,
json=payload,
timeout=timeout_seconds,
)
response.raise_for_status()
response_json = response.json() or {}
return extract_image_bytes(response_json, timeout_seconds)
def append_manifest_row(manifest_path: Path, row: Dict[str, Any]) -> None:
"""
以 JSONL 方式追加生成记录。
这样做的好处是:
1. 即使脚本中途停止,前面已成功的记录也不会丢;
2. 方便后续按英雄筛选、统计或补图。
"""
with manifest_path.open("a", encoding="utf-8") as file_obj:
file_obj.write(json.dumps(row, ensure_ascii=False) + "\n")
def ensure_output_dir(output_dir: Path) -> None:
"""确保输出目录存在。"""
output_dir.mkdir(parents=True, exist_ok=True)
def build_generation_tasks(
heroes: List[Dict[str, str]],
output_dir: Path,
count_per_hero: int,
) -> List[Dict[str, Any]]:
"""
预先展开所有生图任务。
这样做的目的:
1. 先把“英雄 x 第几张图”拍平成统一任务列表,便于线程池直接消费;
2. 任务对象中提前算好输出目录、文件名、提示词,线程里只负责执行;
3. 任务顺序保持稳定,后续日志更容易排查。
"""
tasks: List[Dict[str, Any]] = []
total_heroes = len(heroes)
for hero_index, hero in enumerate(heroes, start=1):
hero_slug = sanitize_filename(hero["english_name"].lower().replace(" ", "_"))
hero_dir = output_dir / f"{hero_slug}_{sanitize_filename(hero['localized_name'])}"
ensure_output_dir(hero_dir)
for image_index in range(1, count_per_hero + 1):
file_name = f"{hero_slug}_{image_index:02d}.png"
image_path = hero_dir / file_name
tasks.append(
{
"hero": hero,
"hero_index": hero_index,
"total_heroes": total_heroes,
"hero_slug": hero_slug,
"hero_dir": hero_dir,
"image_index": image_index,
"image_path": image_path,
"prompt": build_consistent_prompt(hero, image_index),
}
)
return tasks
def run_single_generation_task(
task: Dict[str, Any],
request_url: str,
api_key: str,
model: str,
image_size: str,
image_quality: str,
timeout_seconds: int,
max_retries: int,
delay_seconds: float,
force: bool,
manifest_path: Path,
manifest_lock: threading.Lock,
print_lock: threading.Lock,
) -> Tuple[str, Dict[str, Any]]:
"""
在线程池中执行单个图片生成任务。
返回值约定:
1. status 为 success / skipped / failed 三种之一;
2. payload 会带上日志和清单记录所需的数据,主线程只负责汇总结果;
3. manifest 写入放在线程内完成,但通过锁保证同一时刻只有一个线程落盘。
"""
hero = task["hero"]
image_index = task["image_index"]
image_path: Path = task["image_path"]
prompt = task["prompt"]
with print_lock:
print(
f"\n[{task['hero_index']}/{task['total_heroes']}] "
f"处理英雄: {hero['localized_name']} ({hero['english_name']}) "
f"- 第 {image_index}"
)
if image_path.exists() and not force:
with print_lock:
print(f" - 已存在,跳过: {image_path.name}")
return "skipped", {
"hero_id": hero["hero_id"],
"localized_name": hero["localized_name"],
"english_name": hero["english_name"],
"image_index": image_index,
"image_path": str(image_path.as_posix()),
}
last_error: Optional[str] = None
for retry_index in range(1, max_retries + 1):
try:
with print_lock:
print(f" - 生成第 {image_index} 张,尝试 {retry_index}/{max_retries}")
image_bytes = generate_one_image(
request_url=request_url,
api_key=api_key,
model=model,
prompt=prompt,
image_size=image_size,
image_quality=image_quality,
timeout_seconds=timeout_seconds,
)
with image_path.open("wb") as file_obj:
file_obj.write(image_bytes)
manifest_row = {
"hero_id": hero["hero_id"],
"localized_name": hero["localized_name"],
"english_name": hero["english_name"],
"image_index": image_index,
"image_path": str(image_path.as_posix()),
"size": image_size,
"quality": image_quality,
"model": model,
"request_url": request_url,
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"prompt": prompt,
}
# 这里用锁保护清单写入,避免多个线程同时写 JSONL 时内容互相穿插。
with manifest_lock:
append_manifest_row(manifest_path, manifest_row)
with print_lock:
print(f" - 生成成功: {image_path.name}")
time.sleep(delay_seconds)
return "success", manifest_row
except Exception as exc:
last_error = str(exc)
with print_lock:
print(f" - 生成失败: {last_error}")
if retry_index < max_retries:
# 这里做一个简短退避,降低临时网络波动或网关限流的影响。
time.sleep(min(5, retry_index * 2))
failed_row = {
"hero_id": hero["hero_id"],
"localized_name": hero["localized_name"],
"english_name": hero["english_name"],
"image_index": image_index,
"image_path": str(image_path.as_posix()),
"size": image_size,
"quality": image_quality,
"model": model,
"request_url": request_url,
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"error": last_error or "未知错误",
}
with manifest_lock:
append_manifest_row(manifest_path, failed_row)
return "failed", failed_row
def main() -> int:
"""脚本入口。"""
args = parse_args()
config_data = load_yaml_config(args.config)
backend = resolve_image_backend(config_data, args.scene)
if backend["provider"] != "openai_compatible":
raise ValueError(
f"场景 {args.scene} 对应的 provider 不是 openai_compatible而是 {backend['provider']}"
)
if not backend["api_base_url"]:
raise ValueError("图片后端缺少 api_base_url/base_url 配置")
if not backend["api_key"]:
raise ValueError("图片后端缺少 api_key 配置")
output_dir = Path(args.output_dir)
ensure_output_dir(output_dir)
manifest_path = output_dir / "generation_manifest.jsonl"
request_url = build_request_url(backend["api_base_url"], backend["endpoint"])
# 这里优先使用命令行显式传入的超时值;
# 若用户未额外指定,则沿用 argparse 默认值 180 秒。
# 这样这个脚本的行为是稳定可预期的,不会再因为历史默认值导致请求挂太久。
timeout_seconds = int(args.timeout or backend["timeout_seconds"])
heroes = fetch_dota2_heroes()
if args.hero_filter:
keyword = args.hero_filter.lower().strip()
heroes = [
hero for hero in heroes
if keyword in hero["localized_name"].lower() or keyword in hero["english_name"].lower()
]
if args.hero_limit and args.hero_limit > 0:
heroes = heroes[:args.hero_limit]
if not heroes:
print("没有匹配到任何英雄,请检查 --hero-filter 或网络状态。", file=sys.stderr)
return 1
print(f"共准备生成 {len(heroes)} 个英雄,每个英雄 {args.count_per_hero} 张。")
print(f"图片接口: {request_url}")
print(f"输出目录: {output_dir.resolve()}")
print(f"并发线程数: {args.max_workers}")
total_success = 0
total_skipped = 0
total_failed = 0
manifest_lock = threading.Lock()
print_lock = threading.Lock()
tasks = build_generation_tasks(
heroes=heroes,
output_dir=output_dir,
count_per_hero=args.count_per_hero,
)
# 这里将所有任务交给线程池统一调度,让脚本能够同时发起 4 个图片请求。
with ThreadPoolExecutor(max_workers=max(1, int(args.max_workers))) as executor:
future_to_task = {
executor.submit(
run_single_generation_task,
task,
request_url,
backend["api_key"],
backend["model"],
args.size,
args.quality,
timeout_seconds,
args.max_retries,
args.delay,
args.force,
manifest_path,
manifest_lock,
print_lock,
): task
for task in tasks
}
for future in as_completed(future_to_task):
status, _ = future.result()
if status == "success":
total_success += 1
elif status == "skipped":
total_skipped += 1
else:
total_failed += 1
print("\n生成完成。")
print(f"成功: {total_success}")
print(f"跳过: {total_skipped}")
print(f"失败: {total_failed}")
print(f"清单文件: {manifest_path.resolve()}")
return 0 if total_failed == 0 else 2
if __name__ == "__main__":
raise SystemExit(main())