From 30e82af4f1786cad1c584f8acb59384fbdfd035a Mon Sep 17 00:00:00 2001 From: Ukawka <140130066+Ukawka@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:44:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E6=96=B9=E6=A1=88v1=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E7=90=86=E8=A7=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 + README.md | 16 +- configs/astra-qwen.json | 45 ++++++ configs/deepseek-qwen.json | 45 ++++++ docs/model-stages.md | 165 +++++++++++++++++++++ lib/config.mjs | 92 ++++++++++++ lib/images.mjs | 23 +++ lib/report.mjs | 30 ++++ lib/run-artifacts.mjs | 34 +++++ lib/validate-parts.mjs | 91 ++++++++++++ package.json | 7 +- pipeline.mjs | 25 ++-- prompts/complete-v1.txt | 10 ++ prompts/understand-v1.txt | 20 +++ providers/http.mjs | 112 ++++++++++++++ providers/image-edits.mjs | 45 ++++++ providers/vision.mjs | 53 +++++++ schemas/semantic-parts.schema.json | 30 ++++ stages/model-stage.mjs | 118 +++++++++++++++ tests/model-stages.test.mjs | 228 +++++++++++++++++++++++++++++ tools/model-check.mjs | 104 +++++++++++++ 21 files changed, 1287 insertions(+), 14 deletions(-) create mode 100644 configs/astra-qwen.json create mode 100644 configs/deepseek-qwen.json create mode 100644 docs/model-stages.md create mode 100644 lib/config.mjs create mode 100644 lib/images.mjs create mode 100644 lib/report.mjs create mode 100644 lib/run-artifacts.mjs create mode 100644 lib/validate-parts.mjs create mode 100644 prompts/complete-v1.txt create mode 100644 prompts/understand-v1.txt create mode 100644 providers/http.mjs create mode 100644 providers/image-edits.mjs create mode 100644 providers/vision.mjs create mode 100644 schemas/semantic-parts.schema.json create mode 100644 stages/model-stage.mjs create mode 100644 tests/model-stages.test.mjs create mode 100644 tools/model-check.mjs diff --git a/.env.example b/.env.example index 8ae1c43..00fae72 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,11 @@ VECTOR_ENGINE_BASE_URL=https://your-vector-engine.example VECTOR_ENGINE_API_KEY=replace-me # Optional request timeout in milliseconds VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1200000 + +# Independent model IDs for the annotated-parts pipeline. Use EXACT relay IDs. +# Confirm with: node tools/model-check.mjs --list-models +SPINE_UNDERSTANDING_MODEL=gpt-6-astra +SPINE_COMPLETION_MODEL=qwen-image-3.0 + +# Checkpoint location only; the SAM runtime is integrated in milestone 2. +SAM_MODEL_DIR=F:/Study/sam3.1 diff --git a/README.md b/README.md index 83ebd42..a525e18 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,18 @@ # Genarrative / Genielabs Spine Animation Pipeline +## 新增:可替换模型与角色结构理解 + +已新增 `--mode annotated-parts`,默认 GPT6Astra 做结构理解,Qwen Image 3.0 作为独立图片编辑适配器。模型、协议和提示词可分别配置;第一阶段输出 JSON、粗标注 SVG 和检查报告。SAM 分割与补全合并尚未接入。 + +```powershell +node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-astra --stop-after understand +``` + +完整配置、模型切换、独立图片编辑测试和产物说明见 [docs/model-stages.md](docs/model-stages.md)。以下为旧版拆件管线的说明。 + 这是独立目录,不修改 Genarrative。它只参考 Genarrative 的 VectorEngine 图片生成协议:后端环境变量提供 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`,使用 OpenAI-compatible `POST /v1/images/edits` multipart 请求。 -当前上游没有 `gpt-image-2.5`,脚本会把它映射为 `gpt-image-2`,并在 manifest 中保留 `requestedModel`。 +模型 ID 原样传给中转站,不再进行隐式映射。可通过 `node tools/model-check.mjs --list-models` 查询当前服务实际列出的 ID。 拆件提示词明确要求保留参考图的原始姿态、左右方向、遮挡关系和非对称结构,禁止镜像、拉直和生成对称备件。图片接口对 429/5xx 会自动重试 3 次;使用新输出目录即可重新生成,旧目录可用 `--resume` 复用已生成的拆件图。 @@ -16,7 +26,7 @@ node pipeline.mjs \ --out-dir ./output/character-spine \ --genielabs-dir /absolute/path/spine-animation-ai \ --env-file /absolute/path/.env \ - --image-model gpt-image-2.5 \ + --image-model qwen-image-3.0 \ --animations idle,walk,wave ``` @@ -32,7 +42,7 @@ node pipeline.mjs \ `matched` 依赖拆件图与参考图保持相同画风和可辨识纹理;低纹理部件会进入模板匹配回退,并进行白底归一化以避免透明像素产生 `NaN/inf`。`anchored` 只按拆件图的行布局做通用兜底,不应作为正式定位结果。 -Python 环境探测顺序为显式 `--python`、Anaconda、Homebrew Python、系统 Python;要求导入 `cv2`、`PIL`、`numpy`。如果全部不可用,脚本会在本目录创建 `.venv` 并安装 `requirements.txt`。 +旧流程的 Python 环境默认探测 `python`、`python3` 和项目 `.venv`;显式 `--python` 时只使用指定环境。要求导入 `cv2`、`PIL`、`numpy`。自动创建虚拟环境时区分 Windows 的 `Scripts/python.exe` 和 Unix 的 `bin/python`。新增的结构理解模式不需要 Python。 脚本默认只读取本目录 `.env` 和当前 shell 环境,不会自动读取 Genarrative 的 secrets 文件。可以通过 `--env-file` 显式指定一个只在受控后端使用的环境文件。 diff --git a/configs/astra-qwen.json b/configs/astra-qwen.json new file mode 100644 index 0000000..db0e6a9 --- /dev/null +++ b/configs/astra-qwen.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "providers": { + "relay-vision": { + "protocol": "responses", + "base_url_env": "VECTOR_ENGINE_BASE_URL", + "api_key_env": "VECTOR_ENGINE_API_KEY", + "timeout_ms": 180000, + "max_retries": 2 + }, + "relay-image-edit": { + "protocol": "images-edits", + "base_url_env": "VECTOR_ENGINE_BASE_URL", + "api_key_env": "VECTOR_ENGINE_API_KEY", + "timeout_ms": 1200000, + "max_retries": 2, + "capabilities": {"multiple_images": false, "mask": false}, + "image_field": "image", + "mask_editable": "transparent" + } + }, + "stages": { + "understanding": { + "provider": "relay-vision", + "model_env": "SPINE_UNDERSTANDING_MODEL", + "default_model": "gpt-6-astra", + "prompt": "../prompts/understand-v1.txt", + "output_mode": "text-json", + "stream": true, + "params": {"store": false} + }, + "completion": { + "provider": "relay-image-edit", + "model_env": "SPINE_COMPLETION_MODEL", + "default_model": "qwen-image-3.0", + "prompt": "../prompts/complete-v1.txt", + "params": {"n": 1} + }, + "segmentation": { + "provider": "sam-local", + "model_dir_env": "SAM_MODEL_DIR", + "default_model_dir": "F:/Study/sam3.1" + } + } +} diff --git a/configs/deepseek-qwen.json b/configs/deepseek-qwen.json new file mode 100644 index 0000000..2fe1a7b --- /dev/null +++ b/configs/deepseek-qwen.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "providers": { + "relay-vision": { + "protocol": "chat-completions", + "base_url_env": "VECTOR_ENGINE_BASE_URL", + "api_key_env": "VECTOR_ENGINE_API_KEY", + "timeout_ms": 180000, + "max_retries": 2 + }, + "relay-image-edit": { + "protocol": "images-edits", + "base_url_env": "VECTOR_ENGINE_BASE_URL", + "api_key_env": "VECTOR_ENGINE_API_KEY", + "timeout_ms": 1200000, + "max_retries": 2, + "capabilities": {"multiple_images": false, "mask": false}, + "image_field": "image", + "mask_editable": "transparent" + } + }, + "stages": { + "understanding": { + "provider": "relay-vision", + "model_env": "SPINE_UNDERSTANDING_MODEL", + "default_model": "deepseek-flash", + "prompt": "../prompts/understand-v1.txt", + "output_mode": "text-json", + "stream": false, + "params": {} + }, + "completion": { + "provider": "relay-image-edit", + "model_env": "SPINE_COMPLETION_MODEL", + "default_model": "qwen-image-3.0", + "prompt": "../prompts/complete-v1.txt", + "params": {"n": 1} + }, + "segmentation": { + "provider": "sam-local", + "model_dir_env": "SAM_MODEL_DIR", + "default_model_dir": "F:/Study/sam3.1" + } + } +} diff --git a/docs/model-stages.md b/docs/model-stages.md new file mode 100644 index 0000000..4b7a9b2 --- /dev/null +++ b/docs/model-stages.md @@ -0,0 +1,165 @@ +# 第一阶段:可替换模型接口与角色结构理解 + +本轮实现 GPT6Astra 结构理解、Qwen Image 3.0 图片编辑适配器、结构 JSON 校验、粗标注预览、实验记录和缓存。新增模式不需要 Python,不运行旧的“拆件图 → SIFT 定位”流程。 + +结构理解当前停在 `understand`;SAM 分割、掩码修正、隐藏部件合并、Spine 骨骼导出是后续阶段。图片编辑测试保存的是原始候选,不是验收通过的最终资产。 + +## 环境与模型 + +要求 Node.js 20 或更高版本,无新增 npm 依赖。在 `AI_spine` 目录运行。 + +复用现有 `.env` 的地址和密钥,可增加: + +```dotenv +SPINE_UNDERSTANDING_MODEL=gpt-6-astra +SPINE_COMPLETION_MODEL=qwen-image-3.0 +SAM_MODEL_DIR=F:/Study/sam3.1 +``` + +不配置这三项时,`configs/astra-qwen.json` 已提供相同默认值。不会自动改写用户的 `.env`。 + +查询中转站模型 ID(只读取列表,不调用生成): + +```powershell +node .\tools\model-check.mjs --list-models +``` + +模型展示名不等于 API ID。2026-09-17 的本机接口查询包含 `gpt-6-astra` 和 `qwen-image-3.0`。支持列表不保证所有端点/多图/mask 参数可用,需要分别验证。 + +本地配置检查(不联网,不生成图片): + +```powershell +node .\tools\model-check.mjs --dry-run +``` + +## 运行结构理解 + +```powershell +node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-astra --stop-after understand +``` + +首次调用会向配置的中转站发送参考图。每个实验使用新输出目录;本轮不会自动覆盖旧产物。 + +切换理解模型: + +```powershell +node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-other --understanding-model YOUR_EXACT_MODEL_ID --stop-after understand +``` + +切换协议(需中转站实际支持): + +```powershell +node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-responses --understanding-protocol responses --stop-after understand +``` + +协议可选 `chat-completions` / `responses`,默认 `responses`:本机中转站实测 Astra 的 codex channel 明确拒绝 `/v1/chat/completions`。输出模式默认 `text-json`:在提示词中提供 Schema,并始终在本地严格校验。显式确认中转站支持后,可使用 `--output-mode json-object` 或 `--output-mode json-schema`。不会因 400/不支持的协议自动换模型或偷偷降低输出约束。 + +该 Astra 通道还要求 `stream=true`,所以默认配置启用流式 Responses,并设置 `store=false`。适配器收集 SSE 后只接受完整的终止事件,输出仍是普通 JSON 文件。新模型可在阶段配置中设置 `stream: false`。`response.raw.json` 对 SSE 保存终止事件中的 response 对象,非逐字节 SSE 日志;`request-metadata.json` 记录 wire_format。 + +切换到非流式 Chat Completions 模型时可使用 `--understanding-protocol chat-completions --stream false`,不会沿用 Astra 的流式设置。 + +项目同时提供 `configs/deepseek-qwen.json`。它把第一阶段固定为 +`deepseek-flash + chat-completions + stream=false`,第三阶段仍使用 +`qwen-image-3.0`,适合与默认的 `configs/astra-qwen.json` 做对照实验: + +```powershell +node .\pipeline.mjs --mode annotated-parts ` + --config .\configs\deepseek-qwen.json ` + --reference ..\spine-animation-ai\demo\character_reference.png ` + --out-dir .\output\sombrero-understand-deepseek ` + --stop-after understand +``` + +主要产物: + +| 文件 | 含义 | +| --- | --- | +| `source.png` | 实际提交的原图,不缩放、不裁剪 | +| `semantic-parts.json` | 已通过结构校验的部件清单 | +| `annotated.svg` | 原图上的粗框、关节点、正点和父子连线,可直接浏览器打开 | +| `report.html` | 自包含检查页面,带部件表和模型记录 | +| `validation.json` | 字段、坐标、引用、父子环和遮挡环校验结果 | +| `response.raw.json` / `response.txt` | 原始服务端响应 / 提取的文本 | +| `prompt.txt` / `request.json` | 提示词 / 请求设置与输入哈希;图片内容单独保存在 source.png | +| `request-metadata.json` | requested/returned model、request ID、usage、耗时和请求次数 | +| `manifest.json` | 运行状态、配置、代码版本、源文件哈希和产物哈希 | + +`status=completed` 仅表示这一步执行完成。`quality_status=needs_review` 表示仍需人工检查语义和框的位置。粗框不是像素 mask,输出也不是自动批准的骨骼。 + +校验失败保存原始响应、validation 和报告,退出码为非零,不继续分割。网络/API 失败保存失败 manifest。拒绝、截断和不完整 Responses 都不会被当作成功。 + +HTTP/SSE/网络失败还保存 `response.error.json`:状态码、请求 ID、耗时、尝试次数、响应体(最多 2 MB)或网络错误代码,密钥会脱敏。若服务端返回 overloaded,使用新目录稍后重跑;这不表示 schema 或分割模型出错。 + +## 独立测试图片编辑接口 + +先准备一个局部部件 PNG 和任务文本 `task.txt`,例如: + +```text +The input canvas contains the screen-left forearm. Complete the hidden elbow +connection behind the sleeve. Keep the visible hand and forearm in their exact +positions. Preserve the original outline and shading. Do not add other parts. +``` + +调用 Qwen: + +```powershell +node .\tools\model-check.mjs --stage completion --image .\local-part.png --prompt-file .\task.txt --out-dir .\output\qwen-edit-test --completion-model qwen-image-3.0 +``` + +改 `--completion-model` 即可对比另一个兼容的图片编辑模型。此命令直接调用图片接口并可能计费,不调用结构理解。 + +结果保存 `candidate-1.png`(或服务端实际返回的 JPEG/WebP 扩展名)、输入图、任务提示词、原始响应和运行记录。PNG 输出会记录是否与输入画布同尺寸;尺寸相同不代表内容对齐。 + +默认图片适配器只发送一张图。若中转站确认支持多图,在配置中启用 `capabilities.multiple_images`,并使用重复 `--image`:第一张是局部画布,其余是原图/标注图参考。字段名通过 `image_field` 选择 `image` 或 `image[]`。 + +若确认支持编辑 mask,再启用 `capabilities.mask` 并使用 `--mask edit-mask.png`。当前适配器约定透明像素可编辑,要求 mask 与第一张图同尺寸、包含 alpha 通道。SAM 的普通黑白 mask 不能直接当作编辑 alpha mask。未启用能力时参数会明确报错,不静默忽略。 + +本轮没有恢复原始可见像素,也没有生成隐藏区域的透明掩码;因此候选始终标记 `raw_completion_probe` 和 `needs_review`,不能直接接入最终 atlas。 + +## 配置与切换规则 + +`configs/astra-qwen.json` 分离 `providers` 和 `stages`: + +- provider:协议、base URL/key 的环境变量名、超时、重试次数、多图和 mask 声明。 +- stage:provider 名称、模型、提示词文件、输出模式和模型特定参数。 +- 提示词路径相对于配置 JSON 所在目录解析;命令行输入/输出路径相对于当前目录。 +- 模型优先级:CLI > 阶段显式 `model` > `model_env` 环境变量 > `default_model`。 +- shell 环境覆盖 `.env`。实验 JSON 不应存放密钥,仅引用密钥环境变量名。 +- 不同服务可配置各自的 base URL/key 环境变量。 +- 模型 ID 原样发送,不再把 `gpt-image-2.5` 映射成其他模型。 +- `params` 允许模型特定参数,但不能覆盖 model、prompt、messages、input 等适配器保留字段。 +- 新模式的超时/重试来自 provider 配置;旧模式的图片超时仍用 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`。 + +例如把理解阶段改为固定模型: + +```json +{ + "provider": "relay-vision", + "model": "YOUR_EXACT_MODEL_ID", + "prompt": "../prompts/understand-v1.txt", + "output_mode": "text-json", + "params": {} +} +``` + +## 重跑与对比 + +同一命令增加 `--resume`,仅当上次完整结束,且模型、协议、参数、提示词、Schema、源图、适配器代码和所有产物校验一致时复用。模型变化时要求新的输出目录,防止覆盖对照组。失败任务也保留原目录,使用新目录重跑。 + +本轮实现单阶段缓存;跨 SAM/补全/导出的依赖缓存留到后续阶段接入。对比补全模型时,固定相同局部画布、任务文本与掩码。 + +显式 429/5xx 最多按配置重试,超时或网络断开不自动重试,因为服务端可能已经执行任务。完整请求不保证模型内容可复现;seed 只在提供方确认支持时放入 params,usage 不存在时记录 null,不估造价格或 token 数。 + +## SAM 路径 + +默认 `F:/Study/sam3.1`,检查 `sam3.1_multiplex.pt` 是否存在。该目录 README 明确说明它仅提供 checkpoint,不是可直接调用的推理服务。本轮仅记录路径和 checkpoint 存在性,状态为 `not_integrated`,不加载权重、不占用 GPU。 + +## 测试 + +```powershell +npm test +``` + +测试使用本地 mock HTTP 服务或注入响应,不调用真实付费模型。覆盖模型优先级、协议请求/响应、multipart、能力限制、数值/引用/环校验、错误响应、重试、缓存和 CLI 全链路。 + +2026-09-17 的集成检查:模型列表请求成功;Astra 要求 Responses + stream=true,切换后服务端返回 overloaded,尚未得到成功的真实角色标注。Qwen 适配器通过 mock 请求/响应测试,本轮未执行真实图片编辑生成。此状态不等于视觉质量已验证。 diff --git a/lib/config.mjs b/lib/config.mjs new file mode 100644 index 0000000..f33292a --- /dev/null +++ b/lib/config.mjs @@ -0,0 +1,92 @@ +import {existsSync, readFileSync} from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +export const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +export function readEnvFile(file) { + if (!existsSync(file)) return {}; + const values = {}; + for (const line of readFileSync(file, 'utf8').replace(/^\uFEFF/u, '').split(/\r?\n/u)) { + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/u); + if (!match) continue; + let value = match[2]; + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } else value = value.replace(/\s+#.*$/u, ''); + values[match[1]] = value; + } + return values; +} + +export function loadConfig({configFile, envFile, env = process.env} = {}) { + const file = path.resolve(configFile || path.join(projectRoot, 'configs', 'astra-qwen.json')); + const config = JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/u, '')); + if (config.schema_version !== 1 || !config.providers || !config.stages) { + throw new Error('配置必须声明 schema_version: 1、providers 和 stages'); + } + return {config, file, env: {...readEnvFile(envFile || path.join(projectRoot, '.env')), ...env}}; +} + +export function resolveStage(loaded, name, overrides = {}, {requireKey = true} = {}) { + const stage = loaded.config.stages[name]; + if (!stage) throw new Error(`没有配置阶段:${name}`); + const provider = loaded.config.providers[stage.provider]; + if (!provider) throw new Error(`没有配置提供方:${stage.provider}`); + const model = String(overrides.model || stage.model || loaded.env[stage.model_env] || stage.default_model || '').trim(); + if (!model) throw new Error(`缺少 ${name} 的模型 ID`); + const baseUrl = String(loaded.env[provider.base_url_env] || '').trim().replace(/\/+$/u, ''); + const apiKey = String(loaded.env[provider.api_key_env] || '').trim(); + if (requireKey && (!baseUrl || !apiKey)) throw new Error(`需要配置 ${provider.base_url_env} 和 ${provider.api_key_env}`); + if (baseUrl) { + const url = new URL(baseUrl); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + throw new Error('提供方 base URL 必须是无用户名、密码、查询参数的 HTTP(S) 地址'); + } + } + const protocol = overrides.protocol || provider.protocol; + const allowed = name === 'understanding' ? ['chat-completions', 'responses'] : ['images-edits']; + if (!allowed.includes(protocol)) throw new Error(`${name} 不支持协议:${protocol}`); + const params = {...stage.params, ...overrides.params}; + // Semantic fields are owned by adapters, never overridden via opaque params. + for (const key of ['model', 'messages', 'input', 'instructions', 'prompt', 'image', 'image[]', 'mask', 'response_format', 'text', 'stream', 'Authorization', 'api_key', 'apiKey']) { + if (Object.hasOwn(params, key)) throw new Error(`params 不能覆盖保留字段:${key}`); + } + const timeoutMs = provider.timeout_ms ?? 180000; + const maxRetries = provider.max_retries ?? 2; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 5) { + throw new Error('timeout_ms 必须为正数,max_retries 必须为 0~5 的整数'); + } + const outputMode = overrides.outputMode || stage.output_mode || 'text-json'; + if (!['text-json', 'json-object', 'json-schema'].includes(outputMode)) throw new Error(`未知 output_mode:${outputMode}`); + const stream = overrides.stream ?? stage.stream ?? false; + if (typeof stream !== 'boolean') throw new Error('stream 必须为 boolean'); + const promptPath = path.resolve(path.dirname(loaded.file), overrides.prompt || stage.prompt); + const capabilities = {multiple_images: false, mask: false, ...provider.capabilities}; + if (typeof capabilities.mask !== 'boolean' || typeof capabilities.multiple_images !== 'boolean') { + throw new Error('capabilities.mask 和 multiple_images 必须为 boolean'); + } + const imageField = provider.image_field || 'image'; + if (!['image', 'image[]'].includes(imageField)) throw new Error('image_field 必须是 image 或 image[]'); + return { + name, provider: stage.provider, protocol, model, baseUrl, apiKey, timeoutMs, maxRetries, + params, outputMode, stream, promptPath, prompt: readFileSync(promptPath, 'utf8'), capabilities, + imageField, maskEditable: provider.mask_editable || 'transparent', + }; +} + +export function publicStage(stage) { + return { + provider: stage.provider, protocol: stage.protocol, model: stage.model, + params: stage.params, output_mode: stage.outputMode, stream: stage.stream, timeout_ms: stage.timeoutMs, + max_retries: stage.maxRetries, capabilities: stage.capabilities, + image_field: stage.imageField, mask_editable: stage.maskEditable, + }; +} + +export function samInfo(loaded) { + const stage = loaded.config.stages.segmentation || {}; + const modelDir = loaded.env[stage.model_dir_env] || stage.default_model_dir || ''; + const checkpoint = modelDir ? path.join(modelDir, 'sam3.1_multiplex.pt') : ''; + return {model_dir: modelDir, checkpoint, checkpoint_exists: Boolean(checkpoint && existsSync(checkpoint)), runtime_status: 'not_integrated'}; +} diff --git a/lib/images.mjs b/lib/images.mjs new file mode 100644 index 0000000..44a5a69 --- /dev/null +++ b/lib/images.mjs @@ -0,0 +1,23 @@ +import {readFileSync} from 'node:fs'; +import {createHash} from 'node:crypto'; +import path from 'node:path'; + +export function sha256(value) { return createHash('sha256').update(value).digest('hex'); } + +export function imageFormat(bytes) { + if (bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { + return {mime: 'image/png', extension: 'png', width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20)}; + } + if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return {mime: 'image/jpeg', extension: 'jpg'}; + if (bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return {mime: 'image/webp', extension: 'webp'}; + throw new Error('图片数据不是支持的 PNG/JPEG/WebP 格式'); +} + +export function readImage(file, {pngOnly = false} = {}) { + const bytes = readFileSync(file); + const format = imageFormat(bytes); + if (pngOnly && (format.extension !== 'png' || !format.width || !format.height)) throw new Error('结构理解与局部画布当前要求非空 PNG 图片'); + return {file: path.resolve(file), bytes, ...format, sha256: sha256(bytes)}; +} + +export function dataUrl(image) { return `data:${image.mime};base64,${image.bytes.toString('base64')}`; } diff --git a/lib/report.mjs b/lib/report.mjs new file mode 100644 index 0000000..f0e813e --- /dev/null +++ b/lib/report.mjs @@ -0,0 +1,30 @@ +import {dataUrl} from './images.mjs'; + +const escape = (value) => String(value).replace(/[&<>"']/gu, (c) => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])); + +export function annotatedSvg(image, parts) { + const {width: w, height: h} = image; + const stroke = Math.max(w, h) / 400; + const font = Math.max(w, h) / 65; + const byId = new Map(parts.map((part) => [part.id, part])); + const overlays = parts.map((part, index) => { + const color = `hsl(${(index * 137.5) % 360} 85% 55%)`; + const [px, py] = [part.pivot_norm[0] * w, part.pivot_norm[1] * h]; + const parent = byId.get(part.parent); + const line = parent ? `` : ''; + let box = ''; + if (part.bbox_norm_xyxy) { + const [x0, y0, x1, y1] = part.bbox_norm_xyxy; + box = ``; + } + const points = part.positive_points_norm.map(([x, y]) => ``).join(''); + return `${line}${box}${points}${escape(part.id)}`; + }).join('\n'); + return `${overlays}`; +} + +export function understandingReport(image, data, validation, metadata) { + const svg = data && validation.valid ? annotatedSvg(image, data.parts) : `source`; + const rows = data && validation.valid ? data.parts.map((part) => `${escape(part.id)}${escape(part.parent ?? 'root')}${escape(part.visibility)}${escape(part.occluded_by.join(', '))}${part.confidence}`).join('') : ''; + return `角色结构理解检查

角色结构理解

此图仅包含模型预测的粗框、关节点(空心圆)和正点(实心圆),尚未执行 SAM 分割或补全。结构校验通过不代表语义正确。

状态:${validation.valid ? 'needs_review(待检查)' : 'failed'}

${svg}

校验

${escape(JSON.stringify(validation, null, 2))}

部件

${rows}
ID父部件可见性遮挡物自报置信度

模型说明

${escape(JSON.stringify(data?.notes || [], null, 2))}

请求记录

${escape(JSON.stringify(metadata, null, 2))}
`; +} diff --git a/lib/run-artifacts.mjs b/lib/run-artifacts.mjs new file mode 100644 index 0000000..9d702c2 --- /dev/null +++ b/lib/run-artifacts.mjs @@ -0,0 +1,34 @@ +import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs'; +import path from 'node:path'; +import {sha256} from './images.mjs'; +import {redact} from '../providers/http.mjs'; + +export function jsonFile(file, value, secret = '') { + writeFileSync(file, `${redact(JSON.stringify(value, null, 2), secret)}\n`, 'utf8'); +} + +export function prepareRun(outDir, fingerprint, resume) { + const out = path.resolve(outDir); + const manifestPath = path.join(out, 'manifest.json'); + if (existsSync(out) && readdirSync(out).length) { + if (!resume) throw new Error(`输出目录非空,请使用新目录或 --resume:${out}`); + if (!existsSync(manifestPath)) throw new Error('无法复用:目录没有 manifest.json'); + const previous = JSON.parse(readFileSync(manifestPath, 'utf8')); + if (previous.fingerprint !== fingerprint) throw new Error('无法复用:模型、配置、输入或代码版本变化,请使用新的输出目录'); + if (previous.status !== 'completed') throw new Error('上次任务没有完整结束,请保留诊断文件并使用新目录重跑'); + for (const [name, hash] of Object.entries(previous.artifact_hashes || {})) { + const file = path.join(out, name); + if (!existsSync(file) || sha256(readFileSync(file)) !== hash) throw new Error(`缓存产物缺失或被修改:${name}`); + } + if (!Object.keys(previous.artifact_hashes || {}).length) throw new Error('缓存缺少产物校验记录'); + return {out, manifestPath, reused: true}; + } + mkdirSync(out, {recursive: true}); + return {out, manifestPath, reused: false}; +} + +export function finishRun(run, manifest, artifacts, secret) { + manifest.artifact_hashes = Object.fromEntries(artifacts.map((name) => [name, sha256(readFileSync(path.join(run.out, name)))])); + manifest.status = 'completed'; + jsonFile(run.manifestPath, manifest, secret); +} diff --git a/lib/validate-parts.mjs b/lib/validate-parts.mjs new file mode 100644 index 0000000..9048c61 --- /dev/null +++ b/lib/validate-parts.mjs @@ -0,0 +1,91 @@ +import {readFileSync} from 'node:fs'; +import path from 'node:path'; +import {projectRoot} from './config.mjs'; + +export const partsSchema = JSON.parse(readFileSync(path.join(projectRoot, 'schemas', 'semantic-parts.schema.json'), 'utf8')); + +// Validate the subset of JSON Schema used by the checked-in contract. Keeping +// the validation driven by that contract avoids two independent field lists. +function validate(value, schema, at, errors) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + const matches = (type) => { + if (type === 'null') return value === null; + if (type === 'array') return Array.isArray(value); + if (type === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value); + if (type === 'integer') return Number.isInteger(value); + if (type === 'number') return typeof value === 'number' && Number.isFinite(value); + return typeof value === type; + }; + if (!types.some(matches)) { errors.push(`${at}: 类型必须为 ${types.join('/')}`); return; } + if (schema.enum && !schema.enum.includes(value)) errors.push(`${at}: 值不在允许列表中`); + if (value === null) return; + if (typeof value === 'number') { + if (value < (schema.minimum ?? -Infinity) || value > (schema.maximum ?? Infinity)) errors.push(`${at}: 数值越界`); + } + if (typeof value === 'string') { + if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) errors.push(`${at}: 格式不合法`); + if (value.length < (schema.minLength ?? 0) || value.length > (schema.maxLength ?? Infinity)) errors.push(`${at}: 字符串长度不合法`); + } + if (Array.isArray(value)) { + if (value.length < (schema.minItems ?? 0) || value.length > (schema.maxItems ?? Infinity)) errors.push(`${at}: 数组长度不合法`); + value.forEach((item, index) => validate(item, schema.items, `${at}[${index}]`, errors)); + } else if (typeof value === 'object') { + for (const key of schema.required || []) if (!Object.hasOwn(value, key)) errors.push(`${at}.${key}: 必填`); + for (const [key, item] of Object.entries(value)) { + if (schema.properties[key]) validate(item, schema.properties[key], `${at}.${key}`, errors); + else if (schema.additionalProperties === false) errors.push(`${at}.${key}: 未知字段`); + } + } +} + +export function parseParts(text) { + const cleaned = text.trim().replace(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/u, '$1'); + return JSON.parse(cleaned); +} + +export function validateParts(data) { + const errors = []; + const warnings = []; + validate(data, partsSchema, '$', errors); + if (errors.length) return {valid: false, errors, warnings}; + const ids = new Set(data.parts.map((part) => part.id)); + if (ids.size !== data.parts.length) errors.push('部件 ID 重复'); + for (const part of data.parts) { + if (part.id === 'root') errors.push('root 为后续导出保留 ID'); + for (const ref of [part.parent, ...part.occluded_by].filter((item) => item !== null)) { + if (!ids.has(ref) || ref === part.id) errors.push(`${part.id}: 引用了不存在的部件或自身 ${ref}`); + } + if (new Set(part.occluded_by).size !== part.occluded_by.length) errors.push(`${part.id}: occluded_by 重复`); + const box = part.bbox_norm_xyxy; + if (part.visibility === 'hidden') { + if (box !== null || part.positive_points_norm.length || !part.needs_completion) errors.push(`${part.id}: 完全隐藏部件应无可见框/正点,且需要补全`); + warnings.push(`${part.id}: 完全隐藏部件需要人工确认,不能直接生成可见 mask`); + } else { + if (!box || box[0] >= box[2] || box[1] >= box[3]) errors.push(`${part.id}: 可见 bbox 必须具有正面积`); + if (!part.positive_points_norm.length) errors.push(`${part.id}: 可见部件需要至少一个正点`); + if (box) for (const [x, y] of part.positive_points_norm) { + if (x < box[0] || x > box[2] || y < box[1] || y > box[3]) errors.push(`${part.id}: 正点不在可见框内`); + } + } + if (part.confidence < 0.6) warnings.push(`${part.id}: 模型自报低置信度,需要复核`); + } + if (!errors.length) { + const parts = new Map(data.parts.map((part) => [part.id, part])); + for (const relation of ['parent', 'occluded_by']) { + const visiting = new Set(); + const done = new Set(); + const visit = (id) => { + if (visiting.has(id)) { errors.push(`${relation} 存在环:${id}`); return; } + if (done.has(id)) return; + visiting.add(id); + const part = parts.get(id); + const refs = relation === 'parent' ? [part.parent].filter(Boolean) : part.occluded_by; + refs.forEach(visit); + visiting.delete(id); + done.add(id); + }; + ids.forEach(visit); + } + } + return {valid: errors.length === 0, errors, warnings}; +} diff --git a/package.json b/package.json index 0ecd1fe..d12472a 100644 --- a/package.json +++ b/package.json @@ -2,5 +2,10 @@ "name": "genarrative-genielabs-spine-pipeline", "private": true, "type": "module", - "scripts": {"run": "node pipeline.mjs"} + "scripts": { + "run": "node pipeline.mjs", + "models": "node tools/model-check.mjs --list-models", + "test": "node --test" + }, + "engines": {"node": ">=20"} } diff --git a/pipeline.mjs b/pipeline.mjs index a2f9bec..246f4e0 100644 --- a/pipeline.mjs +++ b/pipeline.mjs @@ -13,7 +13,7 @@ import {fileURLToPath} from 'node:url'; import path from 'node:path'; const repoRoot = path.dirname(fileURLToPath(import.meta.url)); -const defaultGenielabsDir = '/Users/zhaoziyu/Documents/Codex/2026-09-10/diao/work/repos/spine-animation-ai'; +const defaultGenielabsDir = path.resolve(repoRoot, '..', 'spine-animation-ai'); const defaultModel = 'gpt-image-2'; const positivePrompt = [ 'Create a complete 2D game sprite sheet for Spine animation from the exact reference character.', @@ -57,12 +57,8 @@ function run(command, commandArgs, cwd = repoRoot) { } function resolveModel(model) { - const normalized = String(model || '').trim(); - if (normalized === 'gpt-image-2.5' || normalized === 'gpt image 2.5') { - console.warn('提示:VectorEngine 当前没有 gpt-image-2.5,已兼容映射为 gpt-image-2。'); - return defaultModel; - } - return normalized || defaultModel; + // Preserve exact provider IDs so experiments never silently use another model. + return String(model || '').trim() || defaultModel; } function endpoint(base, suffix) { return base.endsWith('/v1') ? `${base}/${suffix}` : `${base}/v1/${suffix}`; } @@ -87,14 +83,17 @@ function loadEnvFile(file) { } function selectPython(requested) { - const candidates = [requested, '/Users/zhaoziyu/anaconda3/bin/python', '/opt/homebrew/bin/python3', 'python3'].filter(Boolean); + const venvPython = path.join(repoRoot, '.venv', process.platform === 'win32' ? 'Scripts' : 'bin', process.platform === 'win32' ? 'python.exe' : 'python'); + const candidates = requested ? [requested] : ['python', 'python3', venvPython]; for (const candidate of candidates) { const check = spawnSync(candidate, ['-c', 'import cv2, PIL, numpy'], {stdio: 'ignore'}); if (check.status === 0) return candidate; } - const venvPython = path.join(repoRoot, '.venv', 'bin', 'python'); + if (requested) throw new Error(`指定的 Python 无法导入 cv2、PIL、numpy:${requested};请在该环境安装 requirements.txt`); if (!existsSync(venvPython)) { - const bootstrap = spawnSync('python3', ['-m', 'venv', path.join(repoRoot, '.venv')], {stdio: 'inherit'}); + const bootstrapPython = ['python', 'python3'].find((candidate) => spawnSync(candidate, ['-c', 'import sys'], {stdio: 'ignore'}).status === 0); + if (!bootstrapPython) throw new Error('未找到 Python,请激活环境或通过 --python 指定'); + const bootstrap = spawnSync(bootstrapPython, ['-m', 'venv', path.join(repoRoot, '.venv')], {stdio: 'inherit'}); if (bootstrap.status !== 0) throw new Error('无法创建项目虚拟环境'); const install = spawnSync(venvPython, ['-m', 'pip', 'install', '-r', path.join(repoRoot, 'requirements.txt')], {stdio: 'inherit'}); if (install.status !== 0) throw new Error('Python 依赖安装失败,请检查网络或手动安装 requirements.txt'); @@ -248,6 +247,12 @@ function writeReferencePreview(reference, outputPath) { } async function main() { + const modeIndex = process.argv.indexOf('--mode'); + if (modeIndex >= 0) { + if (process.argv[modeIndex + 1] !== 'annotated-parts') throw new Error('新增 --mode 仅支持 annotated-parts;旧流程无需 --mode'); + const {main: modelMain} = await import('./tools/model-check.mjs'); + return modelMain(process.argv.slice(2)); + } const options = args(process.argv); if (!options.reference || !existsSync(options.reference) || !options.outDir || !options.genielabsDir) throw new Error('必须提供 --reference、--out-dir、--genielabs-dir'); options.python = selectPython(options.python); diff --git a/prompts/complete-v1.txt b/prompts/complete-v1.txt new file mode 100644 index 0000000..4f1a382 --- /dev/null +++ b/prompts/complete-v1.txt @@ -0,0 +1,10 @@ +Complete only the hidden portion of the specified character part on the first +input image (the local canvas). Any subsequent images are context references. +Preserve the exact original pose, scale, screen direction, perspective, colors, +line thickness and shading. Do not mirror, rotate, resize, recenter or redesign. +Keep the canvas dimensions and the position of every already-visible feature. +Extend the hidden joint area naturally behind its occluder, without adding other +body parts, props, labels, annotations or extra versions of the same part. +If an edit mask is supplied, transparent mask pixels are editable. If no edit +mask is supplied, this request is only an adapter experiment, not a guaranteed +pixel-preserving final asset. A later stage will restore source visible pixels. diff --git a/prompts/understand-v1.txt b/prompts/understand-v1.txt new file mode 100644 index 0000000..149b2db --- /dev/null +++ b/prompts/understand-v1.txt @@ -0,0 +1,20 @@ +Analyze the supplied single character image for a Spine cutout animation pipeline. +Return a JSON part plan, not an image. Preserve the exact pose, asymmetry, clothing, +and screen-left/screen-right orientation. Do not require a fixed number of parts. +Use stable lowercase ASCII IDs (letters, numbers, underscores, hyphens). +Separate parts only where independent motion is useful and the image supports it. +Parent refers to the motion hierarchy; null means attached to the future root bone. +Occlusion is a separate relation: occluded_by lists parts visually in front. +All coordinates are normalized to the full supplied canvas, including transparent +margins. bbox_norm_xyxy is [xmin,ymin,xmax,ymax], never [x,y,width,height]. +pivot_norm is the estimated attachment joint, not necessarily the bbox center. +For visible or partly visible parts give a coarse visible bbox and positive points +inside visible pixels; optional negative points identify nearby other parts. +Do not claim precise pixel masks. A segmentation stage will refine boundaries. +If a part is fully hidden, mark visibility="hidden", bbox_norm_xyxy=null, +positive_points_norm=[], needs_completion=true and explain the uncertainty. +Do not invent hidden limbs or accessories without visual evidence. Put assumptions +in notes. Confidence is your uncertainty estimate, not a measured segmentation score. +Avoid cycles in parent and occluded_by relations. If an object crosses in front +and behind another, split its visual layers or flag the ambiguity in notes. +Return schema_version=1, coordinate_space="normalized_xy_top_left", parts, notes. diff --git a/providers/http.mjs b/providers/http.mjs new file mode 100644 index 0000000..39a5507 --- /dev/null +++ b/providers/http.mjs @@ -0,0 +1,112 @@ +export function endpoint(baseUrl, route) { + const base = baseUrl.replace(/\/+$/u, ''); + return `${base.endsWith('/v1') ? base : `${base}/v1`}/${route}`; +} + +export function redact(value, secret = '') { + let text = typeof value === 'string' ? value : JSON.stringify(value); + if (secret) text = text.split(secret).join('[REDACTED]'); + return text.replace(/Bearer\s+[^\s"<>]+/giu, 'Bearer [REDACTED]'); +} + +// Some relay channels require SSE even for offline jobs. Accept a terminal +// Responses event (not only text deltas) so truncated streams cannot pass QA. +export function parseEventStream(raw) { + let terminal = null; + let text = ''; + let model = null; + let usage = null; + let id = null; + let finishReason = null; + let refusal = ''; + for (const block of raw.replace(/\r\n/gu, '\n').split(/\n\n/u)) { + const data = block.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n'); + if (!data || data.trim() === '[DONE]') continue; + let event; + try { event = JSON.parse(data); } catch { throw new Error('SSE 包含不完整或非法 JSON 事件'); } + if (event.error || event.type === 'error') throw new Error(`SSE 服务端错误:${event.error?.message || event.message || 'unknown error'}`); + if (['response.completed', 'response.incomplete', 'response.failed'].includes(event.type)) { + if (!event.response || typeof event.response !== 'object') throw new Error('SSE 终止事件缺少 response'); + terminal = event.response; + terminal.status ||= event.type.slice('response.'.length); + } + if (event.choices) { + model = event.model || model; id = event.id || id; usage = event.usage || usage; + const choice = event.choices.find((item) => (item.index ?? 0) === 0); + if (typeof choice?.delta?.content === 'string') text += choice.delta.content; + if (typeof choice?.delta?.refusal === 'string') refusal += choice.delta.refusal; + if (choice?.finish_reason) finishReason = choice.finish_reason; + } + } + if (terminal) return terminal; + if (finishReason) return {id, model, usage, choices: [{finish_reason: finishReason, message: {content: text, ...(refusal ? {refusal} : {})}}]}; + throw new Error('SSE 未收到完整终止事件,可能连接中断;不接受部分输出'); +} + +// Retry only explicit 429/5xx responses. A timeout/network failure may already +// have generated and billed an image; do not automatically duplicate that job. +export async function requestJson(stage, route, makeRequest, { + fetchImpl = fetch, + sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), +} = {}) { + const started = Date.now(); + for (let attempt = 0; attempt <= stage.maxRetries; attempt += 1) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), stage.timeoutMs); + let diagnostics = null; + try { + const request = makeRequest(); + const response = await fetchImpl(endpoint(stage.baseUrl, route), { + ...request, + headers: {...request.headers, Authorization: `Bearer ${stage.apiKey}`}, + signal: controller.signal, + redirect: 'error', + }); + const raw = await response.text(); + diagnostics = { + http_status: response.status, request_id: response.headers.get('x-request-id'), + elapsed_ms: Date.now() - started, attempts: attempt + 1, + // Bounded diagnostics; normal successful artifacts retain full payloads. + response_body: raw.slice(0, 2_000_000), truncated: raw.length > 2_000_000, + }; + let payload; + try { payload = JSON.parse(raw); } catch { payload = null; } + if (!response.ok) { + if ((response.status === 429 || response.status >= 500) && attempt < stage.maxRetries) { + clearTimeout(timer); + await sleep(Math.min(10000, 2000 * (2 ** attempt))); + continue; + } + const message = payload?.error?.message || `请求未成功 (${response.status})`; + throw new Error(redact(`HTTP ${response.status}: ${message}`, stage.apiKey)); + } + const isSse = response.headers.get('content-type')?.includes('text/event-stream') || /^data:|\nevent:|^event:/u.test(raw); + if (!payload && isSse) payload = parseEventStream(raw); + if (!payload || typeof payload !== 'object') throw new Error('服务端没有返回 JSON 对象或完整 SSE'); + if (payload.error) throw new Error(redact(`服务端错误:${payload.error.message || 'unknown error'}`, stage.apiKey)); + return {payload, metadata: { + requested_model: stage.model, returned_model: payload.model || null, + request_id: response.headers.get('x-request-id') || payload.id || null, + usage: payload.usage || null, elapsed_ms: Date.now() - started, attempts: attempt + 1, wire_format: isSse ? 'sse' : 'json', + }}; + } catch (error) { + const cause = error.cause?.code || error.cause?.message; + const failure = new Error(controller.signal.aborted + ? `请求超时 (${stage.timeoutMs} ms),未自动重试;请检查中转站任务状态` + : redact(`${error.message}${cause ? ` (${cause})` : ''}`, stage.apiKey)); + failure.diagnostics = diagnostics + ? JSON.parse(redact(diagnostics, stage.apiKey)) + : {elapsed_ms: Date.now() - started, attempts: attempt + 1, network_code: cause || null}; + throw failure; + } finally { clearTimeout(timer); } + } +} + +export async function downloadImage(url, stage, fetchImpl = fetch) { + const parsed = new URL(url); + if (!['https:', 'http:'].includes(parsed.protocol)) throw new Error('图片 URL 必须使用 HTTP(S)'); + const response = await fetchImpl(parsed, {signal: AbortSignal.timeout(stage.timeoutMs)}); + if (!response.ok) throw new Error(`下载图片失败:HTTP ${response.status}`); + // Never forward the relay API key to image download hosts. + return Buffer.from(await response.arrayBuffer()); +} diff --git a/providers/image-edits.mjs b/providers/image-edits.mjs new file mode 100644 index 0000000..19eafd1 --- /dev/null +++ b/providers/image-edits.mjs @@ -0,0 +1,45 @@ +import path from 'node:path'; +import {imageFormat} from '../lib/images.mjs'; +import {requestJson, downloadImage} from './http.mjs'; + +export function validateEditInputs(stage, images, mask) { + if (!images.length) throw new Error('图片编辑至少需要一张输入图'); + if (images.length > 1 && !stage.capabilities.multiple_images) throw new Error('尚未确认此提供方支持多图;请先验证再启用 capabilities.multiple_images'); + if (mask) { + if (!stage.capabilities.mask) throw new Error('尚未确认此提供方支持编辑掩码;不能静默忽略 --mask'); + if (stage.maskEditable !== 'transparent') throw new Error('当前掩码适配仅支持透明像素为可编辑区域'); + if (mask.extension !== 'png' || mask.width !== images[0].width || mask.height !== images[0].height) { + throw new Error('编辑 mask 必须为与第一张输入图同尺寸的 PNG'); + } + if (![4, 6].includes(mask.bytes[25])) throw new Error('编辑 mask 必须包含 alpha 通道(透明处允许编辑),不能直接传 SAM 黑白掩码'); + } +} + +export async function editImage(stage, {images, mask, prompt}, options) { + validateEditInputs(stage, images, mask); + return requestJson(stage, 'images/edits', () => { + const form = new FormData(); + form.append('model', stage.model); + form.append('prompt', prompt); + for (const [name, value] of Object.entries(stage.params)) { + if (value !== null && value !== undefined) form.append(name, typeof value === 'object' ? JSON.stringify(value) : String(value)); + } + for (const image of images) form.append(stage.imageField, new Blob([image.bytes], {type: image.mime}), path.basename(image.file)); + if (mask) form.append('mask', new Blob([mask.bytes], {type: mask.mime}), path.basename(mask.file)); + return {method: 'POST', body: form}; + }, options); +} + +export async function decodeEditedImages(payload, stage, {fetchImpl = fetch} = {}) { + const items = payload.data || payload.images || payload.output; + if (!Array.isArray(items) || !items.length) throw new Error('图片编辑响应没有图片列表'); + const decoded = []; + for (const item of items) { + let bytes; + if (typeof item.b64_json === 'string') bytes = Buffer.from(item.b64_json, 'base64'); + else if (typeof item.url === 'string') bytes = await downloadImage(item.url, stage, fetchImpl); + else throw new Error('图片编辑响应缺少 b64_json/url'); + decoded.push({bytes, ...imageFormat(bytes)}); + } + return decoded; +} diff --git a/providers/vision.mjs b/providers/vision.mjs new file mode 100644 index 0000000..8ad67fd --- /dev/null +++ b/providers/vision.mjs @@ -0,0 +1,53 @@ +import {dataUrl} from '../lib/images.mjs'; +import {requestJson} from './http.mjs'; + +export function buildVisionRequest(stage, image, schema) { + const instruction = `${stage.prompt}\n\nCanvas: ${image.width} x ${image.height} pixels.\nReturn ONLY JSON matching this schema:\n${JSON.stringify(schema)}`; + if (stage.protocol === 'chat-completions') { + const body = { ...stage.params, model: stage.model, stream: stage.stream, + messages: [{role: 'user', content: [ + {type: 'text', text: instruction}, + {type: 'image_url', image_url: {url: dataUrl(image)}}, + ]}], + }; + if (stage.outputMode === 'json-object') body.response_format = {type: 'json_object'}; + if (stage.outputMode === 'json-schema') body.response_format = {type: 'json_schema', json_schema: {name: 'semantic_parts', strict: true, schema}}; + return {route: 'chat/completions', body}; + } + if (stage.protocol === 'responses') { + const body = {...stage.params, model: stage.model, stream: stage.stream, instructions: stage.prompt, input: [{role: 'user', content: [ + {type: 'input_text', text: instruction}, + {type: 'input_image', image_url: dataUrl(image)}, + ]}]}; + if (stage.outputMode === 'json-object') body.text = {format: {type: 'json_object'}}; + if (stage.outputMode === 'json-schema') body.text = {format: {type: 'json_schema', name: 'semantic_parts', strict: true, schema}}; + return {route: 'responses', body}; + } + throw new Error(`未知理解协议:${stage.protocol}`); +} + +export function extractVisionText(payload, protocol) { + if (protocol === 'chat-completions') { + const choice = payload.choices?.[0]; + if (choice?.message?.refusal) throw new Error('结构理解请求被模型拒绝'); + if (choice?.finish_reason && choice.finish_reason !== 'stop') throw new Error(`结构理解响应未完整结束:${choice.finish_reason}`); + const content = choice?.message?.content; + if (typeof content === 'string' && content.trim()) return content; + if (Array.isArray(content)) { + const text = content.filter((item) => item.type === 'text').map((item) => item.text).join('\n'); + if (text.trim()) return text; + } + } else { + if (payload.status && payload.status !== 'completed') throw new Error(`结构理解响应状态:${payload.status}`); + const items = (payload.output || []).flatMap((item) => item.content || []); + if (items.some((item) => item.type === 'refusal')) throw new Error('结构理解请求被模型拒绝'); + const text = payload.output_text || items.filter((item) => item.type === 'output_text').map((item) => item.text).join('\n'); + if (typeof text === 'string' && text.trim()) return text; + } + throw new Error('服务端响应中没有结构理解文本'); +} + +export async function analyze(stage, image, schema, options) { + const {route, body} = buildVisionRequest(stage, image, schema); + return requestJson(stage, route, () => ({method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}), options); +} diff --git a/schemas/semantic-parts.schema.json b/schemas/semantic-parts.schema.json new file mode 100644 index 0000000..2305176 --- /dev/null +++ b/schemas/semantic-parts.schema.json @@ -0,0 +1,30 @@ +{ + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "coordinate_space", "parts", "notes"], + "properties": { + "schema_version": {"type": "integer", "enum": [1]}, + "coordinate_space": {"type": "string", "enum": ["normalized_xy_top_left"]}, + "notes": {"type": "array", "items": {"type": "string"}}, + "parts": { + "type": "array", "minItems": 1, "maxItems": 64, + "items": { + "type": "object", "additionalProperties": false, + "required": ["id", "semantic", "parent", "visibility", "bbox_norm_xyxy", "pivot_norm", "positive_points_norm", "negative_points_norm", "occluded_by", "needs_completion", "confidence"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z][a-z0-9_-]*$", "maxLength": 64}, + "semantic": {"type": "string", "minLength": 1}, + "parent": {"type": ["string", "null"]}, + "visibility": {"type": "string", "enum": ["visible", "partial", "hidden"]}, + "bbox_norm_xyxy": {"type": ["array", "null"], "minItems": 4, "maxItems": 4, "items": {"type": "number", "minimum": 0, "maximum": 1}}, + "pivot_norm": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number", "minimum": 0, "maximum": 1}}, + "positive_points_norm": {"type": "array", "maxItems": 32, "items": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number", "minimum": 0, "maximum": 1}}}, + "negative_points_norm": {"type": "array", "maxItems": 32, "items": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number", "minimum": 0, "maximum": 1}}}, + "occluded_by": {"type": "array", "items": {"type": "string"}}, + "needs_completion": {"type": "boolean"}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1} + } + } + } + } +} diff --git a/stages/model-stage.mjs b/stages/model-stage.mjs new file mode 100644 index 0000000..467f922 --- /dev/null +++ b/stages/model-stage.mjs @@ -0,0 +1,118 @@ +import {readFileSync, writeFileSync} from 'node:fs'; +import {execFileSync} from 'node:child_process'; +import path from 'node:path'; +import {projectRoot, publicStage, samInfo} from '../lib/config.mjs'; +import {readImage, sha256} from '../lib/images.mjs'; +import {partsSchema, parseParts, validateParts} from '../lib/validate-parts.mjs'; +import {annotatedSvg, understandingReport} from '../lib/report.mjs'; +import {prepareRun, jsonFile, finishRun} from '../lib/run-artifacts.mjs'; +import {analyze, extractVisionText} from '../providers/vision.mjs'; +import {editImage, validateEditInputs, decodeEditedImages} from '../providers/image-edits.mjs'; +import {redact} from '../providers/http.mjs'; + +function codeVersion() { + // Include uncommitted source changes as well as a commit ID for experiments. + const files = [ + 'stages/model-stage.mjs', 'lib/config.mjs', 'lib/images.mjs', 'lib/report.mjs', + 'lib/run-artifacts.mjs', 'lib/validate-parts.mjs', 'providers/http.mjs', + 'providers/vision.mjs', 'providers/image-edits.mjs', 'tools/model-check.mjs', + 'schemas/semantic-parts.schema.json', + ]; + const sourceHash = sha256(files.map((file) => `${file}\n${readFileSync(path.join(projectRoot, file), 'utf8')}`).join('\n')); + let commit = null; + try { commit = execFileSync('git', ['rev-parse', 'HEAD'], {cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']}).trim(); } catch { /* Not all distributions are Git checkouts. */ } + return {commit, source_sha256: sourceHash}; +} + +function runIdentity(stage, images, extra) { + return { + schema_version: 1, stage: stage.name, resolved_stage: publicStage(stage), + endpoint_sha256: sha256(stage.baseUrl), prompt_sha256: sha256(stage.prompt), + inputs: images.map((image) => ({sha256: image.sha256, mime: image.mime, width: image.width ?? null, height: image.height ?? null})), + code: codeVersion(), ...extra, + }; +} + +export async function runUnderstanding({loaded, stage, reference, outDir, resume = false}, transport) { + const image = readImage(reference, {pngOnly: true}); + const identity = runIdentity(stage, [image], {schema_sha256: sha256(JSON.stringify(partsSchema))}); + const run = prepareRun(outDir, sha256(JSON.stringify(identity)), resume); + if (run.reused) return {out: run.out, reused: true}; + const manifest = {...identity, fingerprint: sha256(JSON.stringify(identity)), status: 'running', quality_status: 'pending', started_at: new Date().toISOString(), sam: samInfo(loaded)}; + jsonFile(run.manifestPath, manifest, stage.apiKey); + writeFileSync(path.join(run.out, 'source.png'), image.bytes); + writeFileSync(path.join(run.out, 'prompt.txt'), stage.prompt); + jsonFile(path.join(run.out, 'request.json'), {...identity, reference: 'source.png'}, stage.apiKey); + try { + const {payload, metadata} = await analyze(stage, image, partsSchema, transport); + jsonFile(path.join(run.out, 'response.raw.json'), payload, stage.apiKey); + jsonFile(path.join(run.out, 'request-metadata.json'), metadata, stage.apiKey); + let data = null; + let validation; + try { + const text = extractVisionText(payload, stage.protocol); + writeFileSync(path.join(run.out, 'response.txt'), redact(text, stage.apiKey)); + data = parseParts(text); + validation = validateParts(data); + } catch (error) { validation = {valid: false, errors: [redact(error.message, stage.apiKey)], warnings: []}; } + jsonFile(path.join(run.out, 'validation.json'), validation, stage.apiKey); + writeFileSync(path.join(run.out, 'report.html'), redact(understandingReport(image, data, validation, metadata), stage.apiKey)); + if (!validation.valid) throw new Error('结构理解结果未通过本地校验,请查看 validation.json、response.raw.json 和 report.html'); + jsonFile(path.join(run.out, 'semantic-parts.json'), data, stage.apiKey); + writeFileSync(path.join(run.out, 'annotated.svg'), redact(annotatedSvg(image, data.parts), stage.apiKey)); + manifest.request = metadata; + manifest.quality_status = 'needs_review'; + manifest.completed_at = new Date().toISOString(); + finishRun(run, manifest, ['source.png', 'prompt.txt', 'request.json', 'response.raw.json', 'response.txt', 'request-metadata.json', 'validation.json', 'report.html', 'semantic-parts.json', 'annotated.svg'], stage.apiKey); + return {out: run.out, reused: false, parts: data.parts.length}; + } catch (error) { + if (error.diagnostics) jsonFile(path.join(run.out, 'response.error.json'), error.diagnostics, stage.apiKey); + manifest.status = 'failed'; + manifest.quality_status = 'failed'; + manifest.error = redact(error.message, stage.apiKey); + jsonFile(run.manifestPath, manifest, stage.apiKey); + throw error; + } +} + +export async function runCompletionProbe({stage, imagePaths, maskPath, promptFile, outDir, resume = false}, transport) { + const images = imagePaths.map((file) => readImage(file, {pngOnly: true})); + const mask = maskPath ? readImage(maskPath, {pngOnly: true}) : null; + validateEditInputs(stage, images, mask); + const taskPrompt = promptFile ? readFileSync(promptFile, 'utf8') : ''; + if (!taskPrompt.trim()) throw new Error('图片编辑测试需要 --prompt-file 明确指定本次部件和补全需求'); + const prompt = `${stage.prompt}\n\nTask:\n${taskPrompt}`; + const identity = runIdentity(stage, images, {task_prompt_sha256: sha256(taskPrompt), mask_sha256: mask?.sha256 || null}); + const run = prepareRun(outDir, sha256(JSON.stringify(identity)), resume); + if (run.reused) return {out: run.out, reused: true}; + const manifest = {...identity, fingerprint: sha256(JSON.stringify(identity)), status: 'running', quality_status: 'pending', started_at: new Date().toISOString(), output_kind: 'raw_completion_probe'}; + jsonFile(run.manifestPath, manifest, stage.apiKey); + const artifacts = []; + images.forEach((image, i) => { const name = `input-${i}.png`; writeFileSync(path.join(run.out, name), image.bytes); artifacts.push(name); }); + if (mask) { writeFileSync(path.join(run.out, 'edit-mask.png'), mask.bytes); artifacts.push('edit-mask.png'); } + writeFileSync(path.join(run.out, 'prompt.txt'), prompt); + jsonFile(path.join(run.out, 'request.json'), identity, stage.apiKey); + try { + const {payload, metadata} = await editImage(stage, {images, mask, prompt}, transport); + jsonFile(path.join(run.out, 'response.raw.json'), payload, stage.apiKey); + jsonFile(path.join(run.out, 'request-metadata.json'), metadata, stage.apiKey); + const outputs = await decodeEditedImages(payload, stage, transport); + manifest.outputs = outputs.map((output, index) => { + const file = `candidate-${index + 1}.${output.extension}`; + writeFileSync(path.join(run.out, file), output.bytes); + artifacts.push(file); + return {file, width: output.width ?? null, height: output.height ?? null, canvas_size_matches: output.width ? output.width === images[0].width && output.height === images[0].height : null}; + }); + manifest.request = metadata; + manifest.quality_status = 'needs_review'; + manifest.notes = ['仅验证图片编辑适配器;尚未执行掩码提取、可见像素恢复、几何验收或 Spine 导出。', 'mask/多图能力由显式配置声明,不能仅凭 API 返回 200 判断模型遵循约束。']; + manifest.completed_at = new Date().toISOString(); + finishRun(run, manifest, [...artifacts, 'prompt.txt', 'request.json', 'response.raw.json', 'request-metadata.json'], stage.apiKey); + return {out: run.out, reused: false, candidates: outputs.length}; + } catch (error) { + if (error.diagnostics) jsonFile(path.join(run.out, 'response.error.json'), error.diagnostics, stage.apiKey); + manifest.status = 'failed'; manifest.error = redact(error.message, stage.apiKey); + jsonFile(run.manifestPath, manifest, stage.apiKey); + throw error; + } +} diff --git a/tests/model-stages.test.mjs b/tests/model-stages.test.mjs new file mode 100644 index 0000000..9b95c73 --- /dev/null +++ b/tests/model-stages.test.mjs @@ -0,0 +1,228 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, readFileSync, writeFileSync, rmSync, realpathSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {createServer} from 'node:http'; +import {execFile} from 'node:child_process'; +import {promisify} from 'node:util'; +import {loadConfig, resolveStage, projectRoot, publicStage} from '../lib/config.mjs'; +import {readImage} from '../lib/images.mjs'; +import {validateParts, partsSchema} from '../lib/validate-parts.mjs'; +import {requestJson, parseEventStream} from '../providers/http.mjs'; +import {buildVisionRequest, extractVisionText} from '../providers/vision.mjs'; +import {editImage, decodeEditedImages, validateEditInputs} from '../providers/image-edits.mjs'; +import {runUnderstanding, runCompletionProbe} from '../stages/model-stage.mjs'; + +const exec = promisify(execFile); +const reference = path.join(projectRoot, 'references', 'characters', 'kenney-beige-idle.png'); +const image = readImage(reference, {pngOnly: true}); +const part = (id, parent = null) => ({id, semantic: id, parent, visibility: 'visible', bbox_norm_xyxy: [0.1, 0.1, 0.9, 0.9], pivot_norm: [0.5, 0.5], positive_points_norm: [[0.5, 0.5]], negative_points_norm: [], occluded_by: [], needs_completion: false, confidence: 0.8}); +const goodParts = () => ({schema_version: 1, coordinate_space: 'normalized_xy_top_left', parts: [part('torso'), part('head', 'torso')], notes: []}); +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {status, headers: {'Content-Type': 'application/json', 'x-request-id': 'test-request'}}); +const stageFor = (name = 'understanding') => resolveStage(loadConfig({envFile: '__no_test_env__', env: {VECTOR_ENGINE_BASE_URL: 'https://relay.invalid/v1', VECTOR_ENGINE_API_KEY: 'test-secret'}}), name, name === 'understanding' ? {protocol: 'chat-completions'} : {}); +const chatPayload = (parts = goodParts()) => ({model: 'gpt-6-astra', choices: [{finish_reason: 'stop', message: {content: JSON.stringify(parts)}}], usage: {total_tokens: 12}}); + +function temporary(t) { + const base = realpathSync(tmpdir()); + const dir = mkdtempSync(path.join(base, 'ai-spine-test-')); + t.after(() => { + const resolved = realpathSync(dir); + assert.equal(path.dirname(resolved), base); + assert.ok(path.basename(resolved).startsWith('ai-spine-test-')); + rmSync(resolved, {recursive: true}); + }); + return dir; +} + +test('model precedence, independent model selection, no aliases, no secrets in public config', () => { + const loaded = loadConfig({envFile: '__no_test_env__', env: {VECTOR_ENGINE_BASE_URL: 'https://relay.invalid', VECTOR_ENGINE_API_KEY: 'secret', SPINE_UNDERSTANDING_MODEL: 'env-vision', SPINE_COMPLETION_MODEL: 'env-image'}}); + assert.equal(resolveStage(loaded, 'understanding').model, 'env-vision'); + loaded.config.stages.understanding.model = 'config-vision'; + assert.equal(resolveStage(loaded, 'understanding').model, 'config-vision'); + assert.equal(resolveStage(loaded, 'understanding', {model: 'cli-vision'}).model, 'cli-vision'); + assert.equal(resolveStage(loaded, 'understanding', {stream: false}).stream, false); + assert.equal(resolveStage(loaded, 'completion').model, 'env-image'); + assert.equal(resolveStage(loaded, 'completion', {model: 'gpt-image-2.5'}).model, 'gpt-image-2.5'); + assert.ok(!JSON.stringify(publicStage(resolveStage(loaded, 'understanding'))).includes('secret')); + loaded.config.stages.understanding.params = {model: 'sneaky'}; + assert.throws(() => resolveStage(loaded, 'understanding'), /保留字段/u); +}); + +test('Chat and Responses payloads retain exact model/image/schema and parse structured text', () => { + const stage = stageFor(); + stage.outputMode = 'json-schema'; + let request = buildVisionRequest(stage, image, partsSchema); + assert.equal(request.route, 'chat/completions'); + assert.equal(request.body.model, 'gpt-6-astra'); + assert.ok(request.body.messages[0].content[1].image_url.url.startsWith('data:image/png;base64,')); + assert.equal(request.body.response_format.json_schema.schema, partsSchema); + stage.protocol = 'responses'; + request = buildVisionRequest(stage, image, partsSchema); + assert.equal(request.route, 'responses'); + assert.equal(request.body.text.format.type, 'json_schema'); + assert.equal(request.body.input[0].content[1].type, 'input_image'); + assert.equal(extractVisionText({status: 'completed', output: [{type: 'message', content: [{type: 'output_text', text: '{}'}]}]}, 'responses'), '{}'); + assert.throws(() => extractVisionText({status: 'incomplete', output_text: '{}'}, 'responses'), /状态/u); + assert.throws(() => extractVisionText({choices: [{finish_reason: 'length', message: {content: '{}'}}]}, 'chat-completions'), /未完整/u); +}); + +test('schema + semantic validation reject invalid coordinates, references and both graph cycles', () => { + assert.equal(validateParts(goodParts()).valid, true); + for (const mutate of [ + (d) => { d.parts[0].bbox_norm_xyxy = [0.9, 0, 0.1, 1]; }, + (d) => { d.parts[0].confidence = 2; }, + (d) => { d.parts[0].parent = 'missing'; }, + (d) => { d.parts[0].parent = 'head'; }, + (d) => { d.parts[0].occluded_by = ['head']; d.parts[1].occluded_by = ['torso']; }, + (d) => { d.parts[1].id = 'torso'; }, + (d) => { d.parts[0].visibility = 'hidden'; }, + (d) => { d.parts[0].positive_points_norm = [[0, 0]]; }, + (d) => { d.parts[0].unknown = 1; }, + ]) { + const data = goodParts(); mutate(data); + assert.equal(validateParts(data).valid, false); + } +}); + +test('SSE requires terminal completion, supports Responses and Chat, rejects truncated/error streams', async () => { + const payload = {status: 'completed', model: 'gpt-6-astra', output: [{content: [{type: 'output_text', text: '{}'}]}]}; + const stream = `event: response.completed\r\ndata: ${JSON.stringify({type: 'response.completed', response: payload})}\r\n\r\ndata: [DONE]\r\n\r\n`; + assert.deepEqual(parseEventStream(stream), payload); + const result = await requestJson(stageFor(), 'responses', () => ({method: 'POST'}), {fetchImpl: async () => new Response(stream, {headers: {'content-type': 'text/event-stream'}})}); + assert.equal(result.metadata.wire_format, 'sse'); + assert.equal(extractVisionText(result.payload, 'responses'), '{}'); + assert.throws(() => parseEventStream('data: {"type":"response.output_text.delta","delta":"{}"}\n\ndata: [DONE]\n\n'), /终止事件/u); + assert.throws(() => parseEventStream('data: {"type":"error","message":"bad"}\n\n'), /bad/u); + const chatStream = 'data: {"model":"chat","choices":[{"index":0,"delta":{"content":"{}"},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n'; + assert.equal(extractVisionText(parseEventStream(chatStream), 'chat-completions'), '{}'); +}); + +test('HTTP retries explicit 429/5xx, not 400 or network errors; credentials redacted', async () => { + const stage = stageFor(); + let count = 0; + const result = await requestJson(stage, 'models', () => ({method: 'GET'}), { + fetchImpl: async (url, options) => { + assert.equal(url, 'https://relay.invalid/v1/models'); + assert.equal(options.headers.Authorization, 'Bearer test-secret'); + count += 1; + return count < 3 ? jsonResponse({error: {message: 'retry'}}, count === 1 ? 429 : 500) : jsonResponse({data: []}); + }, sleep: async () => {}, + }); + assert.equal(result.metadata.attempts, 3); + count = 0; + await assert.rejects(requestJson(stage, 'models', () => ({method: 'GET'}), {fetchImpl: async () => { count += 1; return jsonResponse({error: {message: 'test-secret'}}, 400); }}), (error) => !error.message.includes('test-secret')); + assert.equal(count, 1); + count = 0; + await assert.rejects(requestJson(stage, 'models', () => ({method: 'GET'}), {fetchImpl: async () => { count += 1; throw new Error('network'); }}), /network/u); + assert.equal(count, 1); +}); + +test('image adapter sends multipart, blocks unconfirmed capabilities, decodes inline and downloaded images', async () => { + const stage = stageFor('completion'); + assert.throws(() => validateEditInputs(stage, [image, image]), /多图/u); + assert.throws(() => validateEditInputs(stage, [image], image), /掩码/u); + let captured; + await editImage(stage, {images: [image], prompt: 'test'}, {fetchImpl: async (url, request) => { + captured = request; + assert.ok(url.endsWith('/images/edits')); + return jsonResponse({data: [{b64_json: image.bytes.toString('base64')}]}); + }}); + assert.equal(captured.body.get('model'), 'qwen-image-3.0'); + assert.equal(captured.body.get('n'), '1'); + assert.equal(captured.body.get('image').type, 'image/png'); + assert.equal(captured.headers['Content-Type'], undefined); + const decoded = await decodeEditedImages({data: [{b64_json: image.bytes.toString('base64')}, {url: 'https://cdn.invalid/output.png'}]}, stage, {fetchImpl: async (url, request) => { + assert.equal(request.headers, undefined); + return new Response(image.bytes); + }}); + assert.equal(decoded.length, 2); + assert.equal(decoded[0].width, 128); + await assert.rejects(decodeEditedImages({data: [{b64_json: Buffer.from('error').toString('base64')}]}, stage), /图片数据/u); + await assert.rejects(decodeEditedImages({data: [{url: 'https://cdn.invalid/404'}]}, stage, {fetchImpl: async () => new Response('', {status: 404})}), /404/u); +}); + +test('understanding run saves report and provenance; resume checks inputs/model/artifacts', async (t) => { + const dir = temporary(t); + const stage = stageFor(); + const loaded = loadConfig({envFile: '__no_test_env__', env: {}}); + const outDir = path.join(dir, 'run'); + const options = {loaded, stage, reference, outDir}; + let calls = 0; + const transport = {fetchImpl: async () => { calls += 1; return jsonResponse(chatPayload()); }}; + await runUnderstanding(options, transport); + const manifest = JSON.parse(readFileSync(path.join(outDir, 'manifest.json'))); + assert.equal(manifest.status, 'completed'); + assert.equal(manifest.quality_status, 'needs_review'); + assert.equal(manifest.request.returned_model, 'gpt-6-astra'); + assert.equal(manifest.inputs[0].width, 128); + assert.match(readFileSync(path.join(outDir, 'report.html'), 'utf8'), /尚未执行 SAM/u); + await assert.rejects(runUnderstanding(options, transport), /非空/u); + assert.equal((await runUnderstanding({...options, resume: true}, transport)).reused, true); + assert.equal(calls, 1); + await assert.rejects(runUnderstanding({...options, stage: {...stage, model: 'another-model'}, resume: true}, transport), /变化/u); + writeFileSync(path.join(outDir, 'semantic-parts.json'), '{}'); + await assert.rejects(runUnderstanding({...options, resume: true}, transport), /被修改/u); +}); + +test('invalid/refused/truncated understanding stays failed, preserving raw responses', async (t) => { + const dir = temporary(t); + const loaded = loadConfig({envFile: '__no_test_env__', env: {}}); + const invalid = chatPayload(); invalid.choices[0].message.content = 'not json'; + const refused = {choices: [{message: {refusal: 'no'}}]}; + for (const [i, payload] of [invalid, refused].entries()) { + const outDir = path.join(dir, `failed-${i}`); + await assert.rejects(runUnderstanding({loaded, stage: stageFor(), reference, outDir}, {fetchImpl: async () => jsonResponse(payload)}), /校验/u); + assert.equal(JSON.parse(readFileSync(path.join(outDir, 'manifest.json'))).status, 'failed'); + assert.deepEqual(JSON.parse(readFileSync(path.join(outDir, 'response.raw.json'))), payload); + assert.equal(JSON.parse(readFileSync(path.join(outDir, 'validation.json'))).valid, false); + } +}); + +test('SSE overload keeps redacted diagnostics and a failed manifest', async (t) => { + const outDir = path.join(temporary(t), 'overloaded'); + const loaded = loadConfig({envFile: '__no_test_env__', env: {}}); + await assert.rejects(runUnderstanding({loaded, stage: stageFor(), reference, outDir}, {fetchImpl: async () => new Response('data: {"type":"error","message":"overloaded test-secret"}\n\n', {headers: {'content-type': 'text/event-stream'}})}), /overloaded/u); + const diagnostic = readFileSync(path.join(outDir, 'response.error.json'), 'utf8'); + assert.ok(!diagnostic.includes('test-secret')); + assert.match(diagnostic, /REDACTED/u); + assert.equal(JSON.parse(diagnostic).http_status, 200); + assert.equal(JSON.parse(readFileSync(path.join(outDir, 'manifest.json'))).status, 'failed'); +}); + +test('completion probe produces raw candidates and never claims final assets', async (t) => { + const dir = temporary(t); + const promptFile = path.join(dir, 'task.txt'); writeFileSync(promptFile, 'Complete this sleeve behind the torso.'); + const outDir = path.join(dir, 'edit'); + await runCompletionProbe({stage: stageFor('completion'), imagePaths: [reference], promptFile, outDir}, {fetchImpl: async () => jsonResponse({model: 'qwen-image-3.0', data: [{b64_json: image.bytes.toString('base64')}]})}); + const manifest = JSON.parse(readFileSync(path.join(outDir, 'manifest.json'))); + assert.equal(manifest.output_kind, 'raw_completion_probe'); + assert.equal(manifest.quality_status, 'needs_review'); + assert.equal(manifest.outputs[0].canvas_size_matches, true); + assert.deepEqual(readFileSync(path.join(outDir, 'candidate-1.png')), image.bytes); +}); + +test('pipeline new mode reaches a mock relay without Python; model listing and unsupported stages', async (t) => { + const dir = temporary(t); + let requests = 0; + const server = createServer(async (req, res) => { + requests += 1; + const chunks = []; for await (const chunk of req) chunks.push(chunk); + const body = Buffer.concat(chunks).toString(); + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/v1/models') res.end(JSON.stringify({data: [{id: 'relay-vision-test'}]})); + else if (req.url === '/v1/responses' && JSON.parse(body).model === 'relay-vision-test') res.end(JSON.stringify({model: 'relay-vision-test', status: 'completed', output: [{type: 'message', content: [{type: 'output_text', text: JSON.stringify(goodParts())}]}]})); + else { res.statusCode = 400; res.end('{}'); } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const env = {...process.env, VECTOR_ENGINE_BASE_URL: `http://127.0.0.1:${server.address().port}`, VECTOR_ENGINE_API_KEY: 'mock-key'}; + const outDir = path.join(dir, 'cli'); + const result = await exec(process.execPath, ['pipeline.mjs', '--mode', 'annotated-parts', '--reference', reference, '--out-dir', outDir, '--stop-after', 'understand', '--understanding-model', 'relay-vision-test', '--python', 'deliberately-nonexistent-python'], {cwd: projectRoot, env}); + assert.match(result.stdout, /结构理解完成/u); + const list = await exec(process.execPath, ['tools/model-check.mjs', '--list-models'], {cwd: projectRoot, env}); + assert.match(list.stdout, /relay-vision-test/u); + assert.equal(requests, 2); + await assert.rejects(exec(process.execPath, ['pipeline.mjs', '--mode', 'annotated-parts', '--stop-after', 'segment'], {cwd: projectRoot, env}), (error) => /仅支持 understand/u.test(error.stderr)); + assert.equal(requests, 2); +}); diff --git a/tools/model-check.mjs b/tools/model-check.mjs new file mode 100644 index 0000000..9206935 --- /dev/null +++ b/tools/model-check.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {loadConfig, resolveStage, publicStage, samInfo} from '../lib/config.mjs'; +import {requestJson} from '../providers/http.mjs'; +import {runUnderstanding, runCompletionProbe} from '../stages/model-stage.mjs'; +import {readImage} from '../lib/images.mjs'; + +const help = `模型接入与结构理解(新增模式,不调用旧拆件流程) + node pipeline.mjs --mode annotated-parts --reference image.png --out-dir output/run --stop-after understand + node tools/model-check.mjs --stage completion --image local.png --prompt-file task.txt --out-dir output/edit + node tools/model-check.mjs --list-models + node tools/model-check.mjs --dry-run + +公共选项: + --config FILE 默认 configs/astra-qwen.json(prompt 相对配置文件) + --env-file FILE 默认项目 .env,shell 环境变量优先 + --understanding-model ID 理解模型覆盖(使用原样 ID,不自动映射) + --completion-model ID 图片编辑模型覆盖 + --understanding-protocol NAME chat-completions / responses + --stream true|false 覆盖理解阶段的流式设置 + --output-mode NAME text-json / json-object / json-schema + --resume 输入/配置/代码和产物哈希相同时复用 + --image FILE 图片编辑输入,可重复;多图需配置 capabilities + --mask FILE 透明处可编辑的 RGBA PNG;需配置 capabilities + --dry-run 检查配置与本地输入,不请求模型、不生成产物 + +本轮 --stop-after 仅支持 understand。SAM 路径已配置,但分割/合并/导出尚未接入。`; + +export function parseOptions(argv) { + const options = {stage: 'understanding', imagePaths: []}; + const valueFlags = new Map([ + ['--config', 'configFile'], ['--env-file', 'envFile'], ['--reference', 'reference'], ['--out-dir', 'outDir'], + ['--understanding-model', 'understandingModel'], ['--completion-model', 'completionModel'], + ['--understanding-protocol', 'understandingProtocol'], ['--output-mode', 'outputMode'], + ['--stage', 'stage'], ['--mask', 'maskPath'], ['--prompt-file', 'promptFile'], ['--stop-after', 'stopAfter'], + ['--mode', 'mode'], ['--python', 'python'], ['--stream', 'streamRaw'], + ]); + for (let i = 0; i < argv.length; i += 1) { + const flag = argv[i]; + if (['--help', '-h'].includes(flag)) options.help = true; + else if (flag === '--resume') options.resume = true; + else if (flag === '--dry-run') options.dryRun = true; + else if (flag === '--list-models') options.listModels = true; + else if (valueFlags.has(flag) || flag === '--image') { + const value = argv[++i]; + if (!value || value.startsWith('--')) throw new Error(`${flag} 缺少参数值`); + if (flag === '--image') options.imagePaths.push(value); + else options[valueFlags.get(flag)] = value; + } else throw new Error(`未知参数:${flag},请用 --help 查看`); + } + return options; +} + +export async function main(argv = process.argv.slice(2)) { + const options = parseOptions(argv); + if (options.help) { console.log(help); return; } + if (!['understanding', 'completion'].includes(options.stage)) throw new Error('--stage 仅支持 understanding 或 completion'); + if (options.streamRaw !== undefined && !['true', 'false'].includes(options.streamRaw)) throw new Error('--stream 仅支持 true 或 false'); + options.stream = options.streamRaw === undefined ? undefined : options.streamRaw === 'true'; + if (options.mode && options.mode !== 'annotated-parts') throw new Error('新入口仅支持 --mode annotated-parts'); + if (options.stopAfter && (options.stopAfter !== 'understand' || options.stage !== 'understanding')) { + throw new Error('本轮仅落地模型接入和结构理解;--stop-after 目前仅支持 understand'); + } + const loaded = loadConfig(options); + const overrides = options.stage === 'understanding' + ? {model: options.understandingModel, protocol: options.understandingProtocol, outputMode: options.outputMode, stream: options.stream} + : {model: options.completionModel}; + const stage = resolveStage(loaded, options.stage, overrides, {requireKey: !options.dryRun}); + if (options.listModels) { + if (options.dryRun) throw new Error('--list-models 会请求模型列表,不能与 --dry-run 同时使用'); + const {payload} = await requestJson(stage, 'models', () => ({method: 'GET'})); + if (!Array.isArray(payload.data)) throw new Error('模型列表响应缺少 data 数组,请核对中转站协议'); + console.log(payload.data.map((item) => item.id).filter((id) => typeof id === 'string').join('\n')); + return; + } + if (options.dryRun) { + const understanding = resolveStage(loaded, 'understanding', {model: options.understandingModel, protocol: options.understandingProtocol, outputMode: options.outputMode, stream: options.stream}, {requireKey: false}); + const completion = resolveStage(loaded, 'completion', {model: options.completionModel}, {requireKey: false}); + if (options.reference) readImage(options.reference, {pngOnly: true}); + for (const file of options.imagePaths) readImage(file, {pngOnly: true}); + console.log(JSON.stringify({understanding: publicStage(understanding), completion: publicStage(completion), sam: samInfo(loaded), credentials_present: Boolean(stage.apiKey && stage.baseUrl), network_requested: false}, null, 2)); + return; + } + if (!options.outDir) throw new Error('需要 --out-dir,建议每个实验使用独立目录'); + let result; + if (options.stage === 'understanding') { + if (!options.reference) throw new Error('结构理解需要 --reference'); + if (options.imagePaths.length || options.maskPath || options.promptFile) throw new Error('结构理解不接受图片编辑参数'); + console.log(`角色结构理解:${stage.model} (${stage.protocol})`); + result = await runUnderstanding({...options, loaded, stage}); + console.log(`${result.reused ? '复用已校验产物' : '结构理解完成,语义待复核'}:${path.join(result.out, 'report.html')}`); + } else { + if (options.reference) throw new Error('图片编辑使用 --image 指定局部画布;额外参考图也通过重复 --image 指定'); + console.log(`图片编辑接入测试:${stage.model}`); + result = await runCompletionProbe({...options, stage}); + console.log(`${result.reused ? '复用已校验产物' : '原始候选已保存,尚未生成最终部件'}:${result.out}`); + } + return result; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { console.error(`模型阶段失败:${error.message}`); process.exitCode = 1; }); +}