
Image Assistant
- 92 installs
- 739 repo stars
- Updated July 27, 2026
- yunshu0909/yunshu_skillshub
Helps with ai & agent building tasks.
About
image-assistant is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- image-assistant
- AI & Agent Building
- AI-coding skill
Image Assistant by the numbers
- 92 all-time installs (skills.sh)
- Ranked #4,715 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yunshu0909/yunshu_skillshub --skill image-assistantAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 92 |
|---|---|
| repo stars | ★ 739 |
| Last updated | July 27, 2026 |
| Repository | yunshu0909/yunshu_skillshub ↗ |
What it does
Helps with ai & agent building tasks.
Files
配图助手
触发方式
当用户说类似以下内容时触发:
- “这段内容做个图 / 配几张图?”
- “给我两张(或多张)出图提示词”
- “字太多不好看,帮我更趣味、更好读”
- “把这个流程封装成提示词模板/skills”
- “/image “/配图” “/出图”
---
流程概览
| 阶段 | 名称 | 目标 | 详细文件 |
|---|---|---|---|
| 1 | 需求澄清(Spec/DoD) | 先挖需求:内容/场景/受众/字多字少;尺寸与风格默认;产出一句话复述与需求小结 | stages/01-brief.md |
| 2 | 配图规划(拆块→清单) | 拆内容→定图清单(几张/每张讲啥/用啥模板) | stages/02-plan.md |
| 3 | 文案定稿(Copy Spec) | 逐字定稿“图上写什么”(唯一真值) | stages/03-copy.md |
| 4 | 提示词封装(Prompt Pack) | 把 Copy Spec 封装成可复制提示词;生成批量请求包并在用户确认后批量出图 | stages/04-prompts.md |
| 5 | 迭代润色 | 根据反馈减字、换隐喻、提可读性 | stages/05-iterate.md |
---
调度规则
如何判断当前阶段: 1. 还没把需求讲清楚(内容 + 场景 + 受众 + 字多/字少)→ 阶段1 2. 文章很长、需要拆块,或需要确定“几张图/每张讲什么”→ 阶段2 3. 已确认图清单,但还没确定“图上逐字写什么”→ 阶段3 4. Copy Spec 已确认,要出可复制提示词;(可选)提示词确认后批量出图 → 阶段4 5. 用户反馈“字多/不好看/不符合封面” → 阶段5(必要时回退到阶段1重锁需求与字多/字少)
每个阶段开始时:
- 告诉用户当前阶段与本阶段输出物
- 读取对应阶段文件并按步骤执行
---
输出规范(必须遵守)
- 每张图一个“核心信息”,不把解释性段落塞进图里
- 所有中文必须清晰可读:大字号、少字短句、避免密集小字
- 每张提示词用一个独立代码块输出,便于复制
- 默认输出 16:9 横版(除非用户明确要 3:4 漫画/竖版)
- 默认风格:奶油纸底 + 彩铅水彩手绘 + 轻涂鸦,趣味但干净(可用
templates/style-block.md) - 阶段3产物(Copy Spec)一旦确认,阶段4不得擅自改文案,只做封装与参数/约束补全
---
快速使用(给用户的最小输入)
用户只要给这四项,就能开始: 1. 要配图的内容(可是一段、一个小节、或整篇文章) 2. 用在哪里 + 观看距离(PPT投影远看 / 手机近看 / 海报) 3. 谁来看(小白/从业者/老板/学生…) 4. 偏好:更“少字清爽”还是更“信息密度”
可选补充(不写也没关系):
- 你大概想要哪类图:封面/目录、单页概览、讲义解释、社媒海报(不确定我会根据场景与偏好推荐)
你要做的交付顺序:
- 先输出:图清单(几张 + 每张一句话目的 + 模板建议)(阶段2)
- 用户确认后:逐张输出 Copy Spec(逐字定稿)(阶段3)
- Copy Spec 确认后:逐张输出可复制提示词/调用包(阶段4)
- (可选)提示词确认后:批量调用 API 出图(阶段4内连续流程)
- 用户说“字多/不好看”就进入迭代(阶段5)
---
文件结构
stages/
├── 01-brief.md
├── 02-plan.md
├── 03-copy.md
├── 04-prompts.md
└── 05-iterate.md
templates/
├── style-block.md
├── 16x9-infographic.md
├── 16x9-contrast-2cards.md
├── 16x9-3cards-insights.md
├── 16x9-cover-roadmap.md
├── 16x9-5panel-comic.md
├── api-config.md
├── apimart-curl.md
├── apimart-requests-jsonl.md
└── checklist.md
examples/
└── ai-tools-selection.md
scripts/
├── apimart_batch_generate.py
├── apimart.env.example
└── README.md示例:AI 工具选择文章配图(16:9)
这个示例展示“先规划几张图→压缩文案→输出提示词”的交付形态。
建议做法:
- 概念/选型:用“对比卡片/关系总览”
- 过程/差异:用“流程/五格漫画”
- 总结收束:用“三卡洞察”
输出时每张图一个代码块,并复用同一段风格块(见 templates/style-block.md)。
{"id":"01","prompt":"A minimal test prompt for 16:9 infographic style. Cream paper background, colored pencil watercolor, clean layout, no extra text.","size":"16:9","n":1,"resolution":"2K","model":"gemini-3-pro-image-preview","pad_url":""}
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import datetime as _dt
import json
import mimetypes
import re
import ssl
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
from urllib import error, parse, request
_CONFIG_LINE_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.*)$")
def _strip_quotes(value: str) -> str:
value = value.strip()
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
return value[1:-1]
return value
def load_config(path: Path) -> dict[str, str]:
config: dict[str, str] = {}
if not path.exists():
raise FileNotFoundError(f"Config not found: {path}")
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
match = _CONFIG_LINE_RE.match(line)
if not match:
continue
key = match.group(1).upper()
value = _strip_quotes(match.group(2))
config[key] = value
return config
def _as_int(value: str | None, default: int) -> int:
if value is None or value == "":
return default
try:
return int(value)
except ValueError:
return default
def _join_prompt(pad_url: str, prompt: str) -> str:
pad_url = pad_url.strip()
prompt = prompt.strip()
if not pad_url:
return prompt
return f"{pad_url} {prompt}"
def load_requests(input_path: Path) -> list[dict[str, Any]]:
if not input_path.exists():
raise FileNotFoundError(f"Input not found: {input_path}")
if input_path.suffix.lower() == ".jsonl":
requests_list: list[dict[str, Any]] = []
for line_no, raw_line in enumerate(input_path.read_text(encoding="utf-8").splitlines(), start=1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
try:
obj = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSONL at line {line_no}: {exc}") from exc
if not isinstance(obj, dict):
raise ValueError(f"Invalid JSONL at line {line_no}: expected object")
requests_list.append(obj)
return requests_list
obj = json.loads(input_path.read_text(encoding="utf-8"))
if isinstance(obj, list) and all(isinstance(item, dict) for item in obj):
return list(obj) # type: ignore[return-value]
raise ValueError("Unsupported input format: use .jsonl (recommended) or a JSON array of objects")
@dataclass(frozen=True)
class Normalized:
request_id: str
payload: dict[str, Any]
def normalize_requests(
raw_requests: Iterable[dict[str, Any]],
*,
default_model: str,
default_size: str,
default_n: int,
default_resolution: str,
default_pad_url: str,
) -> list[Normalized]:
normalized: list[Normalized] = []
auto_id = 1
for raw in raw_requests:
prompt = str(raw.get("prompt", "")).strip()
if not prompt:
continue
request_id = str(raw.get("id", "")).strip()
if not request_id:
request_id = f"{auto_id:02d}"
auto_id += 1
model = str(raw.get("model", "")).strip() or default_model
size = str(raw.get("size", "")).strip() or default_size
resolution = str(raw.get("resolution", "")).strip() or default_resolution
n = raw.get("n", None)
if isinstance(n, int):
n_value = n
else:
n_value = default_n
pad_url = str(raw.get("pad_url", "")).strip() or default_pad_url
joined_prompt = _join_prompt(pad_url, prompt)
payload = {
"model": model,
"prompt": joined_prompt,
"size": size,
"n": n_value,
"resolution": resolution,
}
normalized.append(Normalized(request_id=request_id, payload=payload))
return normalized
def _safe_filename_part(value: str) -> str:
value = value.strip()
value = re.sub(r"[^A-Za-z0-9._-]+", "_", value)
return value[:80] if value else "item"
def post_json(*, url: str, token: str, payload: dict[str, Any], timeout_s: float) -> tuple[int, dict[str, str], bytes]:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = request.Request(url=url, data=data, method="POST")
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Content-Type", "application/json")
with request.urlopen(req, timeout=timeout_s) as resp:
status = int(getattr(resp, "status", 200))
headers = {k.lower(): v for k, v in resp.headers.items()}
body = resp.read()
return status, headers, body
def _extract_base64_blob(value: str) -> tuple[str | None, str]:
value = value.strip()
if value.startswith("data:") and ";base64," in value:
header, b64 = value.split(";base64,", 1)
mime = header[5:] if header.startswith("data:") else None
return mime, b64
return None, value
def extract_images(response_obj: Any) -> list[tuple[str, str]]:
found: list[tuple[str, str]] = []
seen: set[tuple[str, str]] = set()
def visit(node: Any) -> None:
if isinstance(node, dict):
for key in ("url",):
value = node.get(key)
if isinstance(value, str) and value.startswith(("http://", "https://")):
item = ("url", value)
if item not in seen:
seen.add(item)
found.append(item)
for key in ("b64_json", "base64", "image_base64", "image_b64"):
value = node.get(key)
if isinstance(value, str) and value.strip():
item = ("base64", value.strip())
if item not in seen:
seen.add(item)
found.append(item)
for value in node.values():
visit(value)
return
if isinstance(node, list):
for value in node:
visit(value)
visit(response_obj)
return found
def _guess_extension_from_mime(mime: str | None) -> str:
if not mime:
return ".png"
ext = mimetypes.guess_extension(mime.split(";", 1)[0].strip())
return ext or ".png"
def _ssl_context() -> ssl.SSLContext:
try:
import certifi # type: ignore
return ssl.create_default_context(cafile=certifi.where())
except Exception:
return ssl.create_default_context()
def download_url(url: str, *, timeout_s: float, ctx: ssl.SSLContext) -> tuple[bytes, str | None]:
req = request.Request(url=url, method="GET")
with request.urlopen(req, timeout=timeout_s, context=ctx) as resp:
body = resp.read()
mime = resp.headers.get("Content-Type")
return body, mime
def render_curl_command(api_url: str, payload: dict[str, Any]) -> str:
body = json.dumps(payload, ensure_ascii=False)
return (
"curl --request POST \\\n"
f" --url '{api_url}' \\\n"
" --header 'Authorization: Bearer [TOKEN]' \\\n"
" --header 'Content-Type: application/json' \\\n"
f" --data-raw '{body}'"
)
def _extract_task_ids(obj: Any) -> list[str]:
task_ids: list[str] = []
def visit(node: Any) -> None:
if isinstance(node, dict):
for key in ("task_id", "taskId", "id"):
value = node.get(key)
if isinstance(value, str) and value.startswith("task_"):
task_ids.append(value)
for value in node.values():
visit(value)
return
if isinstance(node, list):
for value in node:
visit(value)
visit(obj)
seen: set[str] = set()
unique: list[str] = []
for tid in task_ids:
if tid not in seen:
seen.add(tid)
unique.append(tid)
return unique
def _extract_urls(obj: Any) -> list[str]:
urls: list[str] = []
def visit(node: Any) -> None:
if isinstance(node, dict):
for key in ("url", "urls"):
value = node.get(key)
if isinstance(value, str) and value.startswith(("http://", "https://")):
urls.append(value)
elif isinstance(value, list):
for item in value:
if isinstance(item, str) and item.startswith(("http://", "https://")):
urls.append(item)
for value in node.values():
visit(value)
return
if isinstance(node, list):
for value in node:
visit(value)
visit(obj)
seen: set[str] = set()
unique: list[str] = []
for url in urls:
if url not in seen:
seen.add(url)
unique.append(url)
return unique
def _task_base_url(api_url: str) -> str:
parts = parse.urlsplit(api_url)
if not parts.scheme or not parts.netloc:
raise ValueError(f"Invalid API_URL: {api_url}")
return f"{parts.scheme}://{parts.netloc}"
def _get_json(url: str, *, token: str, timeout_s: float, ctx: ssl.SSLContext) -> Any:
req = request.Request(url=url, method="GET")
req.add_header("Authorization", f"Bearer {token}")
with request.urlopen(req, timeout=timeout_s, context=ctx) as resp:
body = resp.read()
return json.loads(body.decode("utf-8", errors="replace"))
def poll_task(
*,
api_url: str,
task_id: str,
token: str,
timeout_s: float,
max_wait_s: float,
interval_s: float,
ctx: ssl.SSLContext,
) -> dict[str, Any]:
base_url = _task_base_url(api_url)
url = f"{base_url}/v1/tasks/{task_id}"
deadline = time.time() + max_wait_s
last_obj: Any = None
while True:
try:
last_obj = _get_json(url, token=token, timeout_s=timeout_s, ctx=ctx)
except error.HTTPError as exc:
body = exc.read() if hasattr(exc, "read") else b""
return {"error": {"type": "HTTPError", "code": exc.code, "body": body.decode("utf-8", errors="replace")}}
except Exception as exc:
return {"error": {"type": type(exc).__name__, "message": str(exc)}}
status = str(((last_obj or {}).get("data") or {}).get("status") or "").strip().lower()
if status in {"completed", "failed", "canceled", "cancelled"}:
break
if time.time() >= deadline:
break
time.sleep(max(0.1, interval_s))
return last_obj if isinstance(last_obj, dict) else {"data": last_obj}
def _write_run_json(path: Path, obj: dict[str, Any]) -> None:
path.write_text(json.dumps(obj, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _get_main_output_dir() -> Path:
"""
检测主工作目录:
- 如果当前目录在 .claude/skills 下,往上找到主工作目录
- 否则使用当前目录
"""
cwd = Path.cwd().resolve()
# 检查是否在 .claude/skills 路径下
parts = cwd.parts
if '.claude' in parts and 'skills' in parts:
# 找到 .claude 的位置,往上一层就是主工作目录
try:
claude_idx = parts.index('.claude')
main_dir = Path(*parts[:claude_idx])
return main_dir / "outputs"
except (ValueError, IndexError):
pass
# 默认返回当前目录下的 outputs
return Path("outputs")
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="Batch generate images via APIMart.")
parser.add_argument("--config", type=Path, default=Path("scripts/apimart.env"))
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--out", type=Path, default=None)
parser.add_argument("--timeout", type=float, default=120.0)
parser.add_argument("--dry-run", action="store_true", help="Do not call API; write a single run.json with requests/curl.")
parser.add_argument("--no-download", action="store_true", help="Do not download images; still record URLs in run.json.")
parser.add_argument("--max-wait", type=float, default=300.0, help="Max seconds to wait for async task completion.")
parser.add_argument("--interval", type=float, default=2.0, help="Polling interval seconds for async tasks.")
args = parser.parse_args(argv)
config = load_config(args.config)
api_url = config.get("API_URL", "https://api.apimart.ai/v1/images/generations").strip()
model = config.get("MODEL", "gemini-3-pro-image-preview").strip()
token = config.get("TOKEN", "").strip()
resolution = config.get("RESOLUTION", "2K").strip()
size = config.get("SIZE", "16:9").strip()
n = _as_int(config.get("N"), default=1)
pad_url = config.get("PAD_URL", "").strip()
if not api_url:
raise ValueError("Missing API_URL in config")
if not model:
raise ValueError("Missing MODEL in config")
if not resolution:
raise ValueError("Missing RESOLUTION in config")
if not size:
raise ValueError("Missing SIZE in config")
if not args.dry_run and not token:
raise ValueError("Missing TOKEN in config (or use --dry-run)")
raw_requests = load_requests(args.input)
normalized = normalize_requests(
raw_requests,
default_model=model,
default_size=size,
default_n=n,
default_resolution=resolution,
default_pad_url=pad_url,
)
if not normalized:
print("No valid requests found (need at least a 'prompt').", file=sys.stderr)
return 2
if args.out is None:
stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
base_output_dir = _get_main_output_dir()
out_dir = base_output_dir / f"apimart-{stamp}"
else:
out_dir = args.out
out_dir.mkdir(parents=True, exist_ok=True)
run_json_path = out_dir / "run.json"
ctx = _ssl_context()
started_at = _dt.datetime.now().isoformat(timespec="seconds")
run: dict[str, Any] = {
"started_at": started_at,
"input": str(args.input),
"output_dir": str(out_dir),
"config": {
"api_url": api_url,
"model": model,
"resolution": resolution,
"size": size,
"n": n,
"pad_url": pad_url,
},
"items": [],
}
_write_run_json(run_json_path, run)
for item in normalized:
request_id = _safe_filename_part(item.request_id)
record: dict[str, Any] = {
"id": request_id,
"request": item.payload,
}
if args.dry_run:
record["curl"] = render_curl_command(api_url, item.payload)
run["items"].append(record)
_write_run_json(run_json_path, run)
continue
try:
status, headers, body = post_json(url=api_url, token=token, payload=item.payload, timeout_s=args.timeout)
except error.HTTPError as exc:
body = exc.read() if hasattr(exc, "read") else b""
record["error"] = {
"type": "HTTPError",
"message": str(exc),
"body": (body or b"").decode("utf-8", errors="replace"),
}
run["items"].append(record)
_write_run_json(run_json_path, run)
print(f"[{request_id}] HTTPError: {exc}", file=sys.stderr)
continue
except Exception as exc:
record["error"] = {"type": type(exc).__name__, "message": str(exc)}
run["items"].append(record)
_write_run_json(run_json_path, run)
print(f"[{request_id}] Error: {exc}", file=sys.stderr)
continue
record["post"] = {"status": status, "headers": headers}
response_obj: Any | None = None
body_text = body.decode("utf-8", errors="replace")
try:
response_obj = json.loads(body_text)
record["post_json"] = response_obj
except Exception:
record["post_text"] = body_text
saved_images: list[dict[str, Any]] = []
image_sources: list[dict[str, Any]] = []
tasks: list[dict[str, Any]] = []
if response_obj is not None:
images = extract_images(response_obj)
for idx, (kind, value) in enumerate(images, start=1):
if kind == "base64":
mime, b64 = _extract_base64_blob(value)
image_sources.append({"kind": "base64", "index": idx, "mime": mime})
if args.no_download:
continue
try:
data = base64.b64decode(b64, validate=False)
except Exception:
continue
ext = _guess_extension_from_mime(mime)
filename = f"{request_id}-{idx}{ext}"
(out_dir / filename).write_bytes(data)
saved_images.append({"filename": filename, "source_kind": "base64", "index": idx})
continue
if kind == "url":
image_sources.append({"kind": "url", "index": idx, "url": value})
if args.no_download:
continue
try:
data, mime = download_url(value, timeout_s=args.timeout, ctx=ctx)
except Exception:
continue
ext = _guess_extension_from_mime(mime)
filename = f"{request_id}-{idx}{ext}"
(out_dir / filename).write_bytes(data)
saved_images.append({"filename": filename, "source_kind": "url", "index": idx, "url": value})
task_ids = _extract_task_ids(response_obj)
for task_index, task_id in enumerate(task_ids, start=1):
task_obj = poll_task(
api_url=api_url,
task_id=task_id,
token=token,
timeout_s=args.timeout,
max_wait_s=args.max_wait,
interval_s=args.interval,
ctx=ctx,
)
task_entry: dict[str, Any] = {"task_id": task_id, "task": task_obj}
task_urls = _extract_urls(task_obj)
if task_urls:
task_entry["image_urls"] = task_urls
tasks.append(task_entry)
if args.no_download:
continue
for url_index, url in enumerate(task_urls, start=1):
try:
data, mime = download_url(url, timeout_s=args.timeout, ctx=ctx)
except Exception:
continue
ext = _guess_extension_from_mime(mime)
filename = f"{request_id}-task{task_index}-{url_index}{ext}"
(out_dir / filename).write_bytes(data)
saved_images.append(
{
"filename": filename,
"source_kind": "task_url",
"task_id": task_id,
"task_index": task_index,
"url_index": url_index,
"url": url,
}
)
if image_sources:
record["image_sources"] = image_sources
if tasks:
record["tasks"] = tasks
if saved_images:
record["saved_images"] = saved_images
run["items"].append(record)
_write_run_json(run_json_path, run)
print(f"[{request_id}] OK ({status})")
run["ended_at"] = _dt.datetime.now().isoformat(timespec="seconds")
_write_run_json(run_json_path, run)
print(f"\nDone. Output: {out_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
# Local-only config (contains a real token; do NOT share or commit)
API_URL: https://api.apimart.ai/v1/images/generations
MODEL: gemini-3-pro-image-preview
TOKEN:
RESOLUTION: 2K
SIZE: 16:9
N: 1
PAD_URL:APIMart 批量出图(本地脚本)
这个目录用于“阶段4 Prompt Pack 已确认后”的批量出图。
1) 配置
把配置写到 scripts/apimart.env(建议只放在本地,不要公开或提交):
- 示例:
scripts/apimart.env.example
2) 输入(JSONL)
一行一张图(每行一个 JSON),最少需要 prompt:
{"id":"01","prompt":"...","size":"16:9","n":1,"resolution":"2K","model":"gemini-3-pro-image-preview","pad_url":""}3) 运行
python3 scripts/apimart_batch_generate.py \
--config scripts/apimart.env \
--input out/apimart.requests.jsonl输出默认写到 outputs/。输出目录内只会包含:
run.json:本次批量的请求 + 返回(含 task 轮询结果/图片 URL)汇总- 最终图片文件:与
id对应命名(不再生成requests/、responses/等子目录)
阶段1:需求澄清
目标: 在“拆文章/配图”之前,先把用户的真实需求说清楚:内容是什么、用在什么场景、谁来看、想要字多还是字少。其它默认值(如尺寸/风格)不打扰用户。
你需要问用户的 4 个问题(优先级从高到低)
1. 内容(先给我原文/要点):把要配图的内容贴过来(整段/小节/提纲都行)。
- (可选补一句)你希望读者看完记住哪一句话/得到什么结论?
2. 场景(用在哪、怎么看):手机近看 / PPT 投影远看 / 海报打印?
- 如果多个都有:哪个是主场景(我优先为它优化字号与密度)?
3. 受众(谁来看):小白 / 从业者 / 老板 / 学生…(决定措辞与隐喻) 4. 字多还是字少:你希望更“少字清爽”还是更“信息密度高”(决定文案压缩强度)
本阶段输出物
DoD(Definition of Done:没确认不进阶段2)
- 你复述给用户确认的 1 句话(推荐直接这样说):
- 内容主题 + 主场景 + 受众 + 字多/字少 +(我建议的图类型)
- 默认补齐但不打扰用户:
16:9、默认风格(除非用户明确提“竖版/品牌规范/参考图”) - 口径对齐(方便后续阶段衔接):
- 这里的“字多/字少”就是后续阶段会用到的“文字预算”
- “图类型”用户不确定没关系:你可以先推荐一个,用户点头即可进入阶段2
- 需求小结(不超过 5 条,尽量用口语化而非硬指标):
- 例如:
手机近看为主、给小白看、偏少字清爽、希望读者记住:……、先做 1 张概览框架图
给用户的参考输入(让他照抄也行)
- 内容:……(粘贴原文/要点/提纲)
- 场景:手机近看为主(也会用于 PPT)
- 受众:给……看(小白/从业者/老板/学生…)
- 偏好:字少/字多
- 目标:希望读者看完记住……
阶段2:配图规划(要几张图?每张讲什么?)
目标: 基于阶段1已确认的规格(图类型/文字预算/用途),先“拆内容→定图清单→选版式”,避免一张图塞太多导致难看难读。
规划原则(核心)
- 优先遵守阶段1的图类型与文字预算:不符合预算的内容,不要硬塞到图里。
- 一张图=一个核心信息(通常是一个判断):读者扫一眼就能记住一句话。
- 概念图 vs 案例图分开:概念负责“定义与选型”,案例负责“过程可视化”。
- 同一篇文章的多张图要统一风格:同一套纸张/线条/配色/边框/涂鸦。
2A:内容拆块(长文必做)
当用户给你“很长的文章/一堆段落”时,这一步就是你要做的事:
- 先按 小标题/段落主题 抽出模块(不要逐句改写)
- 把模块聚类成 3–7 个“可画的块”(每块一句话概括,不写细节)
- 标记每块更适合的图形承载:流程 / 对比 / 清单 / 关系图 / 漫画
这一步的输出不要是提示词,而是“图清单草案”,让用户先选方向。
2B:判断“需要几张”的启发式规则
1. 纯定义 + 场景:通常 1 张(卡片/对比/列表)。 2. 涉及流程/步骤:至少 1 张流程图;若还有“为什么这样”再加 1 张对比/解释图。 3. 需要讲差异(A vs B):1 张对比图;如果有经典小故事,再加 1 张漫画/流程故事图。 4. 收束段落(总结/洞察/框架):1 张(要点卡片/决策树/关系总览)。
2C:图类型分支(避免“阶段2原则”误伤)
- 封面目录图/课程路线图(允许列 4–6 个模块)
- 目的:读者一眼知道“这节课讲什么结构”
- 文案策略:每块 仅标题(解释信息移到后续单页/讲义图)
- 版式建议:5步流程带 / 路线图 / 分段里程碑
- 概览框架图(强结构+少量结论)
- 目的:给判断框架(每块允许 1 行结论)
- 讲义解释图(信息密度更高)
- 目的:把“为什么/怎么做”讲清楚(但仍避免长段落)
本阶段输出物(给用户确认)
- 配图清单(用户确认后才进入阶段3):
- 图数量:N 张
- 每张图:一句话目的 + 对应内容块 + 建议版式模板
- 若是封面目录图:明确“只放标题、不放解释小字”
阶段3:文案定稿(Copy Spec:唯一真值)
目标: 把内容变成“上图文案规格表(Copy Spec)”:逐字定稿 + 字数预算 + 区域结构。阶段4只负责“封装成提示词”,不再改文案本身。
先选模式(必须与阶段1一致)
- 封面模式(目录/路线图):每块只放标题;不写解释句;尽量不出现小字。
- 概览模式(框架图):每块允许 1 行结论;禁止长解释。
- 讲义模式(解释图):可以更密,但仍要短句;避免段落与密集小字。
如果模式不清楚,回到阶段1补齐“图类型 + 文字预算”,否则阶段3会跑偏。
压缩规则(默认规则;封面模式更严格)
- 先删掉“讲解用”的背景句,只保留“结论/动作”
- 默认:每块区域 1–2 行,每行尽量 ≤10 个字
- 封面模式:每块 仅 1 行标题(0 解释)
- 用直观词替代术语堆叠:更稳/更可控/更省心/能跑通/能复刻…
隐喻与元素(只列清单,不画细节)
- 超载/规则太多:塞爆背包、溢出的纸条、仪表盘转红
- 复杂任务一步到位:打结毛线/一团乱麻 → 分卷/分步骤卡片
- Agent 自驱:失败→排查→定位→修复→重试(漫画)
- Skills:工具箱/文件夹积木/工牌
- 关系总览:阶梯演化 + 套娃容器/大盒子调度
Copy Spec 输出模板(你交付给用户确认的格式)
对每张图输出一份 Copy Spec(可直接粘贴):
- 图编号:图1 / 图2 / …
- 图类型:封面 / 概览 / 讲义
- 画幅:16:9(或用户指定)
- 版式模板:对比两卡 / 三卡洞察 / 五步路线图 / 通用信息图 / 五格漫画
- 文字预算(硬指标):例如“全图仅:标题1行 + 5块标题 + 底栏2行;禁小字;禁模型加字”
- 区域 → 逐字文案(唯一真值):
- 标题:
- 区域A(如:流程5块):
- 区域B(如:底部两卡):
- 图标/隐喻清单(可选):每个区域配 1 个图标关键词
本阶段输出物
- Copy Spec(逐字定稿):用户确认后才进入阶段4
- 若用户说“字还是多”:先回到“文字预算”,优先删到“每卡 1 句(或仅标题)”
阶段4:提示词封装(Prompt Pack:可执行生成包)
目标: 把阶段3的 Copy Spec 原样封装成"可复制/可调用"的提示词包(Prompt Pack),并支持批量出图。阶段4不负责改文案,只负责:模板拼装、风格一致、参数/约束齐全、避免模型乱加字、把提示词整理成可批量请求的结构化请求包。
封装原则(避免和阶段3混淆)
- Copy Spec 是唯一真值:提示词中“必须逐字放入”的文字,直接来自阶段3,不在这里重写。
- 提示词负责“怎么画”:画幅、版式、留白、对齐、图标隐喻、风格块、强制约束、负面提示、参数。
- 封面类默认“禁额外小字”:明确写“除指定文字外不要生成任何额外文字”。
生成步骤(按顺序)
1. 选定结构模板(与 Copy Spec 的版式一致) 2. 粘贴通用风格块:templates/style-block.md
- 风格基准锁定:每张图都必须以
templates/style-block.md定义的风格作为唯一允许的基础风格来生成(奶油纸 + 彩铅线稿 + 淡水彩 + 轻涂鸦、少字高可读)。 - 不得换风格:不要让模型自行切换成扁平矢量海报风/3D/摄影写实等“更像信息图默认风格”的路线。
- 允许你用自己的话描述该风格,但不能删掉关键要素与负面约束(否则风格会被模型先验带偏)。
3. 写清楚画幅/用途(PPT远看 vs 手机近看)与排版硬约束(对齐、留白、字号) 4. 粘贴 Copy Spec 的"必须逐字放入的文字" 5. 加强制约束 + 负面提示(无乱码/不加字/不密集小字/不背景杂乱) 6. 生成批量请求包(JSONL):把每张图的 Prompt 内容写入一行(参考 templates/apimart-requests-jsonl.md) 7. 用户选择批量API方式后:直接生成JSONL并执行批量出图(不再二次确认)
模板使用
- 通用风格块:
templates/style-block.md - 结构模板:
- 封面路线图(目录/5步):
templates/16x9-cover-roadmap.md - 对比两卡:
templates/16x9-contrast-2cards.md - 三卡洞察:
templates/16x9-3cards-insights.md - 五格漫画:
templates/16x9-5panel-comic.md - 通用信息图:
templates/16x9-infographic.md
本阶段输出物
- Prompt Pack:按"图1/图2/…"编号输出;每张图一个独立代码块(便于复制/脚本调用);代码块外最多 1–2 句说明
- Batch Request Pack(JSONL):例如
out/apimart.requests.jsonl(一行一张图,字段见下文) - 执行方式:当用户在阶段4明确选择"批量API"时,先输出提示词让用户查看(选项A:手动出图 或 选项B:批量API),一旦用户选择B,直接生成JSONL并执行,不再二次确认
为什么“阶段4”容易风格跑偏(解释逻辑)
阶段4本质是“用文字去约束一个带强默认审美的出图模型”,风格会被多方力量拉扯:
1. 模型先验(Style Prior):很多模型看到 “infographic/信息图” 会自动偏向“干净的扁平矢量/海报风”,即使你写了彩铅水彩,也可能只被当作弱建议。 2. 可读性约束会压过质感:当你同时要求“中文大字号、严格对齐、少字、清晰”,模型会优先保证字清楚与版式稳定,牺牲纸纹、彩铅笔触等“质感细节”。 3. 风格基准不够“排他”会降权:如果不强调“这是唯一允许风格,不能换”,模型会把它当成“可选项”,然后自动回到信息图的默认风格(常见是扁平矢量/海报风)。 4. 风格词太短/太抽象:仅写“彩铅水彩”不足以锁定细节,需要补“纸纹可见、笔触可见、轻晕染”等可观察特征,并配合负面约束(已在风格块中补强)。
实操上要提升稳定性:在每张图的 prompt 里都明确“以该风格为唯一基础,不得换风格”,并加入“不要扁平矢量/不要3D/不要摄影”等负面约束来对冲模型的默认风格。
---
批量调用 APIMart API 出图(用户选择后直接执行)
规则:先封装 Prompt Pack 并展示给用户 → 询问"手动出图"还是"批量API" → 用户选择"批量API"后直接生成JSONL并执行。不在生成JSONL后再次确认。
需要的两个东西
1. API 配置(建议放本地文件):scripts/apimart.env(参考 scripts/apimart.env.example 与 templates/api-config.md) 2. 批量请求包(JSONL):例如 out/apimart.requests.jsonl(参考 templates/apimart-requests-jsonl.md)
请求包字段(每行一张图)
id:建议01/02/ …prompt:阶段4输出的 Prompt 内容(可直接粘贴)size:默认16:9n:默认1resolution:默认2Kmodel:默认gemini-3-pro-image-previewpad_url:可留空(暂不需要垫图 URL)
运行方式(二选一)
A) 用脚本批量出图(推荐)
python3 scripts/apimart_batch_generate.py \
--config scripts/apimart.env \
--input out/apimart.requests.jsonlB) dry-run(不请求;把 curl 与请求信息写入单个 `run.json`)
python3 scripts/apimart_batch_generate.py \
--config scripts/apimart.env \
--input out/apimart.requests.jsonl \
--dry-runcurl 格式参考:templates/apimart-curl.md阶段5:迭代润色(让图更好看)
目标: 根据用户反馈快速迭代:更趣味、更少字、更清楚。
常见反馈 → 对应动作
- “字太多/太丑” → 每卡压到 1 句 + 1 关键词,删掉副标题或把副标题变短
- “这张应该是封面/目录,但你给成了讲义” → 回到阶段1重锁:图类型 + 文字预算(封面模式:每块仅标题,禁小字)
- “隐喻看不懂” → 换成更大众隐喻(毛线/背包/路牌/工具箱)
- “排版不均衡” → 强调“等宽卡片/严格对齐/留白”
- “中文不清楚” → 强调“大字号/无小字/干净字体/避免花体”
- “模型乱加字/补充说明” → 在提示词里加硬约束:除指定文字外不要生成任何额外文字
回归检查
迭代后对照 templates/checklist.md 过一遍再交付。
16:9 三卡洞察模板(趣味少字版)
标题:{标题}
布局:三张等宽卡片横排(颜色区分、严格对齐)。
卡片1:
- 图标/隐喻:{台阶/进化/路标}
- 文案:1 句 + “关键词:{词}”
卡片2:
- 图标/隐喻:{双刃剑/火箭+刹车}
- 文案:1 句 + “关键词:{词}”
卡片3:
- 图标/隐喻:{分岔路牌}
- 文案:极短路牌(3 行以内)或 1 句 + “关键词:{词}”
底部结论(大字一行):{结论}
强制:中文清晰可读、无乱码;每卡最多 2 行字;不要密集小字。
16:9 五格漫画模板(小故事讲差异)
标题:{标题}
布局:横向 5 格漫画/流程格,箭头清晰;每格只放 1 句短文案。
格1:{起点/目标} 格2:{第一次失败} 格3:{排查/分析} 格4:{找到原因} 格5:{给出解法+重试成功}
底部对比一句话(可选):{Workflow:…|Agent:…}
强制:中文清晰可读、无乱码;大字号;每格仅 1 句;留白足。
16:9 两卡对比模板(少字好读)
标题:{标题} 副标题:{副标题(可选)}
布局:左右两张等宽大卡片 + 中间分隔线/对比符号,严格对齐。
左卡:
- 标题:{A}
- 画面隐喻:{A 的物件/动作}
- 文案(1–2 行):{短句}
右卡:
- 标题:{B}
- 画面隐喻:{B 的物件/动作}
- 文案(1–2 行):{短句}
底部结论(1 行):{结论}
强制:中文清晰可读、无乱码;大字号;避免段落;留白充足。
16:9 封面路线图模板(目录/课程结构)
把 {占位符} 替换成你的内容:
标题(顶部超大字):{标题} 副标题(可选,尽量短):{副标题}
中间主体:从左到右 4–6 个大模块(圆角矩形/里程碑),用粗箭头连接;每个模块只放 1 行标题。
模块1:{标题} 模块2:{标题} 模块3:{标题} 模块4:{标题} 模块5(可选):{标题} 模块6(可选):{标题}
底部(可选):两张并列大卡片(每卡仅 1 行)
- 左卡:{交付/你带走什么}
- 右卡:{不讲/边界}
强制约束:
- 中文清晰可读、无乱码
- 除“指定文字”外不要生成任何额外文字(禁小字注解)
- 留白足、对齐、箭头清晰;图标/插画要大,用元素代替解释文字
16:9 通用信息图模板(骨架)
把 {占位符} 替换成你的内容:
标题(顶部大字):{标题} 副标题(小字):{副标题(可选,尽量短)}
主体:{版式类型:对比/流程/卡片/漫画}
- 画面隐喻:{用背包/毛线/路牌/工具箱等}
- 文案规则:每块 1–2 行短句
底部结论框(大字一行):{结论}
强制约束:
- 中文清晰可读、无乱码
- 不要小字密集段落
- 留白足、对齐、箭头清晰(如有)
API 配置
建议:把真实TOKEN放在scripts/apimart.env(本地文件),不要写进文档/仓库或聊天记录。
API_URL: https://api.apimart.ai/v1/images/generations
MODEL: gemini-3-pro-image-preview
TOKEN: <YOUR_TOKEN>
RESOLUTION: 2K
SIZE: 16:9
N: 1
PAD_URL:批量请求包(JSONL)模板
一行一张图(每行一个 JSON),用于脚本批量出图。
{"id":"01","prompt":"<PROMPT_CONTENT>","size":"16:9","n":1,"resolution":"2K","model":"gemini-3-pro-image-preview","pad_url":""}出图提示词回归检查(交付前 30 秒过一遍)
- [ ] 一张图只讲一个判断,没有把解释段落塞进图
- [ ] 文案符合阶段1的“图类型+文字预算”(封面目录图=只放标题;概览=允许1行结论)
- [ ] 每块区域 1–2 行短句(或更少),没有小字密集(封面模式:无解释小字)
- [ ] 明确写了:中文清晰可读、无乱码、大字号
- [ ] 明确写了:除指定文字外不加字(防止模型自作主张补充说明)
- [ ] 卡片对齐、留白充足、层级清晰
- [ ] 若有流程:箭头醒目、步骤数不超过 5
- [ ] 风格统一:奶油纸 + 彩铅水彩 + 轻涂鸦,趣味但干净
通用风格块(风格基准:必须以此为唯一基础)
- 画幅:16:9 横版信息图(除非用户要竖版)
- 质感:奶油色纸张底(纸纹可见),彩铅线稿(笔触可见) + 淡水彩上色(轻晕染)
- 氛围:暖色调、轻涂鸦装饰、趣味但干净
- 可读性:中文必须清晰可读、无乱码;大字号;少字短句;避免密集小字/段落
- 版式:留白充足、层级清晰、卡片对齐、箭头醒目
- 负面约束:不要扁平矢量海报风、不要 3D、不要摄影写实、不要复杂背景/强渐变光效、不要额外小字注解/水印/署名、不要英文与随机字符