fix: send VectorEngine edit images as file parts
This commit is contained in:
@@ -1749,6 +1749,12 @@
|
|||||||
- 症状:release 部署新 `api-server` 后服务反复 `exit-code`,`LD_TRACE_LOADED_OBJECTS=1 /opt/genarrative/current/api-server` 或 `ldd` 报 `/lib/x86_64-linux-gnu/libssl.so.3: version 'OPENSSL_3.2.0' not found`。
|
- 症状:release 部署新 `api-server` 后服务反复 `exit-code`,`LD_TRACE_LOADED_OBJECTS=1 /opt/genarrative/current/api-server` 或 `ldd` 报 `/lib/x86_64-linux-gnu/libssl.so.3: version 'OPENSSL_3.2.0' not found`。
|
||||||
- 根因:`platform-image` 使用 `libcurl` 后,Linux release 构建产物可能直接要求 `OPENSSL_3.2.0` 符号;Ubuntu 24.04 apt 默认 OpenSSL 仍是 `3.0.13`,不能满足该符号版本。
|
- 根因:`platform-image` 使用 `libcurl` 后,Linux release 构建产物可能直接要求 `OPENSSL_3.2.0` 符号;Ubuntu 24.04 apt 默认 OpenSSL 仍是 `3.0.13`,不能满足该符号版本。
|
||||||
- 处理:`Genarrative-Server-Provision` 独立安装 OpenSSL `3.2.0` 到 `/opt/genarrative/openssl-3.2.0`,并只通过 `genarrative-api.service` 的 `LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 给 api-server 使用,避免替换系统 OpenSSL。
|
- 处理:`Genarrative-Server-Provision` 独立安装 OpenSSL `3.2.0` 到 `/opt/genarrative/openssl-3.2.0`,并只通过 `genarrative-api.service` 的 `LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 给 api-server 使用,避免替换系统 OpenSSL。
|
||||||
|
|
||||||
|
### VectorEngine edits multipart image part
|
||||||
|
|
||||||
|
- 症状:拼图参考图链路请求 `/v1/images/edits` 返回 `500 image is required`,但应用日志里 `reference_image_count=1`、`reference_image_bytes_total>0`,`request_params.referenceImages[0]` 也有 `field=image`、文件名、MIME 和 bytes。
|
||||||
|
- 根因:Rust `curl::easy::Form` 中 `contents(...).filename(...)` 不等价于文件上传 part;VectorEngine 转码层会认为没有收到图片。release 上用 curl CLI `-F image=@file` 可成功,证明字段名和上游接口本身没变。
|
||||||
|
- 处理:multipart 参考图必须用 `Form::buffer(file_name, bytes)` 并设置 `content_type(...)`,让 libcurl 生成真正的 `name="image"; filename="..."` 文件 part。
|
||||||
- 验证:release 上先看 `journalctl -u genarrative-api.service` 中 `VectorEngine 图片请求发送失败,准备重试` 与最终 `HTTP 返回`;若仍失败,再用同一图片分别跑 curl 与最小 reqwest 探针对照。
|
- 验证:release 上先看 `journalctl -u genarrative-api.service` 中 `VectorEngine 图片请求发送失败,准备重试` 与最终 `HTTP 返回`;若仍失败,再用同一图片分别跑 curl 与最小 reqwest 探针对照。
|
||||||
- 关联:`server-rs/crates/platform-image/src/vector_engine/client.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
- 关联:`server-rs/crates/platform-image/src/vector_engine/client.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ npm run check:server-rs-ddd
|
|||||||
## 外部服务与资产
|
## 外部服务与资产
|
||||||
|
|
||||||
- LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。
|
- LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。
|
||||||
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。
|
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。
|
||||||
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
|
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
|
||||||
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
|
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
|
||||||
- Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
|
- Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
|
||||||
|
|||||||
@@ -234,9 +234,11 @@ fn send_multipart_edit_request_with_curl_blocking(
|
|||||||
|
|
||||||
for reference_image in reference_images {
|
for reference_image in reference_images {
|
||||||
form.part("image")
|
form.part("image")
|
||||||
.contents(reference_image.bytes.as_slice())
|
.buffer(
|
||||||
|
reference_image.file_name.as_str(),
|
||||||
|
reference_image.bytes.clone(),
|
||||||
|
)
|
||||||
.content_type(reference_image.mime_type.as_str())
|
.content_type(reference_image.mime_type.as_str())
|
||||||
.filename(reference_image.file_name.as_str())
|
|
||||||
.add()?;
|
.add()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,15 +277,15 @@ fn perform_curl_request(mut easy: Easy) -> Result<VectorEngineCurlResponse, curl
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::vector_engine::types::ReferenceImage;
|
use crate::vector_engine::types::ReferenceImage;
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::{
|
use tokio::{
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
net::TcpListener,
|
net::TcpListener,
|
||||||
|
sync::oneshot,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn vector_engine_curl_transport_posts_json_request() {
|
async fn vector_engine_curl_transport_posts_json_request() {
|
||||||
let (base_url, server) = start_single_response_server().await;
|
let (base_url, server, request_rx) = start_single_response_server().await;
|
||||||
let response = send_vector_engine_json_request_with_curl(
|
let response = send_vector_engine_json_request_with_curl(
|
||||||
format!("{base_url}/v1/images/generations").as_str(),
|
format!("{base_url}/v1/images/generations").as_str(),
|
||||||
"test-key",
|
"test-key",
|
||||||
@@ -295,12 +297,17 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(response.status, 200);
|
assert_eq!(response.status, 200);
|
||||||
assert_eq!(response.body, "{\"data\":[]}");
|
assert_eq!(response.body, "{\"data\":[]}");
|
||||||
|
let request = request_rx
|
||||||
|
.await
|
||||||
|
.expect("mock server should capture request");
|
||||||
|
let request_text = String::from_utf8_lossy(request.as_slice());
|
||||||
|
assert!(request_text.contains("Content-Type: application/json"));
|
||||||
server.abort();
|
server.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn vector_engine_curl_transport_posts_multipart_request() {
|
async fn vector_engine_curl_transport_posts_multipart_request() {
|
||||||
let (base_url, server) = start_single_response_server().await;
|
let (base_url, server, request_rx) = start_single_response_server().await;
|
||||||
let response = send_vector_engine_multipart_edit_request_with_curl(
|
let response = send_vector_engine_multipart_edit_request_with_curl(
|
||||||
format!("{base_url}/v1/images/edits").as_str(),
|
format!("{base_url}/v1/images/edits").as_str(),
|
||||||
"test-key",
|
"test-key",
|
||||||
@@ -320,16 +327,28 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(response.status, 200);
|
assert_eq!(response.status, 200);
|
||||||
assert_eq!(response.body, "{\"data\":[]}");
|
assert_eq!(response.body, "{\"data\":[]}");
|
||||||
|
let request = request_rx
|
||||||
|
.await
|
||||||
|
.expect("mock server should capture request");
|
||||||
|
let request_text = String::from_utf8_lossy(request.as_slice());
|
||||||
|
assert!(request_text.contains("name=\"image\"; filename=\"reference.png\""));
|
||||||
|
assert!(request_text.contains("Content-Type: image/png"));
|
||||||
|
assert!(request_text.contains("reference"));
|
||||||
server.abort();
|
server.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_single_response_server() -> (String, tokio::task::JoinHandle<()>) {
|
async fn start_single_response_server() -> (
|
||||||
|
String,
|
||||||
|
tokio::task::JoinHandle<()>,
|
||||||
|
oneshot::Receiver<Vec<u8>>,
|
||||||
|
) {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0")
|
let listener = TcpListener::bind("127.0.0.1:0")
|
||||||
.await
|
.await
|
||||||
.expect("mock server should bind");
|
.expect("mock server should bind");
|
||||||
let addr = listener
|
let addr = listener
|
||||||
.local_addr()
|
.local_addr()
|
||||||
.expect("mock server addr should be readable");
|
.expect("mock server addr should be readable");
|
||||||
|
let (request_tx, request_rx) = oneshot::channel();
|
||||||
let server = tokio::spawn(async move {
|
let server = tokio::spawn(async move {
|
||||||
let Ok((mut stream, _)) = listener.accept().await else {
|
let Ok((mut stream, _)) = listener.accept().await else {
|
||||||
return;
|
return;
|
||||||
@@ -348,7 +367,31 @@ mod tests {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
let header_end = request
|
||||||
|
.windows(4)
|
||||||
|
.position(|window| window == b"\r\n\r\n")
|
||||||
|
.map(|index| index + 4)
|
||||||
|
.unwrap_or(request.len());
|
||||||
|
let headers = String::from_utf8_lossy(&request[..header_end]);
|
||||||
|
let content_length = headers
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| {
|
||||||
|
line.strip_prefix("Content-Length:")
|
||||||
|
.or_else(|| line.strip_prefix("content-length:"))
|
||||||
|
})
|
||||||
|
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let expected_len = header_end + content_length;
|
||||||
|
while request.len() < expected_len {
|
||||||
|
let Ok(read) = stream.read(&mut buffer).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
request.extend_from_slice(&buffer[..read]);
|
||||||
|
}
|
||||||
|
let _ = request_tx.send(request);
|
||||||
let body = "{\"data\":[]}";
|
let body = "{\"data\":[]}";
|
||||||
let response = format!(
|
let response = format!(
|
||||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
@@ -358,6 +401,6 @@ mod tests {
|
|||||||
let _ = stream.write_all(response.as_bytes()).await;
|
let _ = stream.write_all(response.as_bytes()).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
(format!("http://{addr}"), server)
|
(format!("http://{addr}"), server, request_rx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user