接入画板生成视频功能

新增画板底部生成视频入口、Lovart 风格面板、视频图层渲染与元数据展示。

接入 /api/editor/videos/generations 契约与后端 Ark/VectorEngine 视频任务链路。

统一编辑器生成类泥点配置,并补充 UI 设计图、参考图与生成面板结构测试。

更新编辑器技术方案、生成类面板方案和 Hermes 共享决策/踩坑记录。
This commit is contained in:
2026-06-17 20:47:27 +08:00
parent d1cd300695
commit b2fd5574db
39 changed files with 3390 additions and 238 deletions

View File

@@ -1,9 +1,11 @@
use platform_image::vector_engine::{
GPT_IMAGE_2_MODEL, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings,
build_vector_engine_image_http_client, build_vector_engine_image_request_body,
build_vector_engine_image_request_body_with_model, create_vector_engine_image_edit,
create_vector_engine_image_generation,
build_vector_engine_image_request_body_with_model,
build_vector_engine_nanobanana_generate_content_request_body, create_vector_engine_image_edit,
create_vector_engine_image_generation, create_vector_engine_nanobanana_generate_content,
vector_engine_images_edit_url, vector_engine_images_generation_url,
vector_engine_nanobanana_generate_content_url,
};
use std::{
sync::{
@@ -69,6 +71,60 @@ fn vector_engine_request_body_can_use_nanobanana2_model() {
assert_eq!(body["n"], 1);
}
#[test]
fn vector_engine_request_body_can_use_nanobanana2_half_k() {
let body = build_vector_engine_image_request_body_with_model(
"gemini-3.1-flash-image-preview",
"生成图标 spritesheet",
None,
"512",
1,
&[],
);
assert_eq!(body["model"], "gemini-3.1-flash-image-preview");
assert_eq!(body["size"], "512");
}
#[test]
fn nanobanana_generate_content_body_carries_aspect_ratio_and_image_size() {
let body = build_vector_engine_nanobanana_generate_content_request_body(
"生成角色图",
Some("文字、水印"),
"2:3",
"512",
&[],
);
assert_eq!(body["contents"][0]["role"], "user");
assert_eq!(
body["contents"][0]["parts"][0]["text"],
"生成角色图\n避免:文字、水印"
);
assert_eq!(body["generationConfig"]["responseModalities"][0], "IMAGE");
assert_eq!(
body["generationConfig"]["imageConfig"]["aspectRatio"],
"2:3"
);
assert_eq!(body["generationConfig"]["imageConfig"]["imageSize"], "512");
assert!(body.get("model").is_none());
assert!(body.get("n").is_none());
}
#[test]
fn nanobanana_generate_content_url_uses_model_path() {
let settings = VectorEngineImageSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000,
};
assert_eq!(
vector_engine_nanobanana_generate_content_url(&settings, "gemini-3.1-flash-image-preview"),
"https://vector.example/v1beta/models/gemini-3.1-flash-image-preview:generateContent"
);
}
#[tokio::test]
async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() {
let listener = TcpListener::bind("127.0.0.1:0")
@@ -136,6 +192,73 @@ async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() {
server.abort();
}
#[tokio::test]
async fn nanobanana_generate_content_posts_native_body_and_reads_inline_data() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("mock server should bind");
let server_addr = listener
.local_addr()
.expect("mock server address should be readable");
let server = tokio::spawn(async move {
let Ok((mut stream, _)) = listener.accept().await else {
return;
};
let mut request = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let Ok(read) = stream.read(&mut buffer).await else {
return;
};
if read == 0 {
return;
}
request.extend_from_slice(&buffer[..read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let request_text = String::from_utf8_lossy(request.as_slice());
assert!(
request_text.contains("/v1beta/models/gemini-3.1-flash-image-preview:generateContent")
);
assert!(request_text.contains("\"aspectRatio\":\"2:3\""));
assert!(request_text.contains("\"imageSize\":\"512\""));
let body = r#"{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgpyZXN0"}}]}}]}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(response.as_bytes()).await;
});
let settings = VectorEngineImageSettings {
base_url: format!("http://{}", server_addr),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000,
};
let client = build_vector_engine_image_http_client(&settings).expect("client should build");
let generated = create_vector_engine_nanobanana_generate_content(
&client,
&settings,
"gemini-3.1-flash-image-preview",
"生成角色图",
Some("文字、水印"),
"2:3",
"512",
&[],
"测试 nanobanana",
)
.await
.expect("nanobanana response should parse");
assert_eq!(generated.images.len(), 1);
assert_eq!(generated.images[0].mime_type, "image/png");
server.abort();
}
#[tokio::test]
async fn vector_engine_image_generation_retries_upstream_502_once_and_succeeds() {
let listener = TcpListener::bind("127.0.0.1:0")