diff --git a/backend/internal/service/canvas_share.go b/backend/internal/service/canvas_share.go index 10c71aa3e..0fdc88ac7 100644 --- a/backend/internal/service/canvas_share.go +++ b/backend/internal/service/canvas_share.go @@ -36,7 +36,7 @@ type PublicCanvasShare struct { var publicCanvasMetadataKeys = map[string]bool{ "content": true, "composerContent": true, "prompt": true, "status": true, "fontSize": true, - "generationMode": true, "generationType": true, "model": true, "size": true, "quality": true, + "generationMode": true, "generationType": true, "model": true, "size": true, "quality": true, "transparentBackground": true, "count": true, "seconds": true, "vquality": true, "generateAudio": true, "watermark": true, "audioVoice": true, "audioFormat": true, "audioSpeed": true, "audioInstructions": true, "naturalWidth": true, "naturalHeight": true, "freeResize": true, "isBatchRoot": true, diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 1bd99c47f..596af7742 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -31,24 +31,25 @@ type canvasGenerationInput struct { } type providerConfig struct { - ChannelID string `json:"channelId"` - APIFormat string `json:"apiFormat"` - InterfaceType string `json:"interfaceType"` - BaseURL string `json:"baseUrl"` - APIKey string `json:"apiKey"` - Model string `json:"model"` - Size string `json:"size"` - Quality string `json:"quality"` - Count string `json:"count"` - VideoSeconds string `json:"videoSeconds"` - VQuality string `json:"vquality"` - VideoGenerateAudio string `json:"videoGenerateAudio"` - VideoWatermark string `json:"videoWatermark"` - AudioVoice string `json:"audioVoice"` - AudioFormat string `json:"audioFormat"` - AudioSpeed string `json:"audioSpeed"` - AudioInstructions string `json:"audioInstructions"` - SystemPrompt string `json:"systemPrompt"` + ChannelID string `json:"channelId"` + APIFormat string `json:"apiFormat"` + InterfaceType string `json:"interfaceType"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"apiKey"` + Model string `json:"model"` + Size string `json:"size"` + Quality string `json:"quality"` + TransparentBackground string `json:"transparentBackground"` + Count string `json:"count"` + VideoSeconds string `json:"videoSeconds"` + VQuality string `json:"vquality"` + VideoGenerateAudio string `json:"videoGenerateAudio"` + VideoWatermark string `json:"videoWatermark"` + AudioVoice string `json:"audioVoice"` + AudioFormat string `json:"audioFormat"` + AudioSpeed string `json:"audioSpeed"` + AudioInstructions string `json:"audioInstructions"` + SystemPrompt string `json:"systemPrompt"` } const providerHTTPTimeout = 5 * time.Minute @@ -325,6 +326,9 @@ func systemChannelIDFromBaseURL(baseURL string) string { } func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { + if input.Config.TransparentBackground == "true" && !supportsTransparentImageBackground(input.Config) { + return nil, errors.New("当前模型不支持透明背景,请关闭透明背景或切换至 GPT Image 1 系列模型") + } var payload imageResponse if len(input.ReferenceImages) > 0 || input.Mask != nil { body := &bytes.Buffer{} @@ -334,6 +338,9 @@ func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string] writeField(writer, "n", "1") writeField(writer, "response_format", "b64_json") writeField(writer, "output_format", "png") + if input.Config.TransparentBackground == "true" { + writeField(writer, "background", "transparent") + } if input.Config.Quality != "" { writeField(writer, "quality", normalizeImageQuality(input.Config.Quality)) } @@ -364,6 +371,9 @@ func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string] "response_format": "b64_json", "output_format": "png", } + if input.Config.TransparentBackground == "true" { + body["background"] = "transparent" + } if input.Config.Quality != "" { body["quality"] = normalizeImageQuality(input.Config.Quality) } @@ -1762,6 +1772,12 @@ func isArkPlanVideoConfig(config providerConfig) bool { return strings.Contains(strings.ToLower(config.BaseURL), "/api/plan/v3") } +// 透明背景必须按模型能力显式开放,避免上游静默忽略参数后返回不透明图片。 +func supportsTransparentImageBackground(config providerConfig) bool { + model := strings.ToLower(strings.TrimSpace(config.Model)) + return strings.EqualFold(strings.TrimSpace(config.APIFormat), "openai") && (model == "gpt-image-1" || strings.HasPrefix(model, "gpt-image-1-") || strings.HasPrefix(model, "gpt-image-1.")) +} + func normalizeImageQuality(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "1k": diff --git a/web/src/components/canvas/canvas-assistant-panel.tsx b/web/src/components/canvas/canvas-assistant-panel.tsx index 8a8883af9..7d556ac5e 100644 --- a/web/src/components/canvas/canvas-assistant-panel.tsx +++ b/web/src/components/canvas/canvas-assistant-panel.tsx @@ -36,6 +36,7 @@ const GENERATION_OPTION_PROPERTIES = { model: { type: "string" }, size: { type: "string" }, quality: { type: "string" }, + transparentBackground: { type: "string", enum: ["true", "false"] }, count: { type: "number" }, seconds: { type: "string" }, vquality: { type: "string" }, @@ -1239,6 +1240,7 @@ function configNodeOp(id: string, input: Record, x: number, y: model: resolveGenerationModel(config, mode, stringOptional(input.model)), size: stringOptional(input.size) || config.size, quality: stringOptional(input.quality) || config.quality, + transparentBackground: stringOptional(input.transparentBackground) || config.transparentBackground, count: numberOptional(input.count) ?? generationCount(mode === "image" ? config.canvasImageCount || config.count : config.count), seconds: stringOptional(input.seconds) || config.videoSeconds, vquality: stringOptional(input.vquality) || config.vquality, @@ -1538,6 +1540,7 @@ function backendAgentProviderConfig(config: ReturnType { setOpen(nextOpen); onOpenChange?.(nextOpen); diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 402b9401a..4cdd18ace 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -377,6 +377,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can model, quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality, size: node.metadata?.size || globalConfig.size || defaultConfig.size, + transparentBackground: (node.metadata?.transparentBackground || globalConfig.transparentBackground) === "true" ? "true" : "false", videoSeconds: normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds), vquality: normalizeVideoResolution(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality), videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio, diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index 7a510200c..12f17d14e 100644 --- a/web/src/components/image-settings-panel.tsx +++ b/web/src/components/image-settings-panel.tsx @@ -2,7 +2,7 @@ import { type ReactNode, useState } from "react"; import { ConfigProvider, Switch } from "antd"; import { type CanvasTheme } from "@/lib/canvas-theme"; -import type { AiConfig } from "@/stores/use-config-store"; +import { supportsTransparentImageBackground, type AiConfig } from "@/stores/use-config-store"; const qualityOptions = [ { value: "auto", label: "自动" }, @@ -30,7 +30,7 @@ const aspectOptions = [ type ImageSettingsPanelProps = { config: AiConfig; - onConfigChange: (key: "quality" | "size" | "count", value: string) => void; + onConfigChange: (key: "quality" | "size" | "transparentBackground" | "count", value: string) => void; theme: CanvasTheme; showTitle?: boolean; showCount?: boolean; @@ -42,6 +42,8 @@ type ImageSettingsPanelProps = { export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, showCount = true, className = "w-[304px] space-y-3 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 10 }: ImageSettingsPanelProps) { const [snapDimensionToStep, setSnapDimensionToStep] = useState(true); const quality = config.quality || "auto"; + const transparentBackground = config.transparentBackground === "true"; + const transparentBackgroundSupported = supportsTransparentImageBackground(config, config.model || config.imageModel); const count = Math.max(1, Math.min(maxCount, Math.floor(Math.abs(Number(config.count)) || 1))); const activeSize = config.size || "auto"; const selectedAspect = aspectOptions.find((item) => (item.size || item.value) === activeSize || item.value === activeSize); @@ -79,6 +81,22 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = ))} +
+
+ 透明背景 +
+ {transparentBackgroundSupported ? "输出保留 Alpha 通道的 PNG" : transparentBackground ? "当前模型不支持,生成会被阻止" : "当前模型不支持透明背景"} +
+
+ event.stopPropagation()}> + onConfigChange("transparentBackground", checked ? "true" : "false")} + /> + +
尺寸 diff --git a/web/src/lib/canvas/canvas-project-generation.ts b/web/src/lib/canvas/canvas-project-generation.ts index b8d03c4ed..bc356e793 100644 --- a/web/src/lib/canvas/canvas-project-generation.ts +++ b/web/src/lib/canvas/canvas-project-generation.ts @@ -107,6 +107,7 @@ export function backendProviderConfig(config: AiConfig) { model: requestConfig.model, size: config.size, quality: config.quality, + transparentBackground: config.transparentBackground, count: config.count, videoSeconds: config.videoSeconds, vquality: config.vquality, @@ -177,6 +178,7 @@ export function buildImageGenerationMetadata(type: CanvasImageGenerationType, co model: config.model, size: config.size, quality: config.quality, + transparentBackground: config.transparentBackground, count, references: references.map(referenceUrl).filter((url): url is string => Boolean(url)), }; @@ -338,6 +340,7 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u model, quality: node?.metadata?.quality || config.quality || defaultConfig.quality, size: node?.metadata?.size || config.size || defaultConfig.size, + transparentBackground: (node?.metadata?.transparentBackground || config.transparentBackground) === "true" ? "true" : "false", videoSeconds: normalizeVideoDuration(node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds), vquality: normalizeVideoResolution(node?.metadata?.vquality || config.vquality || defaultConfig.vquality), videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio, diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index e0402e77f..aaff37610 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -1030,6 +1030,8 @@ function InfiniteCanvasPage() { prompt: "", model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, + quality: effectiveConfig.quality, + transparentBackground: effectiveConfig.transparentBackground, count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count), }, ); diff --git a/web/src/pages/canvas/use-canvas-generation-retry.ts b/web/src/pages/canvas/use-canvas-generation-retry.ts index 38c90c26e..c35aa4ea9 100644 --- a/web/src/pages/canvas/use-canvas-generation-retry.ts +++ b/web/src/pages/canvas/use-canvas-generation-retry.ts @@ -62,7 +62,7 @@ export function useCanvasGenerationRetry({ projectId, activatedSkills, nodesRef, const retryMode: CanvasNodeGenerationMode = node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : node.type === CanvasNodeType.Audio ? "audio" : "image"; const generationConfig = hasSavedImageMetadata && savedImageMetadata - ? { ...effectiveConfig, model: savedImageMetadata.model || effectiveConfig.imageModel || effectiveConfig.model, quality: savedImageMetadata.quality || effectiveConfig.quality, size: savedImageMetadata.size || effectiveConfig.size, count: "1" } + ? { ...effectiveConfig, model: savedImageMetadata.model || effectiveConfig.imageModel || effectiveConfig.model, quality: savedImageMetadata.quality || effectiveConfig.quality, size: savedImageMetadata.size || effectiveConfig.size, transparentBackground: (savedImageMetadata.transparentBackground || effectiveConfig.transparentBackground) === "true" ? "true" : "false", count: "1" } : { ...buildGenerationConfig(effectiveConfig, sourceNode, retryMode), count: "1" }; if (!isAiConfigReady(generationConfig, generationConfig.model)) { openConfigDialog(true); @@ -135,7 +135,7 @@ export function useCanvasGenerationRetry({ projectId, activatedSkills, nodesRef, const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height); const generationMetadata = savedImageMetadata?.generationType - ? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, count: savedImageMetadata.count || 1, references: savedImageMetadata.references } + ? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, transparentBackground: generationConfig.transparentBackground, count: savedImageMetadata.count || 1, references: savedImageMetadata.references } : buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryImages); setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Image, width: imageSize.width, height: imageSize.height, metadata: { ...item.metadata, ...imageMetadata(uploadedImage), prompt, ...generationMetadata } } : item))); } catch (error) { diff --git a/web/src/pages/canvas/use-canvas-upload.ts b/web/src/pages/canvas/use-canvas-upload.ts index 547f900d5..15b8a8ff2 100644 --- a/web/src/pages/canvas/use-canvas-upload.ts +++ b/web/src/pages/canvas/use-canvas-upload.ts @@ -278,6 +278,7 @@ export function useCanvasUpload({ model: undefined, size: undefined, quality: undefined, + transparentBackground: undefined, count: undefined, references: undefined, primaryImageId: undefined, diff --git a/web/src/pages/tasks/index.tsx b/web/src/pages/tasks/index.tsx index c187ea7ef..c03e7f714 100644 --- a/web/src/pages/tasks/index.tsx +++ b/web/src/pages/tasks/index.tsx @@ -493,6 +493,7 @@ function backendProviderConfig(config: ReturnType