feat(canvas): 生图设置 - 支持透明背景输出 (#29) (#31)

Co-authored-by: linxi <linxi@enjoysoftware.cn>
This commit is contained in:
naidalao
2026-07-23 13:27:21 +08:00
committed by GitHub
parent 07422d7c1b
commit 3ae52d4fd1
15 changed files with 100 additions and 27 deletions

View File

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

View File

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

View File

@@ -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<string, unknown>, 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<typeof resolveModelReques
model: config.model,
size: config.size,
quality: config.quality,
transparentBackground: config.transparentBackground,
count: config.count,
videoSeconds: config.videoSeconds,
vquality: config.vquality,

View File

@@ -214,6 +214,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,

View File

@@ -29,7 +29,8 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
const quality = config.quality || "auto";
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const activeSize = config.size || "auto";
const summary = showCount ? `${imageQualityLabel(quality)} · ${imageSizeLabel(activeSize)} · ${count} 张` : `${imageQualityLabel(quality)} · ${imageSizeLabel(activeSize)}`;
const transparentLabel = config.transparentBackground === "true" ? " · 透明" : "";
const summary = showCount ? `${imageQualityLabel(quality)} · ${imageSizeLabel(activeSize)} · ${count} 张${transparentLabel}` : `${imageQualityLabel(quality)} · ${imageSizeLabel(activeSize)}${transparentLabel}`;
const updateOpen = (nextOpen: boolean) => {
setOpen(nextOpen);
onOpenChange?.(nextOpen);

View File

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

View File

@@ -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 =
))}
</div>
</div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<SettingTitle color={theme.node.muted}>透明背景</SettingTitle>
<div className="mt-1 text-[11px]" style={{ color: transparentBackgroundSupported ? theme.node.muted : theme.accent.danger }}>
{transparentBackgroundSupported ? "输出保留 Alpha 通道的 PNG" : transparentBackground ? "当前模型不支持,生成会被阻止" : "当前模型不支持透明背景"}
</div>
</div>
<span title={transparentBackgroundSupported ? "使用透明背景生成 PNG 图片" : "仅 GPT Image 1 系列模型支持透明背景"} onMouseDown={(event) => event.stopPropagation()}>
<Switch
size="small"
checked={transparentBackground}
disabled={!transparentBackgroundSupported && !transparentBackground}
onChange={(checked) => onConfigChange("transparentBackground", checked ? "true" : "false")}
/>
</span>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<SettingTitle color={theme.node.muted}>尺寸</SettingTitle>

View File

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

View File

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

View File

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

View File

@@ -278,6 +278,7 @@ export function useCanvasUpload({
model: undefined,
size: undefined,
quality: undefined,
transparentBackground: undefined,
count: undefined,
references: undefined,
primaryImageId: undefined,

View File

@@ -493,6 +493,7 @@ function backendProviderConfig(config: ReturnType<typeof resolveModelRequestConf
model: config.model,
size: config.size,
quality: config.quality,
transparentBackground: config.transparentBackground,
count: config.count,
videoSeconds: config.videoSeconds,
vquality: config.vquality,

View File

@@ -1,6 +1,6 @@
import axios from "axios";
import { buildApiUrl, isSystemProxyBaseUrl, resolveBackendApiUrl, resolveModelRequestConfig, type AiConfig, type ModelChannel } from "@/stores/use-config-store";
import { buildApiUrl, isSystemProxyBaseUrl, resolveBackendApiUrl, resolveModelRequestConfig, supportsTransparentImageBackground, type AiConfig, type ModelChannel } from "@/stores/use-config-store";
import { nanoid } from "nanoid";
import { dataUrlToFile } from "@/lib/image-utils";
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
@@ -233,6 +233,13 @@ function readStatusError(status: number | undefined, fallback: string) {
return status ? `${fallback}:${status}` : fallback;
}
function assertTransparentBackgroundSupported(config: AiConfig, model: string) {
if (config.transparentBackground !== "true") return;
if (!supportsTransparentImageBackground(config, model)) {
throw new Error("当前模型不支持透明背景,请关闭“透明背景”或切换至 GPT Image 1 系列模型");
}
}
function withSystemPrompt(config: AiConfig, prompt: string) {
const systemPrompt = config.systemPrompt.trim();
return systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt;
@@ -756,7 +763,9 @@ function parseGeminiImagePayload(payload: GeminiPayload) {
}
export async function requestGeneration(config: AiConfig, prompt: string, options?: RequestOptions) {
const requestConfig = resolveModelRequestConfig(config, config.model || config.imageModel);
const selectedModel = config.model || config.imageModel;
assertTransparentBackgroundSupported(config, selectedModel);
const requestConfig = resolveModelRequestConfig(config, selectedModel);
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
if (requestConfig.apiFormat === "gemini") {
try {
@@ -778,6 +787,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
...(requestSize ? { size: requestSize } : {}),
response_format: "b64_json",
output_format: IMAGE_OUTPUT_FORMAT,
...(config.transparentBackground === "true" ? { background: "transparent" } : {}),
},
{
headers: aiHeaders(requestConfig, "application/json"),
@@ -792,7 +802,9 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
}
export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[], mask?: ReferenceImage, options?: RequestOptions) {
const requestConfig = resolveModelRequestConfig(config, config.model || config.imageModel);
const selectedModel = config.model || config.imageModel;
assertTransparentBackgroundSupported(config, selectedModel);
const requestConfig = resolveModelRequestConfig(config, selectedModel);
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const requestPrompt = buildImageReferencePromptText(prompt, references);
if (requestConfig.apiFormat === "gemini") {
@@ -811,6 +823,9 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
formData.set("n", String(n));
formData.set("response_format", "b64_json");
formData.set("output_format", IMAGE_OUTPUT_FORMAT);
if (config.transparentBackground === "true") {
formData.set("background", "transparent");
}
if (quality) {
formData.set("quality", quality);
}

View File

@@ -55,6 +55,7 @@ export type AiConfig = {
audioModels: string[];
quality: string;
size: string;
transparentBackground: string;
count: string;
canvasImageCount: string;
};
@@ -101,6 +102,7 @@ export const defaultConfig: AiConfig = {
audioModels: ["default::gpt-4o-mini-tts"],
quality: "auto",
size: "1:1",
transparentBackground: "false",
count: "1",
canvasImageCount: "1",
};
@@ -274,6 +276,7 @@ export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot) {
vquality: normalizeVideoResolution(config.vquality),
videoGenerateAudio: config.videoGenerateAudio || "true",
videoWatermark: config.videoWatermark || "false",
transparentBackground: config.transparentBackground === "true" ? "true" : "false",
canvasImageCount: config.canvasImageCount || defaultConfig.canvasImageCount,
imageModels,
videoModels,
@@ -379,6 +382,13 @@ export function resolveModelChannel(config: AiConfig, value: string) {
return matched || config.channels[0] || createModelChannel({ id: "default", name: "默认渠道", baseUrl: config.baseUrl, apiKey: config.apiKey, apiFormat: config.apiFormat, models: config.models.map(modelOptionName) });
}
// 透明背景必须按模型能力显式开放,避免代理接口静默忽略参数后返回不透明图片。
export function supportsTransparentImageBackground(config: AiConfig, value: string) {
const channel = resolveModelChannel(config, value);
const model = modelOptionName(value).trim().toLowerCase();
return channel.apiFormat === "openai" && (model === "gpt-image-1" || model.startsWith("gpt-image-1-") || model.startsWith("gpt-image-1."));
}
export function resolveModelRequestConfig(config: AiConfig, value: string) {
const channel = resolveModelChannel(config, value);
return {

View File

@@ -146,6 +146,7 @@ export type CanvasNodeMetadata = {
model?: string;
size?: string;
quality?: string;
transparentBackground?: string;
count?: number;
seconds?: string;
vquality?: string;