fix(provider): 火山方舟图片 - 关闭水印并持久化 CDN 结果 (#241)

This commit is contained in:
MasterChiefCN
2026-08-17 07:51:11 +08:00
committed by GitHub
parent 4285500802
commit d5e22a8c49
4 changed files with 75 additions and 4 deletions

View File

@@ -2,6 +2,7 @@
## Unreleased
- 火山方舟 Seedream 图片生成显式关闭“AI 生成”水印,并由后端下载方舟 CDN 结果后写入服务器或对象存储,恢复素材库自动入库。
- 项目开源协议由 AGPL-3.0 调整为 MIT,保留上游及项目贡献者版权声明。
- 修复任务中心等资源入口仍由应用服务器转发对象存储媒体的问题:配置 CDN 加速域名后,登录资源、公开画布分享和管理员媒体预览统一重定向到 CDN;内部 Blob 读取保留同源代理兼容。

View File

@@ -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" {

View File

@@ -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",

View File

@@ -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 } : {}),
},