fix(provider): 火山方舟图片 - 关闭水印并持久化 CDN 结果 (#241)
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 火山方舟 Seedream 图片生成显式关闭“AI 生成”水印,并由后端下载方舟 CDN 结果后写入服务器或对象存储,恢复素材库自动入库。
|
||||
- 项目开源协议由 AGPL-3.0 调整为 MIT,保留上游及项目贡献者版权声明。
|
||||
- 修复任务中心等资源入口仍由应用服务器转发对象存储媒体的问题:配置 CDN 加速域名后,登录资源、公开画布分享和管理员媒体预览统一重定向到 CDN;内部 Blob 读取保留同源代理兼容。
|
||||
|
||||
|
||||
@@ -881,18 +881,49 @@ func runVolcengineArkImageTask(ctx context.Context, input canvasGenerationInput)
|
||||
if err := postJSON(ctx, input.Config, "/images/generations", body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
images, err := imageDataURLs(payload)
|
||||
images, err := volcengineArkImageDataURLs(ctx, input.Config, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"mode": "image", "images": images}, nil
|
||||
}
|
||||
|
||||
func volcengineArkImageDataURLs(ctx context.Context, config providerConfig, payload imageResponse) ([]map[string]string, error) {
|
||||
images, err := imageDataURLs(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, image := range images {
|
||||
value := strings.TrimSpace(image["dataUrl"])
|
||||
if strings.HasPrefix(value, "data:image/") {
|
||||
continue
|
||||
}
|
||||
if !isPublicMediaURL(value) {
|
||||
return nil, errors.New("火山方舟图片接口没有返回可下载的图片")
|
||||
}
|
||||
// 方舟默认返回临时 CDN 地址。必须由后端下载成内联结果,后续资源持久化才能
|
||||
// 原子地写入服务器或用户配置的对象存储,且不依赖浏览器跨域访问方舟 CDN。
|
||||
data, mimeType, err := getProviderExternalBinary(withProviderRequestKind(ctx, "download"), config, value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("火山方舟图片结果下载失败:%w", err)
|
||||
}
|
||||
detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]))
|
||||
mimeType = strings.ToLower(normalizedMediaMimeType(mimeType, data))
|
||||
if len(data) == 0 || strings.Contains(detected, "json") || strings.HasPrefix(detected, "text/") || !strings.HasPrefix(mimeType, "image/") {
|
||||
return nil, fmt.Errorf("火山方舟图片结果无效:%s", defaultString(detected, mimeType))
|
||||
}
|
||||
image["dataUrl"] = dataURL(mimeType, data)
|
||||
image["mimeType"] = mimeType
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func volcengineArkImageBody(input canvasGenerationInput) (map[string]interface{}, error) {
|
||||
body := map[string]interface{}{
|
||||
"model": input.Config.Model,
|
||||
"prompt": withSystemPrompt(input.Config, input.Prompt),
|
||||
"n": 1,
|
||||
"model": input.Config.Model,
|
||||
"prompt": withSystemPrompt(input.Config, input.Prompt),
|
||||
"n": 1,
|
||||
"watermark": false,
|
||||
}
|
||||
if key, value := imageSizeParameter(input.ImageCapability, input.Config.Size); value != "" {
|
||||
if key == "size" {
|
||||
|
||||
@@ -151,6 +151,9 @@ func TestVolcengineArkImageBodyUsesJSONReferencesAndDownscalesSize(t *testing.T)
|
||||
if body["prompt"] != "keep the subject\n\ncombine the references" {
|
||||
t.Fatalf("prompt = %q", body["prompt"])
|
||||
}
|
||||
if watermark, ok := body["watermark"].(bool); !ok || watermark {
|
||||
t.Fatalf("watermark = %#v, want false", body["watermark"])
|
||||
}
|
||||
size, _ := body["size"].(string)
|
||||
parts := strings.Split(size, "x")
|
||||
if len(parts) != 2 {
|
||||
@@ -184,6 +187,40 @@ func TestVolcengineArkImageBodyUpscalesPresetBelowMinimumPixels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcengineArkImageDataURLsDownloadsRemoteResult(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
imageBytes := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(imageBytes)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
images, err := volcengineArkImageDataURLs(context.Background(), providerConfig{}, imageResponse{
|
||||
Data: []map[string]interface{}{{"url": server.URL + "/generated.png"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("volcengineArkImageDataURLs() error = %v", err)
|
||||
}
|
||||
if len(images) != 1 || !strings.HasPrefix(images[0]["dataUrl"], "data:image/png;base64,") {
|
||||
t.Fatalf("images = %#v", images)
|
||||
}
|
||||
|
||||
svc := newResourceTestService(t)
|
||||
stored, err := svc.persistGeneratedMediaResult("user-1", map[string]interface{}{"mode": "image", "images": images})
|
||||
if err != nil {
|
||||
t.Fatalf("persistGeneratedMediaResult() error = %v", err)
|
||||
}
|
||||
storedImages, ok := stored["images"].([]interface{})
|
||||
if !ok || len(storedImages) != 1 {
|
||||
t.Fatalf("stored images = %#v", stored["images"])
|
||||
}
|
||||
storedImage, ok := storedImages[0].(map[string]interface{})
|
||||
if !ok || !strings.HasPrefix(stringField(storedImage, "storageKey"), "resource:") || !strings.HasPrefix(stringField(storedImage, "dataUrl"), "/api/resources/") {
|
||||
t.Fatalf("stored image = %#v", storedImages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcengineArkImageRejectsMaskBeforeRequest(t *testing.T) {
|
||||
_, err := runImageTask(context.Background(), canvasGenerationInput{
|
||||
Prompt: "edit only the masked area",
|
||||
|
||||
@@ -886,6 +886,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
|
||||
model: requestConfig.model,
|
||||
prompt: withSystemPrompt(requestConfig, prompt),
|
||||
n,
|
||||
watermark: false,
|
||||
...(normalizedRequestSize ? { [normalizedRequestSize.parameter]: normalizedRequestSize.value } : {}),
|
||||
}
|
||||
: {
|
||||
@@ -980,6 +981,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
model: requestConfig.model,
|
||||
prompt: withSystemPrompt(requestConfig, requestPrompt),
|
||||
n,
|
||||
watermark: false,
|
||||
...(requestSize ? { [requestSize.parameter]: requestSize.value } : {}),
|
||||
...(images.length === 1 ? { image: images[0] } : images.length > 1 ? { image: images } : {}),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user