
Wechat Director
- 23 installs
- 39 repo stars
- Updated May 3, 2026
- davidlam-oss/wechat-writing-team
WeChat Director is a visual director agent skill that designs storyboards and generates WeChat article imagery through /draw so developers can produce cover and inline assets from drafts.
About
wechat-director is an agent skill that acts as a visual director for WeChat Official Account posts, driving /draw to plan storyboards and generate cover and inline imagery. It integrates optional compression and cloud upload after API keys are configured. Use it when articles are drafted and you need consistent, cinematic visuals without manual design tooling.
- /draw reads drafts and outputs a film-style storyboard for WeChat layouts
- Supports Gemini and GPT-Image2 with config_check.py for first-time setup
- Optional TinyPNG compression and Tencent COS upload helpers
- Pairs with WeChat Writer and Obsidian wechat-converter publishing flow
Wechat Director by the numbers
- 23 all-time installs (skills.sh)
- Ranked #995 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/davidlam-oss/wechat-writing-team --skill wechat-directorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 23 |
|---|---|
| repo stars | ★ 39 |
| Last updated | May 3, 2026 |
| Repository | davidlam-oss/wechat-writing-team ↗ |
How do you generate cohesive, storyboarded visuals for a WeChat article without leaving your Claude Code or Codex writing workflow?
Run /draw to turn WeChat article drafts into storyboarded cover, sidebar, and inline images via Gemini or GPT-Image2 APIs.
Who is it for?
Developers publishing WeChat Official Account content who can configure Gemini or GPT-Image2 API keys in conductor/api_keys.json.
Skip if: Teams with no WeChat publishing workflow or who cannot use the documented image APIs and optional COS upload.
When should I use this skill?
WeChat Writer produced a draft and you run /draw with the Storyboard.md path after config_check.py passes.
What you get
A storyboard plan plus generated cover, sidebar, and inline images, optionally compressed and uploaded.
Files
视觉导演系统 (WeChat Director)
核心使命: 为文章赋予电影感。我不负责说明书式的截图,我负责意境和情绪。
Tip: 建议在 归档前 (Stage 3) 运行本 Skill,默认将图片保存在项目img/目录。若在归档后运行,请务必指定--output-dir以免路径错误。
🎭 核心角色: 张艺谋 (Visual Director)
- 风格: Flat Vector Illustration with Sticker Style (扁平矢量插画 + 贴纸风格)
- IP 形象:
- 描述: A bald Asian male (early 30s), wearing signature bright red round-framed glasses, neat goatee beard on chin. Character has thick white outlines around the entire silhouette (sticker style).
- 一致性: 红色圆框眼镜、光头、山羊胡、白色描边必须保持一致。
🚀 工作流程
首次使用前
运行配置检测,确认生图 provider 就绪:
python3 scripts/config_check.py若提示未配置,可选择: 1. 配置 Gemini 或 GPT-Image2 API key(可选) 2. 跳过,手动在 Storyboard 中填入图片 URL
Step 1: 分镜设计 (Storyboard)
阅读输入文章,根据文章篇幅与情绪节奏,设计适量的插图位置。
- 数量原则: 跟随情绪,不设限。不要刻意计算字数。在每个需要"视觉呼吸"的留白处或情绪转折点 (Emotional Twist) 插入。对于 1500-2000 字的文章,通常 4-6 张为宜。
- 核心目标: 用画面去承接文字无法表达的留白。
输出交付物: Storyboard.md。
⚠️ 重要: 输出格式必须严格遵循 references/io_schema.md 中定义的 Markdown 结构与 Regex 规则。设计要求: 1. IP 标记: 每个 Code Block 前必须根据内容判断是否需要 IP 形象 ((IP形象: 是/否))。
- 重要: 若标记为
(IP形象: 是),则生成的 Prompt 必须包含上方定义的完整 IP 形象描述 (A bald Asian male...)。
2. 尺寸规范 (Aspect Ratio):
- Part A (主视觉): 必须在开头标明
movie composition, 2.35:1 aspect ratio,并在结尾使用--ar 2.35:1。禁止提供 Context 字段 (No Context needed)。 - Part B (侧边栏): 纯色背景 (需提取主视觉主色),必须标明
1:1 aspect ratio。禁止提供 Context 字段 (No Context needed)。 - Part C (配图): 必须在开头标明
portrait composition, 3:4 aspect ratio。这是手机阅读最佳比例。 - Context (锚点): 每一张内文配图 (Part C) 都必须提供
Context字段。 - 定义: 该图片应插入位置的上一段落的最后一句话。
- 要求: 必须是原文中的原句,确保唯一性。
- 格式:
> Context: "原文句子..."
3. 侧边栏内容 (Part B): 纯色背景 + 必须使用中文 (Must use Chinese characters)。
- 字数限制: 总共 4-6个汉字的核心短语 (e.g., "AI编程 / 一次过")。
- 排版: 必须按语义拆分为 上下两行 进行排版 (Split into two lines)。
- 禁止: 绝对禁止出现英文单词 (No English allowed)。
Step 2: 决策 (Check)
询问用户: "分镜表已生成。是否立即生成图片?(Run generation?)"
- Yes: 进入 Step 3。
- No: 任务结束,用户可手动使用 Prompt。
Step 3: 执行 (Execution) - Optional
调用 scripts/visualize.py 脚本批量生成图片。
- 全流程自动化: 推荐同时传入
--draft参数,脚本将自动完成 "生成 -> 压缩 -> 上传COS -> 插入正文 -> 清理本地" 的完整闭环。 - 脚本逻辑: 默认优先使用 vendored
gemini-web后端;启动前会先做健康检查,若不可用,再回退到现有 Gemini API / SiliconFlow API。 - 批量会话: 当本轮优先 provider 为
gemini-web时,脚本会先用单次 Gemini Web 会话批量生成本轮缺失图片,再进入压缩、上传和注入流程,减少 5~6 张图场景下的重复初始化。 - 首次登录: 首次使用
gemini-web前,先运行python3 scripts/visualize.py --gemini-web-login完成独立登录初始化。 - 配置检测: 运行
python3 scripts/config_check.py检查生图配置是否就绪。 - 隔离原则:
gemini-web会使用仓库根目录下.gemini/wechat-director/gemini-web/的独立 runtime,不复用日常 Chrome Profile。 - IP 增强: 脚本会自动识别 Storyboard 中的
(IP形象: 是)标记。若启用,将自动读取assets/IP_Reference.png作为 Gemini Web / Gemini API 的参考图输入。
🛠️ 脚本工具箱
| 脚本 | 功能 | I/O 规范 |
|---|---|---|
scripts/visualize.py | 批量调用生图后端 | 读取 Storyboard.md 中的 Prompt 代码块 |
WeChat Director I/O Schema
📁 输入规范 (Input)
1. Draft Article (`02_Draft.md`)
- 完整 Markdown 正文内容。
- 或已归档的
published/[Title].md。 - 用途:提取画面意境、IP 出现的场景。
📄 输出规范 (Output)
1. Storyboard (Storyboard.md)
- Path: 与 Input 同级目录(或指定目录)。
- Format: Strict Markdown Structure (for script parsing).
## Visual Storyboard
### Part A: 主视觉 cover-main (2.35:1)
(IP形象: 是)[中文 Prompt: 场景描述, IP特征...]
### Part B: 侧边栏 cover-sidebar (1:1)
(IP形象: 否)[中文 Prompt: 纯色背景 + 总结文字...]
### Part C: 内文配图 illustration
#### 插图 1: ...
(IP形象: 是)[中文 Prompt: 动作描述...]
> Context: "原文上一段落的最后一句(用于定位插入点)"🛠️ 脚本契约 (scripts/visualize.py)
- Invoke: Manual trigger by user or Agent.
- Input:
-
--brief:Storyboard.md(Must match Regex above). -
--draft:02_Draft.md(Required for Injection & Cleanup). - Logic:
1. Parse: Reads tasks and IP flags from Storyboard. 2. Health Check: When gemini-web is preferred, validates login state first; if unavailable in auto mode, skips to fallback providers. 3. Generate: Calls gemini-web first, then falls back to Gemini API / SiliconFlow API (injects IP ref if needed).
- When
gemini-webis selected as the preferred provider, missing images in the same article are batch-generated through one Gemini Web session before per-image post-processing.
4. Compress: Optimizes images via TinyPNG (if configured). 5. Upload: Puts illustrations to Tencent COS (if configured). covers stay local. 6. Inject: Inserts COS URLs into Draft.md after the Context sentence. 7. Cleanup: Deletes local copies of uploaded & injected illustrations.
- Provider Notes:
-
--provider auto:gemini-web→gemini→siliconflow -
--gemini-web-login: opens isolated Gemini Web login flow and exits -
gemini-webhealth check: validates cached login before generation;automode skips unhealthygemini-web -
gemini-webbatch mode: one article can reuse a single Gemini Web client session for all missing images -
gemini-webruntime path:/.gemini/wechat-director/gemini-web/ - Output:
- Files:
img/[Title]-cover-main.jpg(Local retained). - Artifact:
02_Draft.mdupdated with image links.
Visual Prompts & Style Guide (v3.0 - Chinese)
Source: Prompt/文章配图(含 IP 形象) - Prompt.mdRole: 张艺谋 (Visual Director)
Engine: Flux / Nano Banana Pro (中优)
🎨 核心风格 (Core Style)
扁平矢量贴纸风格 (Flat Vector Illustration with Sticker Style)
- Global Style Lock: 确保这组关键词在所有 Prompt 中出现。
- Keywords:
扁平矢量插画,白色贴纸描边,极简主义,鲜艳配色,干净的矢量线条,白底,商业插画,Dribbble风格,无阴影,2D扁平.
👤 IP 形象 (Character LoRA)
The "IP Rule": 必须在 (IP形象: 是) 时强制包含以下核心描述。1. 核心特征 (Base)
一个光头亚裔男性(30岁左右),戴着标志性的明亮红色圆框眼镜,下巴留着整洁的山羊胡。人物全身轮廓有粗白色描边(贴纸风格)。2. 动态换装 (Dynamic Clothing)
- Context Aware: 不要默认穿T恤。根据场景换装:
- 创业/代码: 连帽衫 (Hoodie),休闲T恤。
- 商务: 西装外套,衬衫。
- 居家/放松: 宽松毛衣。
📐 构图规范 (Composition)
1. 主视觉封面 (Cover Main) --ar 2.35:1
- Prompt Structure:
[场景描述], [IP核心特征] 穿着 [场景对应服装], [背景环境], 扁平矢量插画, 白色贴纸描边, 极简主义, 鲜艳配色, 干净线条, 白底, --ar 2.35:12. 侧边栏 (Cover Sidebar) --ar 1:1
- Requirement: 纯色背景 (提取主视觉主色调).
- Prompt Structure:
纯色 [主视觉主色调] 背景, 画面中心是一个 [代表主题的物体/图标], 极简主义, 扁平矢量插画, 白色贴纸描边, 无文字, --ar 1:13. 内文配图 (Insert) --ar 3:4
- Prompt Structure:
[具体动作/交互], [IP核心特征 (如需)] 穿着 [场景对应服装], [简单背景], 扁平矢量插画, 白色贴纸描边, 极简主义, --ar 3:4🚫 负向提示 (Negative Prompt)
照片, 真实感, 3d, 阴影, 渐变, 复杂细节, 凌乱, 文字, 水印, 签名, 模糊, 低质量, 素描#!/usr/bin/env python3
"""
config_check.py - WeChat Director 配置检测
首次触发时自动运行,检查生图相关配置是否就绪。
返回状态:
ok - 至少有一个可用的生图 provider
optional_missing - 无 provider(用户可选择手动生图或跳过)
"""
import json
import sys
from pathlib import Path
# --- 路径解析(与 visualize.py 保持一致,支持任意目录名)---
def get_workspace_root():
"""Find the workspace root: parent of the repo (contains David-Writing-Team and conductor/ as siblings)."""
script_dir = Path(__file__).resolve().parent
# go up: scripts -> wechat-director -> Skills -> David-Writing-Team -> parent
repo_root = script_dir.parent.parent.parent # David-Writing-Team/
return repo_root.parent # parent that contains both David-Writing-Team and conductor
def get_skill_root():
"""Find the wechat-director skill root (parent of scripts/)."""
return Path(__file__).resolve().parent.parent # scripts -> wechat-director
def load_api_config():
config_path = get_workspace_root() / "conductor" / "api_keys.json"
if not config_path.exists():
return {}
try:
with open(config_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
# --- 配置检测逻辑 ---
def check_image_providers(api_config):
"""检测生图 provider"""
issues = []
providers = []
# 1. Gemini API(需要 api_key)
gemini = api_config.get("gemini", {})
if gemini.get("api_key") and gemini.get("base_url"):
providers.append("gemini")
elif gemini.get("api_key"):
issues.append("⚠️ gemini.api_key 已配置,但缺少 base_url")
else:
issues.append("ℹ️ gemini 未配置(可选,需要 API key)")
# 2. GPT-Image2(需要 api_key)
gpt = api_config.get("gpt-image2", {})
if gpt.get("api_key") and gpt.get("base_url"):
providers.append("gpt-image2")
elif gpt.get("api_key"):
issues.append("⚠️ gpt-image2.api_key 已配置,但缺少 base_url")
else:
issues.append("ℹ️ gpt-image2 未配置(可选,需要 API key)")
return providers, issues
def check_optional_services(api_config):
"""检测可选服务"""
issues = []
# TinyPNG 压缩
tinify = api_config.get("tinify", {})
if tinify.get("api_key"):
issues.append("✅ TinyPNG 压缩已配置")
else:
issues.append("ℹ️ TinyPNG 压缩未配置(可选,图片将不压缩)")
# 腾讯云 COS 上传
cos = api_config.get("cos", {})
required = ["region", "secret_id", "secret_key", "bucket"]
if all(cos.get(k) for k in required):
issues.append("✅ 腾讯云 COS 上传已配置")
else:
issues.append("ℹ️ 腾讯云 COS 未配置(可选,图片将不上传到 CDN)")
return issues
def run_check():
api_config = load_api_config()
providers, provider_issues = check_image_providers(api_config)
optional_issues = check_optional_services(api_config)
has_provider = len(providers) > 0
print("=" * 50)
print("🎬 WeChat Director 配置检测")
print("=" * 50)
print()
print("📦 生图 Provider 状态:")
for issue in provider_issues:
print(f" {issue}")
print()
if has_provider:
print(f"✅ 可用 provider: {', '.join(providers)}")
print()
print("▶️ 可选服务状态:")
for issue in optional_issues:
print(f" {issue}")
print()
print("✅ 配置检测通过,Director 可以自动生图。")
print(" 如需跳过,可在 /draw 时选择手动生图或使用已有图片链接。")
return 0
else:
print("⚠️ 没有配置任何生图 provider")
print()
print(" 📋 选择方案:")
print(" 1. 配置 API key(推荐 Gemini 或 GPT-Image2):")
print(" → 在 conductor/api_keys.json 中添加 gemini 或 gpt-image2 配置")
print(" 2. 跳过: 手动生图,在 Storyboard 中填入图片 URL")
print()
print(f" 配置路径: {get_workspace_root()}/conductor/api_keys.json")
return 1
if __name__ == "__main__":
sys.exit(run_check())#!/usr/bin/env python3
"""
Image Generator - WeChat Director v2.2.8
Automated "Generate-Compress-Upload-Inject" Pipeline
Usage:
python3 visualize.py --brief path/to/Storyboard.md --draft path/to/Draft.md
"""
import argparse
import base64
import json
import os
import re
import shutil
import subprocess
import sys
import time
import hashlib
import urllib.request
import urllib.error
from datetime import datetime
from pathlib import Path
import logging
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(message)s')
logger = logging.getLogger(__name__)
# Optional dependencies
try:
from PIL import Image
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
logger.warning("⚠️ Pillow not installed. Image stitching will be skipped.")
try:
import tinify
TINIFY_AVAILABLE = True
except ImportError:
TINIFY_AVAILABLE = False
logger.warning("⚠️ tinify not installed. Compression will be skipped.")
try:
from qcloud_cos import CosConfig
from qcloud_cos import CosS3Client
COS_AVAILABLE = True
except ImportError:
COS_AVAILABLE = False
logger.warning("⚠️ cos-python-sdk-v5 not installed. Upload will be skipped.")
# --- Configurations & Constants ---
def run_obsidian_cmd(args):
"""Run an obsidian CLI command and return success status and output."""
try:
if shutil.which("obsidian") is None:
return False, "obsidian CLI not found in PATH"
# Execute the actual command
# Obsidian 1.12.7 CLI uses positional KV pairs like name=val path=path
result = subprocess.run(["obsidian"] + args, capture_output=True, text=True, check=True)
# CRITICAL: Obsidian CLI often exits with 0 even on error.
output = (result.stdout + result.stderr).strip()
if "Error:" in output or "not found" in output or "Missing required parameter" in output:
return False, output
return True, output
except (subprocess.CalledProcessError, FileNotFoundError):
return False, ""
def get_obsidian_vault_root(start_path=None):
"""Get the real Obsidian vault root by looking for the .obsidian configuration folder."""
if start_path is None:
start_path = Path(__file__).resolve()
current = start_path
while current.parent != current:
if (current / ".obsidian").is_dir():
return current
current = current.parent
# Fallback: Assume repo root is vault root
return get_workspace_root().resolve()
def get_obsidian_path(abs_path):
"""Convert absolute path to vault-relative path for Obsidian CLI."""
# Obsidian CLI expects paths RELATIVE to the REAL vault root.
vault_root = get_obsidian_vault_root()
try:
# Ensure we are working with absolute resolved paths
target_path = Path(abs_path).resolve()
rel_path = target_path.relative_to(vault_root)
return str(rel_path)
except Exception:
# If path is not under vault_root, return as is (Obsidian will likely fail anyway)
return str(abs_path)
def is_obsidian_reachable(file_path):
"""Check if a file path is located within the current Obsidian vault."""
vault_root = get_obsidian_vault_root()
try:
Path(file_path).resolve().relative_to(vault_root)
return True
except (ValueError, RuntimeError):
return False
def set_obsidian_properties(file_path, properties):
"""Set multiple properties using Obsidian CLI."""
if not is_obsidian_reachable(file_path):
return False
rel_path = get_obsidian_path(file_path)
success_count = 0
total_props = len([v for v in properties.values() if v is not None])
for key, value in properties.items():
if value is None: continue
# Format: property:set name=key value=val path=path
val_str = json.dumps(value) if isinstance(value, list) else str(value)
# Use Obsidian 1.12 syntax: property:set name=... value=... path=...
cmd_args = ["property:set", f"name={key}", f"value={val_str}", f"path={rel_path}"]
ok, err = run_obsidian_cmd(cmd_args)
if ok:
success_count += 1
else:
logger.warning(f"⚠️ CLI Property set failed for {key}: {err}")
return success_count == total_props
def obsidian_open(file_path):
"""Open a file in Obsidian GUI."""
if not is_obsidian_reachable(file_path):
return False
rel_path = get_obsidian_path(file_path)
# Use Obsidian 1.12 syntax: open path=...
ok, err = run_obsidian_cmd(["open", f"path={rel_path}"])
if not ok:
logger.warning(f"⚠️ CLI Open failed: {err}")
return ok
ASPECT_RATIOS = {
"cover-main": {"width": 1504, "height": 640, "suffix": "cover-main"},
"cover-sidebar": {"width": 1024, "height": 1024, "suffix": "cover-sidebar"},
"illustration": {"width": 768, "height": 1024, "suffix": "illustration"},
"quote": {"width": 768, "height": 1024, "suffix": "quote"},
}
DIRECTOR_VERSION = "2.2.8"
def get_workspace_root():
"""Find the workspace root: the parent of the repo (contains David-Writing-Team and conductor/ as siblings)."""
script_dir = Path(__file__).resolve().parent
# scripts -> wechat-director -> Skills -> David-Writing-Team -> parent
repo_root = script_dir.parent.parent.parent # David-Writing-Team/
return repo_root.parent # parent that contains both David-Writing-Team and conductor
def get_skill_root():
"""Find the wechat-director skill root directory."""
return Path(__file__).resolve().parent.parent
def load_api_config():
"""Load API keys from conductor/api_keys.json"""
config_path = get_workspace_root() / "conductor" / "api_keys.json"
if not config_path.exists():
logger.warning("⚠️ API Config not found. API providers, compression, and upload may be unavailable.")
return {}
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
return config
# --- Helper Functions ---
def sanitize_filename(text):
text = re.sub(r'[<>:"/\\|?*]', '', text)
text = re.sub(r'\s+', '-', text)
return text[:50]
def clean_prompt(prompt):
prompt = prompt.strip()
if len(prompt) < 5: return None
prompt = re.sub(r',?\s*aspect ratio\s*[\d.:]+', '', prompt, flags=re.IGNORECASE)
prompt = re.sub(r'\s--\w+(?:\s+[\w.:]+)?', '', prompt)
return prompt.strip()
def check_ip_requirement(text):
return bool(re.search(r'(?:IP(?:形象)?|Reference|参考图?)\s*[::]\s*(?:Yes|True|是|On|Required|1)', text, re.IGNORECASE))
def calculate_hash(prompt, width, height, model, use_ip=False):
content = f"{prompt}|{width}|{height}|{model}|{use_ip}"
return hashlib.md5(content.encode('utf-8')).hexdigest()[:8]
def require_config(api_config, provider_name, required_keys):
if not api_config:
raise RuntimeError(f"{provider_name} config missing.")
missing = [key for key in required_keys if not api_config.get(key)]
if missing:
missing_str = ", ".join(missing)
raise RuntimeError(f"{provider_name} config incomplete: missing {missing_str}.")
def resolve_gemini_web_settings(api_config):
repo_root = get_workspace_root()
skill_root = get_skill_root()
provider_config = api_config.get("gemini_web", {})
runtime_dir = Path(provider_config.get(
"runtime_dir",
repo_root / ".gemini" / "wechat-director" / "gemini-web"
))
return {
"model": provider_config.get("model", "gemini-3-pro"),
"timeout": int(provider_config.get("timeout", 240)),
"script_dir": skill_root / "vendor" / "baoyu-danger-gemini-web" / "scripts",
"data_dir": Path(provider_config.get("data_dir", runtime_dir / "data")),
"cookie_path": Path(provider_config.get("cookie_path", runtime_dir / "cookies.json")),
"profile_dir": Path(provider_config.get("profile_dir", runtime_dir / "chrome-profile")),
}
def run_gemini_web_command(api_config, extra_args, timeout=None):
settings = resolve_gemini_web_settings(api_config)
script_dir = settings["script_dir"]
bun_path = shutil.which("bun")
if not script_dir.exists():
raise FileNotFoundError(f"Gemini Web backend not found: {script_dir}")
if not bun_path:
raise RuntimeError("bun is required for gemini-web provider, but was not found in PATH.")
settings["data_dir"].mkdir(parents=True, exist_ok=True)
settings["profile_dir"].mkdir(parents=True, exist_ok=True)
settings["cookie_path"].parent.mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
env["GEMINI_WEB_DATA_DIR"] = str(settings["data_dir"])
env["GEMINI_WEB_CHROME_PROFILE_DIR"] = str(settings["profile_dir"])
env["GEMINI_WEB_COOKIE_PATH"] = str(settings["cookie_path"])
cmd = [bun_path, "run", "main.ts", *extra_args]
result = subprocess.run(
cmd,
cwd=script_dir,
env=env,
capture_output=True,
text=True,
timeout=timeout or settings["timeout"],
)
if result.returncode != 0:
stderr = (result.stderr or result.stdout or "").strip()
login_hint = ""
lower_err = stderr.lower()
if "login" in lower_err or "auth" in lower_err or "cookie" in lower_err:
login_hint = " Run visualize.py with --gemini-web-login once first."
raise RuntimeError(f"gemini-web failed: {stderr[:400]}{login_hint}")
return result, settings
def extract_context(text_block):
match = re.search(r'>\s*Context\s*[::]\s*["“](.*?)["”]', text_block, re.IGNORECASE | re.DOTALL)
if match:
return match.group(1).strip()
match = re.search(r'>\s*Context\s*[::]\s*(.+)', text_block, re.IGNORECASE)
if match:
return match.group(1).strip()
return None
def parse_visual_brief(file_path):
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
# 1. Base title from filename
filename_title = sanitize_filename(Path(file_path).stem)
title = filename_title
# 2. Fallback: If filename is generic "Storyboard", use parent directory name (Project Name)
if title.lower() == "storyboard":
parent_dir = Path(file_path).parent.name
if parent_dir:
title = sanitize_filename(parent_dir)
# 3. Override: Explicit frontmatter title has highest priority
fm_match = re.search(r'^title:\s*"(.+?)"', content, re.MULTILINE)
if fm_match:
title = sanitize_filename(fm_match.group(1))
tasks = []
# 1. Main Cover
if "cover-main" in content or "主视觉" in content:
section_match = re.search(r'###\s*Part\s*A[::][^\n]*主视觉.*?(?=###\s*Part\s*B|##|\Z)', content, re.DOTALL | re.IGNORECASE)
if section_match:
section_text = section_match.group(0)
prompt_match = re.search(r'```(?:\w+)?\n(.*?)```', section_text, re.DOTALL)
if prompt_match:
prompt = clean_prompt(prompt_match.group(1))
if prompt:
dims = ASPECT_RATIOS["cover-main"]
tasks.append({
"type": "cover-main", "prompt": prompt,
"width": dims["width"], "height": dims["height"],
"suffix": "cover-main",
"use_ip": check_ip_requirement(section_text),
"context": None
})
# 2. Sidebar Cover
if "cover-sidebar" in content or "侧边栏" in content:
section_match = re.search(r'###\s*Part\s*B[::][^\n]*侧边栏.*?(?=##|\Z)', content, re.DOTALL | re.IGNORECASE)
if section_match:
section_text = section_match.group(0)
prompt_match = re.search(r'```(?:\w+)?\n(.*?)```', section_text, re.DOTALL)
if prompt_match:
prompt = clean_prompt(prompt_match.group(1))
if prompt:
dims = ASPECT_RATIOS["cover-sidebar"]
tasks.append({
"type": "cover-sidebar", "prompt": prompt,
"width": dims["width"], "height": dims["height"],
"suffix": "cover-sidebar",
"use_ip": check_ip_requirement(section_text),
"context": None
})
# 3. In-article Illustrations
illustration_count = 0
for match in re.finditer(r'###\s*Part\s*C[::][^\n]*内文配图.*?(?=###\s*Part\s*[A-Z]|\Z)', content, re.DOTALL | re.IGNORECASE):
section_text = match.group(0)
for block in re.finditer(r'####\s*插图\s*\d+.*?(?=####\s*插图|##|\Z)', section_text, re.DOTALL):
block_text = block.group(0)
# Extract Description
desc_match = re.match(r'####\s*插图\s*\d+[::]?\s*(.*)', block_text)
description = ""
if desc_match:
description = re.sub(r'[<>:"/\\|?*]', '', desc_match.group(1)).strip()
prompt_match = re.search(r'```(?:\w+)?\n(.*?)```', block_text, re.DOTALL)
if not prompt_match: continue
prompt = clean_prompt(prompt_match.group(1))
if not prompt: continue
illustration_count += 1
dims = ASPECT_RATIOS["illustration"]
context = extract_context(block_text)
tasks.append({
"type": "illustration",
"prompt": prompt,
"width": dims["width"],
"height": dims["height"],
"suffix": f"illustration-{illustration_count:02d}",
"use_ip": check_ip_requirement(block_text),
"context": context,
"description": description
})
return title, tasks
# --- Generation Logic ---
def submit_task_gemini(api_config, prompt, width, height, use_ip=False, ratio_suffix=""):
require_config(api_config, "Gemini API", ["base_url", "model", "api_key"])
# Normalize base_url
base_url = api_config['base_url'].rstrip('/')
if base_url.endswith('/models'): base_url = base_url[:-7]
# Dynamic Model Selection
base_model = api_config['model']
model_id = f"{base_model}{ratio_suffix}"
url = f"{base_url}/models/{model_id}:generateContent"
headers = {"Content-Type": "application/json", "x-goog-api-key": api_config['api_key']}
parts = [{"text": prompt}]
w, h = int(width), int(height)
ratio = w / h
# Prompt Tuning for Aspect Ratio (even if model handles it, this helps composition)
if ratio > 1.7:
parts[0]["text"] += ", cinematic anamorphic shot, 2.35:1 aspect ratio"
elif ratio < 0.6:
parts[0]["text"] += ", tall portrait shot, 9:16 aspect ratio"
elif ratio < 0.85:
parts[0]["text"] += ", portrait shot, 3:4 aspect ratio"
# IP Injection
if use_ip:
script_dir = Path(__file__).resolve().parent
ip_image_path = script_dir.parent / "assets" / "IP_Reference.png"
if ip_image_path.exists():
try:
with open(ip_image_path, "rb") as img_f:
b64_img = base64.b64encode(img_f.read()).decode("utf-8")
parts.append({"inlineData": {"mimeType": "image/png", "data": b64_img}})
logger.info("[Gemini] IP Reference injected")
except Exception as e:
logger.warning(f"[Gemini] Failed to load IP image: {e}")
else:
logger.warning("[Gemini] IP flag is ON, but reference image not found.")
payload = {
"contents": [{"role": "user", "parts": parts}]
}
req = urllib.request.Request(url, headers=headers, data=json.dumps(payload).encode('utf-8'))
max_retries = 1
for attempt in range(max_retries + 1):
try:
with urllib.request.urlopen(req, timeout=90) as resp:
data = json.load(resp)
try:
candidate = data["candidates"][0]
parts_resp = candidate["content"]["parts"]
image_data = None
for part in parts_resp:
if "inlineData" in part: image_data = part["inlineData"]["data"]; break
if "inline_data" in part: image_data = part["inline_data"]["data"]; break
if image_data: return image_data
raise RuntimeError(f"No image data. Preview: {str(parts_resp)[:200]}")
except (KeyError, IndexError) as e:
raise RuntimeError(f"Unexpected format: {str(data)[:500]}") from e
except urllib.error.HTTPError as e:
# Fallback for 404 (Model suffix not found)
if e.code == 404:
logger.warning(f"⚠️ Model {model_id} not found (404). Falling back to base {base_model}...")
if ratio_suffix != "":
# Recursive call without suffix
return submit_task_gemini(api_config, prompt, width, height, use_ip, ratio_suffix="")
if (e.code == 429 or 500 <= e.code < 600) and attempt < max_retries:
logger.info(f"[Gemini] Error {e.code}, retrying...")
time.sleep(2)
continue
raise RuntimeError(f"Gemini failed ({e.code}): {e.read().decode('utf-8', errors='replace')[:200]}...") from e
def submit_task_gemini_web(api_config, prompt, output_path, use_ip=False):
settings = resolve_gemini_web_settings(api_config)
cmd = [
"--prompt", prompt,
"--image", str(output_path),
"--model", settings["model"],
"--profile-dir", str(settings["profile_dir"]),
"--cookie-path", str(settings["cookie_path"]),
]
if use_ip:
ip_image_path = Path(__file__).resolve().parent.parent / "assets" / "IP_Reference.png"
if ip_image_path.exists():
cmd.extend(["--reference", str(ip_image_path)])
logger.info("[Gemini Web] IP Reference injected")
else:
logger.warning("[Gemini Web] IP flag is ON, but reference image not found.")
run_gemini_web_command(api_config, cmd, timeout=settings["timeout"])
if not output_path.exists():
raise RuntimeError(f"gemini-web did not create output file: {output_path}")
def login_gemini_web(api_config):
result, settings = run_gemini_web_command(
api_config,
[
"--login",
"--profile-dir", str(resolve_gemini_web_settings(api_config)["profile_dir"]),
"--cookie-path", str(resolve_gemini_web_settings(api_config)["cookie_path"]),
],
timeout=300,
)
logger.info(result.stdout.strip() or f"Gemini Web login prepared: {settings['cookie_path']}")
def submit_task_siliconflow(api_config, prompt, width, height):
require_config(api_config, "SiliconFlow", ["base_url", "model", "api_key"])
url = f"{api_config['base_url']}images/generations"
headers = {"Authorization": f"Bearer {api_config['api_key']}", "Content-Type": "application/json"}
payload = {
"model": api_config["model"], "prompt": prompt,
"image_size": f"{int(width)}x{int(height)}", "batch_size": 1,
"num_inference_steps": 20, "guidance_scale": 3.5
}
req = urllib.request.Request(url, headers=headers, data=json.dumps(payload).encode('utf-8'))
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.load(resp)
if "data" in data and len(data["data"]) > 0: return data["data"][0]["url"]
raise RuntimeError(f"Unexpected response: {data}")
except urllib.error.HTTPError as e:
raise RuntimeError(f"SiliconFlow failed ({e.code}): {e.read().decode('utf-8', errors='replace')[:200]}...") from e
def submit_task_gpt_image2(api_config, prompt, width, height, use_ip=False):
"""Generate image via gpt-image-2 using OpenAI-compatible endpoint."""
require_config(api_config, "GPT-Image2", ["base_url", "model", "api_key"])
url = f"{api_config['base_url'].rstrip('/')}/images/generations"
headers = {
"Authorization": f"Bearer {api_config['api_key']}",
"Content-Type": "application/json"
}
payload = {
"model": api_config["model"],
"prompt": prompt,
"size": f"{int(width)}x{int(height)}",
"n": 1,
"response_format": "b64_json"
}
req = urllib.request.Request(url, headers=headers, data=json.dumps(payload).encode('utf-8'))
max_retries = 1
for attempt in range(max_retries + 1):
try:
with urllib.request.urlopen(req, timeout=300) as resp:
data = json.load(resp)
if "data" in data and len(data["data"]) > 0:
return data["data"][0]["b64_json"]
raise RuntimeError(f"Unexpected response: {str(data)[:200]}")
except urllib.error.HTTPError as e:
body = e.read().decode('utf-8', errors='replace')
if (e.code == 429 or 500 <= e.code < 600) and attempt < max_retries:
logger.info(f"[GPT-Image2] Error {e.code}, retrying...")
time.sleep(3)
continue
raise RuntimeError(f"GPT-Image2 failed ({e.code}): {body[:200]}...") from e
# --- Pipeline Class ---
class VisualPipeline:
def __init__(self, api_config, output_dir, force=False):
self.config = api_config
self.output_dir = output_dir
self.force = force
self.manifest_path = output_dir / "manifest.json"
self.manifest = self._load_manifest()
if TINIFY_AVAILABLE and "tinify" in self.config and self.config["tinify"].get("api_key"):
tinify.key = self.config["tinify"]["api_key"]
self.compress_enabled = True
else:
self.compress_enabled = False
if COS_AVAILABLE and "cos" in self.config:
cos_conf = self.config["cos"]
required_keys = ["region", "secret_id", "secret_key", "bucket"]
if all(k in cos_conf and cos_conf[k] for k in required_keys):
self.cos_config = CosConfig(
Region=cos_conf["region"],
SecretId=cos_conf["secret_id"],
SecretKey=cos_conf["secret_key"]
)
self.cos_client = CosS3Client(self.cos_config)
self.bucket = cos_conf["bucket"]
self.cdn_domain = cos_conf.get("cdn_domain", "")
self.upload_enabled = True
else:
logger.warning(f"⚠️ COS config incomplete. Upload disabled.")
self.upload_enabled = False
else:
self.upload_enabled = False
def _resolve_providers(self, provider):
if provider != "auto":
return [provider]
providers = []
if self.config.get("gemini"):
providers.append("gemini")
if self.config.get("gpt-image2"):
providers.append("gpt-image2")
return providers
def _load_manifest(self):
if self.manifest_path.exists():
try:
with open(self.manifest_path, "r", encoding="utf-8") as f:
return json.load(f)
except: pass
return {}
def _save_manifest(self):
try:
with open(self.manifest_path, "w", encoding="utf-8") as f:
json.dump(self.manifest, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"⚠️ Failed to save manifest: {e}")
def _enforce_ratio(self, img_path, target_w, target_h):
"""Deterministically force image to target dimensions (Aspect Fill + Top-Weighted Crop)."""
if not PIL_AVAILABLE or not img_path.exists(): return False
try:
with Image.open(img_path) as img:
src_w, src_h = img.size
if (src_w, src_h) == (target_w, target_h):
return False
logger.info(f"✂️ Enforcing {target_w}x{target_h} for {img_path.name}...")
# Aspect Fill logic
scale_w = target_w / src_w
scale_h = target_h / src_h
scale = max(scale_w, scale_h)
new_w = int(src_w * scale)
new_h = int(src_h * scale)
img_resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
# Top-Weighted Crop
diff_w = new_w - target_w
diff_h = new_h - target_h
left = diff_w / 2
top = diff_h * 0.2 # Bias: keep top 20% visible, cut mostly from bottom
right = left + target_w
bottom = top + target_h
img_final = img_resized.crop((left, top, right, bottom))
img_final.save(img_path, quality=95)
return True
except Exception as e:
logger.warning(f"⚠️ Ratio enforcement failed: {e}")
return False
def generate(self, task, title, provider="auto"):
# Cover files use simple names (deleted after publish); illustrations keep title prefix
if task['suffix'].startswith("cover"):
local_filename = f"{task['suffix']}.jpg"
else:
local_filename = f"{title}-{task['suffix']}.jpg"
local_path = self.output_dir / local_filename
providers = self._resolve_providers(provider)
if not providers:
logger.error("❌ No available providers. Check gemini-web vendor or API config.")
return None, local_path
preferred_provider = providers[0]
current_hash = calculate_hash(
task['prompt'],
task['width'],
task['height'],
preferred_provider,
task.get('use_ip', False)
)
cached = self.manifest.get(task['suffix'], {})
# URL Cache Hit
if (
not self.force and
cached.get("hash") == current_hash and
cached.get("provider") == preferred_provider and
cached.get("url")
):
logger.info(f"⏭️ [Cache Hit] {task['suffix']} -> {cached['url']}")
# Safety Net: Enforce Ratio on Cached Cover Main
if task['type'] == 'cover-main':
if self._enforce_ratio(local_path, task['width'], task['height']):
cached["compressed"] = False
return cached['url'], local_path
# Local File Cache Hit
generated_new = False
if (
not self.force and
local_path.exists() and
cached.get("hash") == current_hash and
cached.get("provider") == preferred_provider
):
logger.info(f"📂 [Local Hit] {task['suffix']} exists.")
# Safety Net: Enforce Ratio on Cached Cover Main (Local Hit)
if task['type'] == 'cover-main':
if self._enforce_ratio(local_path, task['width'], task['height']):
cached["compressed"] = False
else:
# Generate new image
logger.info(f"🎨 Generating {task['suffix']}...")
success = False
# Determine Ratio Suffix for Gemini
# ratio_suffix = "" # Disabled: model doesn't support suffixed model names
ratio_suffix = ""
provider_used = None
for p in providers:
try:
if p == "gemini":
b64 = submit_task_gemini(self.config["gemini"], task['prompt'], task['width'], task['height'], task.get('use_ip', False), ratio_suffix)
with open(local_path, "wb") as f: f.write(base64.b64decode(b64))
provider_used = p
success = True
break
if p == "gpt-image2":
b64 = submit_task_gpt_image2(self.config["gpt-image2"], task['prompt'], task['width'], task['height'], task.get('use_ip', False))
with open(local_path, "wb") as f: f.write(base64.b64decode(b64))
provider_used = p
success = True
break
except Exception as e:
logger.error(f"❌ {p} failed: {e}")
if not success:
logger.error(f"❌ Failed to generate {task['suffix']}")
return None, local_path
# Safety Net: Enforce Ratio on New Cover Main
if task['type'] == 'cover-main':
if self._enforce_ratio(local_path, task['width'], task['height']):
cached["compressed"] = False
generated_new = True
# COMPRESS
if self.compress_enabled and local_path.exists() and (generated_new or not cached.get("compressed")):
try:
logger.info(f"🗜️ Compressing {local_filename}...")
source = tinify.from_file(str(local_path))
source.to_file(str(local_path))
cached["compressed"] = True
except Exception as e:
logger.warning(f"⚠️ Compression failed: {e}")
# UPLOAD (Illustrations only)
final_url = None
if self.upload_enabled and local_path.exists() and "cover" not in task['type']:
date_suffix = datetime.now().strftime("%Y%m%d")
# Filename: {Project}_{Suffix}_{Hash}_{Date}.jpg (Flat structure in 'wechat/' folder)
cos_filename = f"{title}_{task['suffix']}_{current_hash}_{date_suffix}.jpg"
cos_key = f"wechat/{cos_filename}"
try:
logger.info(f"☁️ Uploading to COS: {cos_key}")
self.cos_client.put_object_from_local_file(Bucket=self.bucket, LocalFilePath=str(local_path), Key=cos_key)
if self.cdn_domain:
final_url = f"{self.cdn_domain.rstrip('/')}/{cos_key}"
else:
final_url = self.cos_config.uri(self.bucket, cos_key)
logger.info(f"✅ Uploaded: {final_url}")
except Exception as e:
logger.error(f"❌ Upload failed: {e}")
if not final_url and cached.get("hash") == current_hash and cached.get("url"):
final_url = cached["url"]
self.manifest[task['suffix']] = {
"hash": current_hash,
"provider": cached.get("provider", preferred_provider) if not generated_new else provider_used,
"prompt": task['prompt'],
"updated_at": time.time(),
"url": final_url,
"compressed": cached.get("compressed", False)
}
self._save_manifest()
return final_url, local_path
def inject(self, draft_path, task, image_url):
if not draft_path or not draft_path.exists(): return False
if not task.get("context") or not image_url: return False
context_sent = task["context"]
logger.info(f"💉 Injecting {task['suffix']}...")
with open(draft_path, "r", encoding="utf-8") as f:
lines = f.readlines()
matches = []
for i, line in enumerate(lines):
if context_sent in line:
matches.append(i)
if len(matches) == 0:
logger.warning(f"⚠️ Injection Skipped: Context not found -> '{context_sent[:30]}...'")
return False
elif len(matches) > 1:
logger.error(f"❌ Injection Failed: Context ambiguous ({len(matches)} matches) -> '{context_sent[:30]}...'")
return False
match_index = matches[0]
# Fix: Ensure fallback to suffix if description is empty string (Falsey)
alt_text = task.get("description") or task['suffix']
existing_line_idx = -1
is_exact_match = False
for j in range(1, 10):
if match_index + j < len(lines):
idx = match_index + j
line_content = lines[idx]
img_match = re.search(r'!\[(.*?)\]\((.*?)\)', line_content)
if img_match:
current_alt = img_match.group(1)
current_url = img_match.group(2)
if task['suffix'] in current_url:
existing_line_idx = idx
if image_url.strip() in current_url and current_alt.strip() == alt_text.strip():
is_exact_match = True
break
if is_exact_match:
logger.info(" ⏭️ Image already up-to-date, skipping.")
elif existing_line_idx != -1:
logger.info(f" 🔄 Updating stale link at line {existing_line_idx+1}...")
lines[existing_line_idx] = f"\n"
with open(draft_path, "w", encoding="utf-8") as f:
f.writelines(lines)
else:
img_md = f"\n\n"
lines.insert(match_index + 1, img_md)
with open(draft_path, "w", encoding="utf-8") as f:
f.writelines(lines)
logger.info(" ✅ Injected successfully.")
return True
def stitch_covers(output_dir, title):
if not PIL_AVAILABLE: return
main_path = output_dir / "cover-main.jpg"
sidebar_path = output_dir / "cover-sidebar.jpg"
combined_path = output_dir / "cover-combined.jpg"
if not main_path.exists() or not sidebar_path.exists(): return
try:
img_main = Image.open(main_path)
img_sidebar = Image.open(sidebar_path)
target_height = img_sidebar.height
aspect = img_main.width / img_main.height
new_width = int(target_height * aspect)
img_main_resized = img_main.resize((new_width, target_height), Image.Resampling.LANCZOS)
combined = Image.new('RGB', (new_width + img_sidebar.width, target_height))
combined.paste(img_main_resized, (0, 0))
combined.paste(img_sidebar, (new_width, 0))
combined.save(combined_path, quality=95)
logger.info(f"🖼️ Stitched Cover: {combined_path}")
except Exception as e:
logger.error(f"❌ Stitching failed: {e}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--brief", help="Path to Storyboard.md")
parser.add_argument("--draft", help="Path to Draft.md for injection")
parser.add_argument("--output-dir")
parser.add_argument("--force", action="store_true")
parser.add_argument("--provider", default="auto", choices=["auto", "gemini-web", "gemini", "gpt-image2"])
parser.add_argument("--gemini-web-login", action="store_true", help="Initialize Gemini Web login, then exit")
args = parser.parse_args()
try:
api_config = load_api_config()
except Exception as e:
logger.error(str(e))
return
if args.gemini_web_login:
try:
login_gemini_web(api_config)
except Exception as e:
logger.error(str(e))
return
if not args.brief:
logger.error("--brief is required unless --gemini-web-login is used.")
return
brief_path = Path(args.brief)
if not brief_path.exists():
logger.error(f"File not found: {brief_path}")
return
output_dir = Path(args.output_dir) if args.output_dir else brief_path.parent / "img"
output_dir.mkdir(parents=True, exist_ok=True)
title, tasks = parse_visual_brief(brief_path)
if not tasks:
logger.warning("No tasks found.")
return
pipeline = VisualPipeline(api_config, output_dir, force=args.force)
print(f"🎬 Director v{DIRECTOR_VERSION} starting for '{title}'")
print(f" Tasks: {len(tasks)} | Provider: {args.provider}")
print(f" Compression: {'ON' if pipeline.compress_enabled else 'OFF'}")
print(f" Upload: {'ON' if pipeline.upload_enabled else 'OFF'}")
print("-" * 40)
for task in tasks:
url, local_path = pipeline.generate(task, title, provider=args.provider)
injection_done = False
if args.draft and url and task.get("context"):
injection_done = pipeline.inject(Path(args.draft), task, url)
elif not args.draft:
logger.info(f"ℹ️ Skipping injection (no draft provided). Local file retained: {local_path.name}")
elif not task.get("context"):
logger.warning(f"⚠️ Skipping injection (missing context). Local file retained: {local_path.name}")
if url and "cover" not in task['type'] and injection_done:
try:
if local_path.exists():
local_path.unlink()
logger.info(f"🗑️ Cleanup: Deleted local file {local_path.name}")
except Exception as e:
logger.warning(f"⚠️ Cleanup failed: {e}")
stitch_covers(output_dir, title)
# --- Obsidian CLI Enhancements ---
cli_ok = shutil.which("obsidian") is not None
if cli_ok and args.draft:
draft_p = Path(args.draft)
if draft_p.exists() and is_obsidian_reachable(draft_p):
# 1. Set property (Verify result)
if set_obsidian_properties(draft_p, {"visual_ready": True}):
logger.info(f"🏷️ Marked 'visual_ready: true' in {draft_p.name}")
# 2. Open in GUI for preview
if obsidian_open(draft_p):
logger.info(f"🚀 Focus jumping to Obsidian for preview: {draft_p.name}")
else:
logger.warning(f"⚠️ Failed to mark 'visual_ready' in {draft_p.name} via CLI.")
print("-" * 40)
print("✅ Director finished.")
if __name__ == "__main__":
main()
Related skills
FAQ
Which APIs does /draw support?
README lists Gemini and GPT-Image2 with keys stored in conductor/api_keys.json.
What should I run before first use?
python3 skills/wechat-director/scripts/config_check.py validates image generation configuration.
How does this relate to WeChat Writer?
README describes WeChat Director as the companion visual skill after the writing pipeline produces drafts.