新方案v1角色结构理解

This commit is contained in:
Ukawka
2026-09-17 17:44:24 +08:00
parent 2e79bc4a12
commit 30e82af4f1
21 changed files with 1287 additions and 14 deletions
+8
View File
@@ -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
+13 -3
View File
@@ -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` 显式指定一个只在受控后端使用的环境文件。
+45
View File
@@ -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"
}
}
}
+45
View File
@@ -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"
}
}
}
+165
View File
@@ -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 请求/响应测试,本轮未执行真实图片编辑生成。此状态不等于视觉质量已验证。
+92
View File
@@ -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'};
}
+23
View File
@@ -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')}`; }
+30
View File
@@ -0,0 +1,30 @@
import {dataUrl} from './images.mjs';
const escape = (value) => String(value).replace(/[&<>"']/gu, (c) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[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 ? `<line x1="${px}" y1="${py}" x2="${parent.pivot_norm[0] * w}" y2="${parent.pivot_norm[1] * h}" stroke-dasharray="${stroke * 3}"/>` : '';
let box = '';
if (part.bbox_norm_xyxy) {
const [x0, y0, x1, y1] = part.bbox_norm_xyxy;
box = `<rect x="${x0 * w}" y="${y0 * h}" width="${(x1 - x0) * w}" height="${(y1 - y0) * h}"/>`;
}
const points = part.positive_points_norm.map(([x, y]) => `<circle cx="${x * w}" cy="${y * h}" r="${stroke * 1.5}" fill="${color}"/>`).join('');
return `<g fill="none" stroke="${color}" stroke-width="${stroke}">${line}${box}${points}<circle cx="${px}" cy="${py}" r="${stroke * 3}"/><text x="${Math.min(px + 4, w * 0.6)}" y="${Math.max(font, py - 5)}" fill="${color}" stroke="#111" stroke-width="${stroke / 2}" paint-order="stroke" font-size="${font}" font-family="sans-serif">${escape(part.id)}</text></g>`;
}).join('\n');
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="100%" height="100%" fill="#262632"/><image href="${dataUrl(image)}" width="${w}" height="${h}"/>${overlays}</svg>`;
}
export function understandingReport(image, data, validation, metadata) {
const svg = data && validation.valid ? annotatedSvg(image, data.parts) : `<img src="${dataUrl(image)}" alt="source">`;
const rows = data && validation.valid ? data.parts.map((part) => `<tr><td>${escape(part.id)}</td><td>${escape(part.parent ?? 'root')}</td><td>${escape(part.visibility)}</td><td>${escape(part.occluded_by.join(', '))}</td><td>${part.confidence}</td></tr>`).join('') : '';
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>角色结构理解检查</title><style>body{font:16px system-ui;max-width:1100px;margin:24px auto;padding:16px;background:#f5f5f5}svg,img{max-width:100%;height:auto;max-height:85vh}td,th{padding:8px;border:1px solid #ccc}table{border-collapse:collapse}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style><h1>角色结构理解</h1><p>此图仅包含模型预测的粗框、关节点(空心圆)和正点(实心圆),尚未执行 SAM 分割或补全。结构校验通过不代表语义正确。</p><p>状态:${validation.valid ? 'needs_review(待检查)' : 'failed'}</p>${svg}<h2>校验</h2><pre>${escape(JSON.stringify(validation, null, 2))}</pre><h2>部件</h2><table><tr><th>ID</th><th>父部件</th><th>可见性</th><th>遮挡物</th><th>自报置信度</th></tr>${rows}</table><h2>模型说明</h2><pre>${escape(JSON.stringify(data?.notes || [], null, 2))}</pre><h2>请求记录</h2><pre>${escape(JSON.stringify(metadata, null, 2))}</pre></html>`;
}
+34
View File
@@ -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);
}
+91
View File
@@ -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};
}
+6 -1
View File
@@ -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"}
}
+15 -10
View File
@@ -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);
+10
View File
@@ -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.
+20
View File
@@ -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.
+112
View File
@@ -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());
}
+45
View File
@@ -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;
}
+53
View File
@@ -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);
}
+30
View File
@@ -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}
}
}
}
}
}
+118
View File
@@ -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;
}
}
+228
View File
@@ -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('<html>error</html>').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);
});
+104
View File
@@ -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; });
}