volcengine-ark-mcp 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ .DS_Store
2
+ .idea/
3
+ .vscode/
4
+ __pycache__/
5
+ *.egg-info/
6
+ .venv/
7
+ dist/
8
+ build/
9
+ .upstream-docs/
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.5
2
+ Name: volcengine-ark-mcp
3
+ Version: 0.6.0
4
+ Summary: 火山方舟图片、Seedance视频、语言模型、搜索、向量与任务恢复 MCP server
5
+ Project-URL: Homepage, https://github.com/hoobnn/hoobnn-mcps/tree/main/servers/volcengine-ark
6
+ Project-URL: Repository, https://github.com/hoobnn/hoobnn-mcps
7
+ Project-URL: Issues, https://github.com/hoobnn/hoobnn-mcps/issues
8
+ Author: hoobnn
9
+ License-Expression: MIT
10
+ Keywords: ark,doubao,mcp,mcp-server,seedance,seedream,volcengine
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Multimedia
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx<1,>=0.28
23
+ Requires-Dist: mcp<3,>=2.3
24
+ Description-Content-Type: text/markdown
25
+
26
+ # volcengine-ark-mcp
27
+
28
+ 调用火山方舟(Volcengine Ark)的 MCP server:Seedream 5.0 生成和编辑图片、Seedance 视频、对话与多模态理解、联网搜索和向量化,产物直接存到本地。
29
+
30
+ 原名 `seedream-mcp`(PyPI 上该名已被他人占用),0.4 起改名,不保留旧命令和 `SEEDREAM_*` 环境变量。
31
+
32
+ `0.6.0` 去掉 `query_video`:视频进度统一用 `get_job(job_id=...)` 或 `get_job(task_id=...)` 查询,完成时自动下载;`generate_image` 新增 `parameters`。这是不兼容变更。
33
+
34
+ ## Seedream 图片生成
35
+
36
+ | 模型 | 参数 | 独有能力 |
37
+ |---|---|---|
38
+ | Seedream 5.0 pro(默认) | `model="pro"` → `doubao-seedream-5-0-pro-260628` | 图层拆分、交互编辑、透明背景、fast 模式 |
39
+ | Seedream 5.0 flash | `model="flash"` → `doubao-seedream-5-0-flash-260915` | 图层拆分、交互编辑、透明背景;不支持 fast 提示词优化 |
40
+ | Seedream 5.0 lite | `model="lite"` → `doubao-seedream-5-0-260128` | 组图、联网搜索、3K / 4K |
41
+
42
+ 三个模型都支持文生图和单图 / 多图参考生图。模型不支持的参数组合会在本地直接报错,不发请求。
43
+
44
+ ## 环境变量
45
+
46
+ | 变量 | 说明 |
47
+ |---|---|
48
+ | `ARK_API_KEY` | 必需,方舟控制台 → API Key 管理 |
49
+ | `ARK_OUT_DIR` | 输出目录,默认 `~/Downloads/volcengine-ark` |
50
+ | `ARK_RESOURCE_MODE` | 交付方式:`local`(默认)下载到本地,`url` 只返回 24 小时内有效的链接 |
51
+ | `ARK_BASE_URL` | 默认 `https://ark.cn-beijing.volces.com/api/v3` |
52
+
53
+ 参数说明见工具描述(`src/volcengine_ark_mcp/server.py`),接口细节以[图片生成 API 文档](https://ark.volcengine.com/region:cn-beijing/docs/ark/image-generation-api)为准。
54
+
55
+ ## 0.3 方舟扩展(待云端验证)
56
+
57
+ 保留原有生图工具,同时接入方舟其他能力,无需新建MCP配置。
58
+
59
+ | 工具 | 能力 | 官方来源 |
60
+ |---|---|---|
61
+ | `generate_video` | Seedance文生、首帧、首尾帧、多模态参考视频 | [创建任务](https://docs.volcengine.com/docs/ark/create-video-generation-task-api?lang=zh)、[查询任务](https://docs.volcengine.com/docs/ark/get-video-generation-task-api?lang=zh) |
62
+ | `chat` | 语言、图片/视频理解、深度思考、结构化输出 | [Chat](https://docs.volcengine.com/docs/ark/chat-api?lang=zh)、[Responses](https://docs.volcengine.com/docs/ark/create-model-responses-api?lang=zh) |
63
+ | `chat(web_search=true)` | 联网搜索,保留来源和完整响应 | [Web Search](https://docs.volcengine.com/docs/ark/web-search?lang=zh) |
64
+ | `embed` | 文本与多模态向量化 | [向量化](https://docs.volcengine.com/docs/ark/vectorization?lang=zh)、[多模态API](https://docs.volcengine.com/docs/ark/multimodal-vectorization-api?lang=zh) |
65
+ | `list_capabilities` | 静态能力目录和边界 | 不联网,不代表账号权限 |
66
+ | `list_jobs` / `get_job` / `recover_job` | 查任务进度(视频生成中会查询方舟并在完成时下载)、补交付 | 图片和视频留档,跨进程重启恢复 |
67
+
68
+ ### Seedance视频
69
+
70
+ 模型别名:`seedance` → `doubao-seedance-2-5-260628`;`seedance-2` / `seedance-fast` / `seedance-mini` → 相应2.0系列。
71
+ 也可直接指定完整模型ID或Endpoint ID。不同模型的素材数量、时长、分辨率限制以官方接口为准。
72
+
73
+ 默认 `wait=0`,提交后返回 `task_id`、`job_id`。`get_job(job_id=..., wait=30)` 查询进度,完成后下载到原目录;别处提交的任务可传 `get_job(task_id=...)`,首次查询会新建本地记录。下载失败或链接过期用 `recover_job(job_id=...)`。都不会提交新生成。
74
+
75
+ 图片支持本地路径/data URL/公网URL;视频和音频参考使用公网URL或 `asset://` ID。本轮没有自动上传本地视频/音频。
76
+ 2.5首帧/首尾帧任务 `ratio=adaptive`;输出时长为4–30秒,2.0系列为4–15秒,均可用 `duration=-1`。首尾帧与全模态参考不能混用;2.0系列不能仅传音频。
77
+ 2.0标准版支持4k,fast/mini仅480p/720p。视频编辑通过prompt明确意图,`duration=-1`、`ratio=adaptive`;可用 `parameters.omni_reference_task_type="edit"` 提前校验任务类型,省略duration采用官方默认值。
78
+ `parameters` 可传官方顶层选项,如 `draft`、`return_last_frame`、`output_format`;不能覆盖模型和输入素材。
79
+
80
+ ### 对话与检索
81
+
82
+ `chat` 默认 `pro` 为 `doubao-seed-2-1-pro-260628`,也可显式指定模型;与生图工具中的pro别名含义不同。
83
+ `images` 支持本地图片,`videos` 须公开URL。不开搜索时使用Chat API;开启 `web_search` 或提供 `previous_response_id` 时使用Responses API。
84
+ 多轮历史采用Chat消息结构,Responses会转换本轮多模态内容;保留 `response_id`、来源注解;完整响应用 `include_response=true` 按需获取。
85
+ `parameters` 透传模型支持的高级选项,不执行模型返回的工具调用。
86
+
87
+ 向量工具须显式指定模型:`texts` 为文本列表;`contents` 为官方类型化数组,例如:
88
+
89
+ ```json
90
+ [{"type":"text","text":"猫"},{"type":"image_url","image_url":{"url":"/absolute/cat.png"}}]
91
+ ```
92
+
93
+ 返回 `data`、`usage`,完整响应用 `include_response=true` 按需获取;不自动建索引或知识库。
94
+
95
+ ### 本地恢复
96
+
97
+ `ARK_JOB_DIR` 默认 `~/.local/share/volcengine-ark-mcp/jobs`。生成前创建记录,响应收到后保留完整响应,内联图片先缓存再交付。
98
+ 组图逐项记录错误,下载失败可单独补交付;图层记录和 `layers.json` 随恢复更新。部分生成失败会返回 `partial`,不会重新生成失败项。
99
+ 视频保存原 `task_id`,通过云端查询刷新URL。临时URL或云端任务过期可能无法恢复。
100
+
101
+ 返回 `job_id`、`job_state`、`request_id` 和 `artifacts`。`ok` 表示本次调用成功;已提交、运行中返回 `ok=true, completed=false`,全部交付返回 `completed=true`,执行错误通过 MCP `isError=true` 返回。
102
+ `get_job.ok` 是记录读取成功;调用超时没拿到ID时可用 `list_jobs` 按时间查找。保留原有files、layers、usage、errors字段。
103
+ 每项产物记录字节数和SHA-256;下载采用临时文件和原子替换。质量、像素尺寸和媒体播放仍需后续验证。
104
+
105
+ `url`模式也留存任务元数据;任务目录保存签名URL、响应与图片缓存,不保存API Key。缓存用于恢复,不自动清理。
106
+ 恢复锁使用POSIX `flock`,面向macOS/Linux。产物默认目录增加随机后缀,避免同秒调用碰撞。
107
+
108
+ 原始接入范围见 [接入记录](https://github.com/hoobnn/hoobnn-mcps/blob/main/docs/mcp-expansion.md)。当前运行时和任务恢复已通过离线故障测试及真实 stdio 检查;尚未验证云端生成、账号权限和媒体质量。
109
+
110
+ 产物的 `media_info` 读取PNG头中的实际宽高;若系统已有 `ffprobe`,可读取其他媒体的实际尺寸、时长和音轨信息。不可读取时显式返回 `available=false`,不把请求参数当作实际输出规格。无需新增Python依赖。
111
+
112
+ ## 运行时与稳定性
113
+
114
+ 共享连接池、总超时、工具分组、错误语义和迁移说明见 [MCP 构建与稳定性](https://github.com/hoobnn/hoobnn-mcps/blob/main/docs/mcp-reliability.md)。完整工具说明可调用 `get_tool_help(tool="工具名")`。
115
+
116
+ ## 官方接口核实
117
+
118
+ 逐工具映射、模型版本、参数差异和可程序化获取的官方文档来源见 [火山方舟接口审计](https://github.com/hoobnn/hoobnn-mcps/blob/main/docs/audits/volcengine-ark.md)。本地校验依据2026-10-10官方文档快照;账号权限及实际云端生成仍未验证。
@@ -0,0 +1,93 @@
1
+ # volcengine-ark-mcp
2
+
3
+ 调用火山方舟(Volcengine Ark)的 MCP server:Seedream 5.0 生成和编辑图片、Seedance 视频、对话与多模态理解、联网搜索和向量化,产物直接存到本地。
4
+
5
+ 原名 `seedream-mcp`(PyPI 上该名已被他人占用),0.4 起改名,不保留旧命令和 `SEEDREAM_*` 环境变量。
6
+
7
+ `0.6.0` 去掉 `query_video`:视频进度统一用 `get_job(job_id=...)` 或 `get_job(task_id=...)` 查询,完成时自动下载;`generate_image` 新增 `parameters`。这是不兼容变更。
8
+
9
+ ## Seedream 图片生成
10
+
11
+ | 模型 | 参数 | 独有能力 |
12
+ |---|---|---|
13
+ | Seedream 5.0 pro(默认) | `model="pro"` → `doubao-seedream-5-0-pro-260628` | 图层拆分、交互编辑、透明背景、fast 模式 |
14
+ | Seedream 5.0 flash | `model="flash"` → `doubao-seedream-5-0-flash-260915` | 图层拆分、交互编辑、透明背景;不支持 fast 提示词优化 |
15
+ | Seedream 5.0 lite | `model="lite"` → `doubao-seedream-5-0-260128` | 组图、联网搜索、3K / 4K |
16
+
17
+ 三个模型都支持文生图和单图 / 多图参考生图。模型不支持的参数组合会在本地直接报错,不发请求。
18
+
19
+ ## 环境变量
20
+
21
+ | 变量 | 说明 |
22
+ |---|---|
23
+ | `ARK_API_KEY` | 必需,方舟控制台 → API Key 管理 |
24
+ | `ARK_OUT_DIR` | 输出目录,默认 `~/Downloads/volcengine-ark` |
25
+ | `ARK_RESOURCE_MODE` | 交付方式:`local`(默认)下载到本地,`url` 只返回 24 小时内有效的链接 |
26
+ | `ARK_BASE_URL` | 默认 `https://ark.cn-beijing.volces.com/api/v3` |
27
+
28
+ 参数说明见工具描述(`src/volcengine_ark_mcp/server.py`),接口细节以[图片生成 API 文档](https://ark.volcengine.com/region:cn-beijing/docs/ark/image-generation-api)为准。
29
+
30
+ ## 0.3 方舟扩展(待云端验证)
31
+
32
+ 保留原有生图工具,同时接入方舟其他能力,无需新建MCP配置。
33
+
34
+ | 工具 | 能力 | 官方来源 |
35
+ |---|---|---|
36
+ | `generate_video` | Seedance文生、首帧、首尾帧、多模态参考视频 | [创建任务](https://docs.volcengine.com/docs/ark/create-video-generation-task-api?lang=zh)、[查询任务](https://docs.volcengine.com/docs/ark/get-video-generation-task-api?lang=zh) |
37
+ | `chat` | 语言、图片/视频理解、深度思考、结构化输出 | [Chat](https://docs.volcengine.com/docs/ark/chat-api?lang=zh)、[Responses](https://docs.volcengine.com/docs/ark/create-model-responses-api?lang=zh) |
38
+ | `chat(web_search=true)` | 联网搜索,保留来源和完整响应 | [Web Search](https://docs.volcengine.com/docs/ark/web-search?lang=zh) |
39
+ | `embed` | 文本与多模态向量化 | [向量化](https://docs.volcengine.com/docs/ark/vectorization?lang=zh)、[多模态API](https://docs.volcengine.com/docs/ark/multimodal-vectorization-api?lang=zh) |
40
+ | `list_capabilities` | 静态能力目录和边界 | 不联网,不代表账号权限 |
41
+ | `list_jobs` / `get_job` / `recover_job` | 查任务进度(视频生成中会查询方舟并在完成时下载)、补交付 | 图片和视频留档,跨进程重启恢复 |
42
+
43
+ ### Seedance视频
44
+
45
+ 模型别名:`seedance` → `doubao-seedance-2-5-260628`;`seedance-2` / `seedance-fast` / `seedance-mini` → 相应2.0系列。
46
+ 也可直接指定完整模型ID或Endpoint ID。不同模型的素材数量、时长、分辨率限制以官方接口为准。
47
+
48
+ 默认 `wait=0`,提交后返回 `task_id`、`job_id`。`get_job(job_id=..., wait=30)` 查询进度,完成后下载到原目录;别处提交的任务可传 `get_job(task_id=...)`,首次查询会新建本地记录。下载失败或链接过期用 `recover_job(job_id=...)`。都不会提交新生成。
49
+
50
+ 图片支持本地路径/data URL/公网URL;视频和音频参考使用公网URL或 `asset://` ID。本轮没有自动上传本地视频/音频。
51
+ 2.5首帧/首尾帧任务 `ratio=adaptive`;输出时长为4–30秒,2.0系列为4–15秒,均可用 `duration=-1`。首尾帧与全模态参考不能混用;2.0系列不能仅传音频。
52
+ 2.0标准版支持4k,fast/mini仅480p/720p。视频编辑通过prompt明确意图,`duration=-1`、`ratio=adaptive`;可用 `parameters.omni_reference_task_type="edit"` 提前校验任务类型,省略duration采用官方默认值。
53
+ `parameters` 可传官方顶层选项,如 `draft`、`return_last_frame`、`output_format`;不能覆盖模型和输入素材。
54
+
55
+ ### 对话与检索
56
+
57
+ `chat` 默认 `pro` 为 `doubao-seed-2-1-pro-260628`,也可显式指定模型;与生图工具中的pro别名含义不同。
58
+ `images` 支持本地图片,`videos` 须公开URL。不开搜索时使用Chat API;开启 `web_search` 或提供 `previous_response_id` 时使用Responses API。
59
+ 多轮历史采用Chat消息结构,Responses会转换本轮多模态内容;保留 `response_id`、来源注解;完整响应用 `include_response=true` 按需获取。
60
+ `parameters` 透传模型支持的高级选项,不执行模型返回的工具调用。
61
+
62
+ 向量工具须显式指定模型:`texts` 为文本列表;`contents` 为官方类型化数组,例如:
63
+
64
+ ```json
65
+ [{"type":"text","text":"猫"},{"type":"image_url","image_url":{"url":"/absolute/cat.png"}}]
66
+ ```
67
+
68
+ 返回 `data`、`usage`,完整响应用 `include_response=true` 按需获取;不自动建索引或知识库。
69
+
70
+ ### 本地恢复
71
+
72
+ `ARK_JOB_DIR` 默认 `~/.local/share/volcengine-ark-mcp/jobs`。生成前创建记录,响应收到后保留完整响应,内联图片先缓存再交付。
73
+ 组图逐项记录错误,下载失败可单独补交付;图层记录和 `layers.json` 随恢复更新。部分生成失败会返回 `partial`,不会重新生成失败项。
74
+ 视频保存原 `task_id`,通过云端查询刷新URL。临时URL或云端任务过期可能无法恢复。
75
+
76
+ 返回 `job_id`、`job_state`、`request_id` 和 `artifacts`。`ok` 表示本次调用成功;已提交、运行中返回 `ok=true, completed=false`,全部交付返回 `completed=true`,执行错误通过 MCP `isError=true` 返回。
77
+ `get_job.ok` 是记录读取成功;调用超时没拿到ID时可用 `list_jobs` 按时间查找。保留原有files、layers、usage、errors字段。
78
+ 每项产物记录字节数和SHA-256;下载采用临时文件和原子替换。质量、像素尺寸和媒体播放仍需后续验证。
79
+
80
+ `url`模式也留存任务元数据;任务目录保存签名URL、响应与图片缓存,不保存API Key。缓存用于恢复,不自动清理。
81
+ 恢复锁使用POSIX `flock`,面向macOS/Linux。产物默认目录增加随机后缀,避免同秒调用碰撞。
82
+
83
+ 原始接入范围见 [接入记录](https://github.com/hoobnn/hoobnn-mcps/blob/main/docs/mcp-expansion.md)。当前运行时和任务恢复已通过离线故障测试及真实 stdio 检查;尚未验证云端生成、账号权限和媒体质量。
84
+
85
+ 产物的 `media_info` 读取PNG头中的实际宽高;若系统已有 `ffprobe`,可读取其他媒体的实际尺寸、时长和音轨信息。不可读取时显式返回 `available=false`,不把请求参数当作实际输出规格。无需新增Python依赖。
86
+
87
+ ## 运行时与稳定性
88
+
89
+ 共享连接池、总超时、工具分组、错误语义和迁移说明见 [MCP 构建与稳定性](https://github.com/hoobnn/hoobnn-mcps/blob/main/docs/mcp-reliability.md)。完整工具说明可调用 `get_tool_help(tool="工具名")`。
90
+
91
+ ## 官方接口核实
92
+
93
+ 逐工具映射、模型版本、参数差异和可程序化获取的官方文档来源见 [火山方舟接口审计](https://github.com/hoobnn/hoobnn-mcps/blob/main/docs/audits/volcengine-ark.md)。本地校验依据2026-10-10官方文档快照;账号权限及实际云端生成仍未验证。
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "volcengine-ark-mcp"
3
+ version = "0.6.0"
4
+ description = "火山方舟图片、Seedance视频、语言模型、搜索、向量与任务恢复 MCP server"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ authors = [{ name = "hoobnn" }]
9
+ keywords = ["mcp", "mcp-server", "volcengine", "ark", "seedream", "seedance", "doubao"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Multimedia",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ ]
22
+ dependencies = ["mcp>=2.3,<3", "httpx>=0.28,<1"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/hoobnn/hoobnn-mcps/tree/main/servers/volcengine-ark"
26
+ Repository = "https://github.com/hoobnn/hoobnn-mcps"
27
+ Issues = "https://github.com/hoobnn/hoobnn-mcps/issues"
28
+
29
+ [project.scripts]
30
+ volcengine-ark-mcp = "volcengine_ark_mcp.server:main"
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ only-include = ["src", "tests", "README.md"]
@@ -0,0 +1 @@
1
+ """火山方舟 MCP server:Seedream 图片、Seedance 视频、对话、搜索与向量化。"""
@@ -0,0 +1,238 @@
1
+ """火山方舟图片生成 API 的校验、请求与落盘。通过共享 HTTP 连接池请求。"""
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ import re
7
+ import urllib.error
8
+ import urllib.request
9
+
10
+ from . import transport
11
+ from .mcp_runtime import merge_parameters
12
+ from pathlib import Path
13
+
14
+ BASE_URL = os.environ.get("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
15
+ MODELS = {
16
+ "pro": "doubao-seedream-5-0-pro-260628",
17
+ "lite": "doubao-seedream-5-0-260128",
18
+ "flash": "doubao-seedream-5-0-flash-260915",
19
+ }
20
+ MAX_REF = {"pro": 10, "flash": 10, "lite": 14}
21
+ IMAGE_EXT = {"png", "jpeg", "jpg", "webp", "bmp", "tiff", "tif", "gif", "heic", "heif"}
22
+
23
+
24
+ class InputError(ValueError):
25
+ pass
26
+
27
+
28
+ def family(model):
29
+ if "seedream-5-0-flash" in model:
30
+ return "flash"
31
+ if "seedream-5-0-pro" in model:
32
+ return "pro"
33
+ if "seedream-5-0-26" in model:
34
+ return "lite"
35
+ return None # Endpoint ID 等无法识别的,跳过本地校验,交给服务端
36
+
37
+
38
+ def encode_image(src):
39
+ if src.startswith(("http://", "https://", "data:")):
40
+ return src
41
+ p = Path(src).expanduser()
42
+ if not p.is_file():
43
+ raise InputError(f"参考图不存在:{src}")
44
+ ext = p.suffix.lower().lstrip(".")
45
+ if ext not in IMAGE_EXT:
46
+ raise InputError(f"不支持的图片格式:{p.name}")
47
+ if p.stat().st_size > 30 * 1024 * 1024:
48
+ raise InputError(f"参考图超过 30MB:{p.name}")
49
+ mime = {"jpg": "jpeg", "tif": "tiff"}.get(ext, ext)
50
+ return f"data:image/{mime};base64," + base64.b64encode(p.read_bytes()).decode()
51
+
52
+
53
+ def check(o, fam):
54
+ """服务端也会拒,但本地先挡掉,省一次请求和一段等待。"""
55
+ n = len(o["images"])
56
+ if fam in ("pro", "flash") and (o["group"] is not None or o["web_search"]):
57
+ return f"Seedream 5.0 {fam} 不支持组图(group)和联网搜索(web_search),改用 model=lite"
58
+ if fam == "flash" and o["fast"]:
59
+ return "Seedream 5.0 flash 不支持 fast 提示词优化模式"
60
+ if fam == "lite":
61
+ if o["layers"] or o["transparent"]:
62
+ return "图层拆分(layers)和透明背景(transparent)仅 Seedream 5.0 pro/flash 支持"
63
+ if o["fast"]:
64
+ return "fast 只有 Seedream 5.0 pro 支持"
65
+ if fam and n > MAX_REF[fam]:
66
+ return f"{fam} 最多 {MAX_REF[fam]} 张参考图,当前 {n} 张"
67
+ if o["layers"] and n != 1:
68
+ return "图层拆分必须且只能传 1 张图(png / jpeg)"
69
+ if o["transparent"]:
70
+ if n != 1:
71
+ return "透明背景只支持图生图,且只能传 1 张带透明通道的图"
72
+ if o["output_format"] == "jpeg":
73
+ return "透明背景输出是 png,不能同时指定 output_format=jpeg"
74
+ if o["group"] is not None and (isinstance(o["group"], bool) or not isinstance(o["group"], int) or o["group"] < 1 or n + o["group"] > 15):
75
+ return f"组图要求 参考图数 + 生成数 ≤ 15(当前 {n} + {o['group']})"
76
+ if o["output_format"] is not None and o["output_format"] not in ("png", "jpeg"):
77
+ return "output_format 必须为 png 或 jpeg"
78
+ if fam and o["size"] is not None:
79
+ allowed = {"2K", "3K", "4K"} if fam == "lite" else {"1K", "1.5K", "2K"}
80
+ if o["layers"]:
81
+ allowed.add("auto")
82
+ size = o["size"]
83
+ if size not in allowed:
84
+ dimensions = re.fullmatch(r"([1-9][0-9]*)x([1-9][0-9]*)", size)
85
+ if not dimensions or o["layers"]:
86
+ return "所选模型/场景不支持该 size;图层拆分仅支持分辨率档位"
87
+ width, height = map(int, dimensions.groups())
88
+ minimum, maximum = (3686400, 16777216) if fam == "lite" else (921600, 4624220)
89
+ if not minimum <= width * height <= maximum or not 1 / 16 <= width / height <= 16:
90
+ return "size 超出所选模型的像素总量或宽高比范围"
91
+ if not o["prompt"] and not o["layers"]:
92
+ return "缺少提示词(只有图层拆分可以不写)"
93
+ return None
94
+
95
+
96
+ def build_body(o, model, mode="local"):
97
+ body = {"model": model, "response_format": "url" if mode == "url" else "b64_json",
98
+ "watermark": o["watermark"]}
99
+ if o["prompt"]:
100
+ body["prompt"] = o["prompt"]
101
+ if o["images"]:
102
+ imgs = [encode_image(s) for s in o["images"]]
103
+ body["image"] = imgs[0] if len(imgs) == 1 else imgs
104
+ if o["size"]:
105
+ body["size"] = o["size"]
106
+ if o["output_format"]:
107
+ body["output_format"] = o["output_format"]
108
+ if o["transparent"]:
109
+ body["background"] = "transparent"
110
+ if o["layers"]:
111
+ body["layer_decomposition"] = True
112
+ if o["group"]:
113
+ body["sequential_image_generation"] = "auto"
114
+ body["sequential_image_generation_options"] = {"max_images": o["group"]}
115
+ if o["web_search"]:
116
+ body["tools"] = [{"type": "web_search"}]
117
+ if o["fast"]:
118
+ body["optimize_prompt_options"] = {"mode": "fast"}
119
+ extra = o.get("parameters")
120
+ if extra:
121
+ if not isinstance(extra, dict) or set(extra) & {"model", "response_format"}:
122
+ raise InputError("parameters 必须是 object,且不能覆盖 model/response_format")
123
+ merge_parameters(body, extra)
124
+ return body
125
+
126
+
127
+ def post(body, timeout):
128
+ key = os.environ.get("ARK_API_KEY")
129
+ if not key:
130
+ return None, "未设置环境变量 ARK_API_KEY"
131
+ req = urllib.request.Request(
132
+ BASE_URL.rstrip("/") + "/images/generations",
133
+ data=json.dumps(body).encode(),
134
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"},
135
+ )
136
+ try:
137
+ with transport.urlopen(req, timeout=timeout) as r:
138
+ data = json.loads(r.read())
139
+ if not isinstance(data, dict):
140
+ return None, "响应状态未知:服务端返回非对象JSON"
141
+ data.setdefault("request_id", r.headers.get("X-Request-Id"))
142
+ return data, None
143
+ except urllib.error.HTTPError as e:
144
+ raw = e.read().decode(errors="replace")
145
+ try:
146
+ err = json.loads(raw).get("error") or {}
147
+ return None, f"HTTP {e.code} {err.get('code', '')}: {err.get('message', raw)}".strip()
148
+ except (json.JSONDecodeError, AttributeError):
149
+ return None, f"HTTP {e.code}: {raw[:500]}"
150
+ except (urllib.error.URLError, TimeoutError) as e:
151
+ return None, f"请求失败:{e}"
152
+ except (OSError, ValueError) as e:
153
+ return None, f"响应状态未知:{e}"
154
+
155
+
156
+ def generate(o, out_dir, timeout=300, mode="local"):
157
+ """mode=local 下载到 out_dir;mode=url 只返回 24 小时内有效的图片 URL,不落盘。"""
158
+ model = MODELS.get(o["model"], o["model"])
159
+ result = {"ok": False, "model": model, "files": [], "layers": [], "usage": None,
160
+ "errors": [], "error": None, "out_dir": None}
161
+ try:
162
+ err = check(o, family(model))
163
+ body = None if err else build_body(o, model, mode)
164
+ except InputError as e:
165
+ err = str(e)
166
+ if err:
167
+ result["error"] = err
168
+ return result
169
+
170
+ from .products import STORE
171
+ job = STORE.create("image", model, out_dir, mode,
172
+ summary={"size": o["size"], "group": o["group"], "layers": o["layers"],
173
+ "output_format": o["output_format"] or ("png" if o["transparent"] else "jpeg")})
174
+ with STORE.processing(job):
175
+ resp, err = post(body, timeout)
176
+ if err:
177
+ job.update(error=err, state="failed" if err.startswith(("HTTP 4", "未设置")) else "unknown")
178
+ STORE.save(job)
179
+ return STORE.result(job)
180
+ if resp.get("error"):
181
+ job.update(state="failed", error=str(resp["error"]))
182
+ STORE.save(job)
183
+ return STORE.result(job)
184
+ STORE.record_response(job, resp)
185
+ return deliver_image(resp, job)
186
+
187
+
188
+ def deliver_image(resp, job):
189
+ from .products import STORE
190
+ from .jobs import atomic_bytes
191
+ errors = []
192
+ job.update(usage=resp.get("usage"), request_id=resp.get("request_id"), state="generated")
193
+ STORE.save(job)
194
+ for i, item in enumerate(resp.get("data") or []):
195
+ if item.get("error"):
196
+ errors.append({"index": i, "error": item["error"]})
197
+ continue
198
+ z = item.get("z_index")
199
+ output_format = item.get("output_format")
200
+ if not output_format:
201
+ output_format = "png" if isinstance(z, int) and z > 0 else job.get("summary", {}).get("output_format", "jpeg")
202
+ # Legacy records do not persist output_format; trust actual inline bytes.
203
+ if item.get("b64_json"):
204
+ try:
205
+ signature = base64.b64decode(item["b64_json"][:16])
206
+ if signature.startswith(b"\x89PNG\r\n\x1a\n"):
207
+ output_format = "png"
208
+ elif signature.startswith(b"\xff\xd8\xff"):
209
+ output_format = "jpeg"
210
+ except ValueError:
211
+ pass # Full base64 validation below records the artifact failure.
212
+ ext = "jpg" if output_format == "jpeg" else "png"
213
+ name = f"layer-{z:02d}.{ext}" if isinstance(z, int) else f"image-{i + 1:02d}.{ext}"
214
+ metadata = {k: item.get(k) for k in ("z_index", "name", "description", "size", "bounding_box")}
215
+ try:
216
+ STORE.add(job, name, url=item.get("url"),
217
+ data=base64.b64decode(item["b64_json"], validate=True) if item.get("b64_json") else None,
218
+ metadata=metadata)
219
+ except (OSError, ValueError) as exc:
220
+ errors.append({"index": i, "error": str(exc)})
221
+ job["result"]["errors"] = errors
222
+ if not job["artifacts"]:
223
+ job.update(state="failed", error="没有可交付的图片")
224
+ STORE.save(job)
225
+ return STORE.result(job)
226
+ STORE.deliver(job)
227
+ layers = [{**a["metadata"], **({"file": a["file"]} if a.get("file") else {"url": a.get("url")})}
228
+ for a in job["artifacts"] if a.get("metadata", {}).get("z_index") is not None]
229
+ job["result"]["layers"] = layers
230
+ if layers and job["mode"] == "local":
231
+ try:
232
+ atomic_bytes(Path(job["out_dir"]) / "layers.json", json.dumps(layers, ensure_ascii=False, indent=2).encode())
233
+ except OSError as exc:
234
+ job.update(state="download_failed", error=f"图层索引保存失败:{exc}")
235
+ if errors and job["state"] == "delivered":
236
+ job.update(state="partial", error="部分图片生成失败;恢复不会重新生成失败项")
237
+ STORE.save(job)
238
+ return STORE.result(job)