zcbot/tools/seedream.py

323 lines
14 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.

"""seedream: 调豆包 Seedream 图像生成 API,产物落 working_dir/figures/。
模型 ID + 单价 + 默认参数全在 `config/media/doubao.yaml`,本 tool 只装配。
完成后:
- 图片落到 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.png`
- 默认隐藏的 `.meta/` 写 prompt / model / size / search / cost_cny / response_id / ts
- usage_events 写 kind="image" 一行(单价 snapshot 进 units → 跨调价对账)
"""
from __future__ import annotations
import json
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from uuid import UUID
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import record_image_usage
from .base import Tool
from .image_ref import load_image_as_data_url
from .media_common import (
find_first_url,
quota_gate,
record_usage_safe,
stamped_path,
write_meta,
)
class SeedreamTool(Tool):
name = "seedream"
description = (
"Generate (text-to-image) OR edit (image-to-image) an image with Doubao Seedream 5.0, "
"saved to working_dir/figures/. Text-to-image: describe what to draw. "
"Image-to-image (改图): pass `reference_images` with an existing image path to modify it "
"at pixel level — use this when the user wants to tweak an already-generated/uploaded image "
"(e.g. '把刚才那张图的天空改成黄昏'), NOT a fresh text-to-image (which would lose the original). "
"Use when the user explicitly asks for / to change an image. "
"Each call costs ¥0.22 (¥0.05 extra if search=true). Don't generate decoratively — "
"only when the user actually wants an image. Returns the saved relative path."
)
parameters = {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "中文或英文都行,详尽描述画面(主体/风格/光线/构图)。改图(reference_images)时只描述「要改成什么」即可。",
},
"reference_images": {
"type": "array",
"items": {"type": "string"},
"description": (
"改图(image-to-image):传 1 张已存在图片的相对路径(task_dir 内,如 "
"'figures/xxx.png',或 seedream 上次返回的 saved 路径)做像素级修改。"
"不传 = 从零文生图。**v1 只支持 1 张**(传多张会报错)。"
"基于刚生成/用户上传的图做局部修改,务必走这里指那张图,不要重新文生图。"
),
},
"size": {
"type": "string",
"description": "Image size like '2048x2048' / '1024x1024' / '3072x3072'. Defaults to config (2048x2048). 改图时建议保持 ≥1920²(ARK i2i 最小输出约束)。",
},
"watermark": {
"type": "boolean",
"description": "是否打豆包水印。默认 false(申报/PPT 场景不需要)。",
},
"search": {
"type": "boolean",
"description": "是否启用联网搜索辅助生成(适合时事/特定品牌等)。默认 false,启用会加价约 ¥0.05/张。",
},
},
"required": ["prompt"],
}
def __init__(
self,
*,
ark_cfg: ArkConfig,
image_variant_cfg: dict,
variant_key: str,
working_dir: Path,
task_id: UUID,
user_id: UUID,
base_dir: Optional[Path] = None,
user_root: Optional[Path] = None,
daily_limit: int = 0,
) -> None:
super().__init__(base_dir, user_root=user_root)
self.ark_cfg = ark_cfg
self.cfg = image_variant_cfg
self.variant_key = variant_key # 'seedream_5' → usage_events.model_profile = "doubao.seedream_5"
self.working_dir = Path(working_dir)
self.task_id = task_id
self.user_id = user_id
self.daily_limit = int(daily_limit) # 0 / 负 = 不限;由 agent_builder 从 yaml quotas 透传
def execute(
self,
prompt: str,
reference_images: Optional[list] = None,
size: Optional[str] = None,
watermark: Optional[bool] = None,
search: Optional[bool] = None,
) -> str:
if not (prompt or "").strip():
return "[Error] prompt 不能为空"
# 改图(i2i)分支:把参考图读成 base64 data URL → ARK body image_urls。
# 不传 / 空 → 走文生图(t2i),与历史行为完全一致(向后兼容)。
refs = [str(r).strip() for r in (reference_images or []) if str(r).strip()]
ref_data_urls: list[str] = []
ref_disp: list[str] = []
if len(refs) > 1:
return (
"[Error] reference_images v1 仅支持单张参考图(传了 "
f"{len(refs)} 张)。多图合成/角色定义留 v2,当前请只传 1 张。"
)
if refs:
data_url, disp, err = load_image_as_data_url(
refs[0],
working_dir=self.working_dir,
user_root=self.user_root,
display_fn=self._display,
)
if err:
return err
ref_data_urls.append(data_url)
ref_disp.append(disp)
is_i2i = bool(ref_data_urls)
# 每账号每日配额(yaml quotas.images_per_day),细节见 media_common.quota_gate
quota_err = quota_gate(
self.user_id, kind="image", limit=self.daily_limit, what="图片生成", noun="",
)
if quota_err:
return quota_err
cfg = self.cfg
model_id = cfg["model_id"]
requested_size = size or cfg.get("default_size", "2048x2048")
# ARK 硬门:输出面积必须落在 [min_pixels, max_pixels],否则 400 InvalidParameter。
# 模型自选 16:9 之类小尺寸(1920x1080=2.07M < 3.69M)会被打回,这里等比钳到合法区间,
# 静默纠错省一轮往返;已合规的尺寸原样透传。归一化时给用户一行提示。
chosen_size, size_note = self._normalize_size(
requested_size,
min_pixels=int(cfg.get("min_pixels", 0)),
max_pixels=int(cfg.get("max_pixels", 0)),
)
chosen_watermark = bool(cfg.get("default_watermark", False)) if watermark is None else bool(watermark)
chosen_search = bool(cfg.get("default_search", False)) if search is None else bool(search)
timeout_s = float(cfg.get("request_timeout_s", 60))
price = float(cfg.get("price_cny_per_image", 0))
body: dict[str, Any] = {
"model": model_id,
"prompt": prompt,
"size": chosen_size,
"response_format": "url",
"watermark": chosen_watermark,
}
if is_i2i:
# ARK /images/generations 接受 base64 data URL 作 image_urls(probe 2026-05-29 实测通)
body["image_urls"] = ref_data_urls
if chosen_search:
# 豆包 search 参数透传(YAML 注释里说明加价 ~¥0.05/张)
body["search"] = True
endpoint = cfg.get("endpoint", "/images/generations")
t0 = time.monotonic()
try:
with ArkClient(self.ark_cfg, timeout_s=timeout_s) as client:
resp = client.post_json(endpoint, body, timeout_s=timeout_s)
image_url, response_id = self._extract_url(resp)
if not image_url:
return f"[Error] seedream response 缺 image url: {json.dumps(resp, ensure_ascii=False)[:300]}"
# 落盘 figures/<ts>-<rand>.png技术元数据进入默认隐藏的 figures/.meta/
dest_png = stamped_path(self.working_dir / "figures", ".png")
client.download(image_url, dest_png, timeout_s=120.0)
except ArkError as e:
return f"[Error] seedream API: {e}"
elapsed = time.monotonic() - t0
# 估算成本(单价 snapshot 在 record_image_usage 里同步落库)
extra_cny = 0.05 if chosen_search else 0.0 # 搜索加价的粗略值,仅供 user 提示
cost_cny = float(price) + extra_cny
meta = {
"prompt": prompt,
"model_id": model_id,
"size": chosen_size,
"requested_size": requested_size, # 归一化前模型/用户请求的原始尺寸(=chosen_size 表示未钳)
"watermark": chosen_watermark,
"search": chosen_search,
"mode": "i2i" if is_i2i else "t2i",
"reference_images": ref_disp, # 改图时记录参考图(可追溯派生链),t2i 为空
"cost_cny": cost_cny,
"elapsed_s": round(elapsed, 2),
"response_id": response_id,
"ts": datetime.now().isoformat(timespec="seconds"),
}
write_meta(dest_png, meta)
record_usage_safe(
"seedream", record_image_usage,
task_id=self.task_id,
user_id=self.user_id,
model_profile=f"doubao.{self.variant_key}",
n_images=1,
size=chosen_size,
price_cny_per_image=float(price),
search=chosen_search,
extra_units={"search_extra_cny": extra_cny} if chosen_search else None,
)
disp = self._display(dest_png)
# 第一行 banner:前端 SPA 把这行(name===seedream 时)单独提到 details summary
# 旁边显示,用户不展开就能看到 model / size / cost / 耗时 —— 透明性的关键。
# 格式严格 key=value · 分隔,parse 用正则 `key=([^·\n]+)` 抓。
mode_seg = " · mode=i2i" if is_i2i else ""
ref_line = f"\nreference={ref_disp[0]}" if is_i2i else ""
note_line = f"\n{size_note}" if size_note else ""
result = (
f"[seedream] model={model_id} · size={chosen_size} · "
f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s{mode_seg}\n"
f"saved: {disp}{ref_line}\n"
f"prompt={prompt!r}\n"
f"watermark={chosen_watermark} search={chosen_search}{note_line}"
)
if self.user_root is None:
return result
_, rel = resolve_artifact_path(
str(dest_png), working_dir=self.working_dir, user_root=self.user_root,
)
return ToolExecutionResult(content=result, artifacts=(ArtifactRef(path=rel),))
@staticmethod
def _normalize_size(
requested: str, *, min_pixels: int = 0, max_pixels: int = 0
) -> tuple[str, str]:
"""把请求尺寸钳进 ARK 面积约束 [min_pixels, max_pixels],保持宽高比。
返回 (chosen_size, note):note 非空表示发生了钳制(用于提示用户 + 记账用真实尺寸)。
- 无法解析成 "WxH" / 任一边 <= 0 → 原样返回,不阻塞(交给 API 自己报错,行为不回退)。
- min/max 传 0 → 视为不设该侧约束(向后兼容:旧 yaml 无这两个键时不改变行为)。
- 面积 < min:按 s=sqrt(min/area) 等比放大,两边向上取整到 8 的倍数,复核达标(不够再 +8)。
- 面积 > max:按 s=sqrt(max/area) 等比缩小,两边向下取整到 8 的倍数,复核达标(超了再 -8)。
- 已在区间内 → 原样透传,note 为空。
"""
raw = (requested or "").strip().lower().replace(" ", "")
parts = raw.split("x")
if len(parts) != 2:
return requested, ""
try:
w, h = int(parts[0]), int(parts[1])
except ValueError:
return requested, ""
if w <= 0 or h <= 0:
return requested, ""
import math
def _round8(v: float, *, up: bool) -> int:
n = math.ceil(v / 8) if up else math.floor(v / 8)
return max(8, n * 8)
area = w * h
if min_pixels > 0 and area < min_pixels:
s = math.sqrt(min_pixels / area)
nw, nh = _round8(w * s, up=True), _round8(h * s, up=True)
# 取整可能把面积压回下限之下,补到达标为止(沿较长边加 8,尽量不破坏比例)
while nw * nh < min_pixels:
if nw >= nh:
nh += 8
else:
nw += 8
chosen = f"{nw}x{nh}"
return chosen, (
f"[note] 请求尺寸 {w}x{h}({area:,}px)低于模型最小面积 {min_pixels:,}px,"
f"已等比放大到 {chosen} 出图。"
)
if max_pixels > 0 and area > max_pixels:
s = math.sqrt(max_pixels / area)
nw, nh = _round8(w * s, up=False), _round8(h * s, up=False)
while nw * nh > max_pixels:
if nw >= nh:
nw -= 8
else:
nh -= 8
chosen = f"{nw}x{nh}"
return chosen, (
f"[note] 请求尺寸 {w}x{h}({area:,}px)超过模型最大面积 {max_pixels:,}px,"
f"已等比缩小到 {chosen} 出图。"
)
return requested, ""
@staticmethod
def _extract_url(resp: dict) -> tuple[str, str]:
"""ark images/generations 响应解析,容忍几种已知 shape:
- OpenAI 兼容: {"data":[{"url":"..."}], "id":"..."}
- 豆包自有: {"data":{"images":[{"url":"..."}]}}
- 兜底: 任意位置出现的第一个 .url 字符串
"""
rid = str(resp.get("id") or resp.get("request_id") or "")
data = resp.get("data")
if isinstance(data, list) and data:
first = data[0]
if isinstance(first, dict):
u = first.get("url") or first.get("image_url")
if isinstance(u, str):
return u, rid
if isinstance(data, dict):
imgs = data.get("images")
if isinstance(imgs, list) and imgs:
u = imgs[0].get("url") if isinstance(imgs[0], dict) else None
if isinstance(u, str):
return u, rid
# 兜底:递归搜(media_common.find_first_url)
return find_first_url(resp, keys=("url", "image_url")), rid