fix(canvas): 局部重绘、图片规格与任务参数修复

This commit is contained in:
Rou
2026-09-09 14:22:21 +08:00
parent 83d9622fd0
commit 1aa539b593
13 changed files with 124 additions and 45 deletions

View File

@@ -822,10 +822,11 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp
return BadAuthRequest(err.Error())
}
}
if profile.Quality.Supported && strings.TrimSpace(input.Config.Quality) != "" && !containsCapabilityString(profile.Quality.Values, input.Config.Quality) {
quality := strings.TrimSpace(input.Config.Quality)
if profile.Quality.Supported && quality != "" && !strings.EqualFold(quality, "auto") && !strings.EqualFold(quality, "any") && !containsCapabilityString(profile.Quality.Values, quality) {
return BadAuthRequest("图片质量不在当前模型支持范围内")
}
if err := validateImagePresetSelection(profile, firstNonEmpty(input.Config.Quality, profile.Quality.Default), firstNonEmpty(input.Config.Size, profile.Size.Default)); err != nil {
if err := validateImagePresetSelection(profile, qualityForImageValidation(quality, profile.Quality.Default), firstNonEmpty(input.Config.Size, profile.Size.Default)); err != nil {
return err
}
count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count))
@@ -835,6 +836,13 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp
return nil
}
func qualityForImageValidation(quality string, fallback string) string {
if strings.EqualFold(quality, "auto") || strings.EqualFold(quality, "any") {
return fallback
}
return firstNonEmpty(quality, fallback)
}
func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio string) error {
if profile.Size.Parameter != "aspect_ratio" || profile.Size.AllowCustom || len(profile.Size.Presets) == 0 || ratio == "auto" {
return nil

View File

@@ -65,6 +65,13 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper
explicitOptions = true
for key, value := range options {
name := canonicalCapabilityOptionName(key)
// auto/any 表示调用方不指定质量;不能把它当作模型必须声明的
// 枚举值,否则未列出 auto 的模型会被错误判定为参数不支持。
if name == "quality" {
if normalized, ok := value.(string); ok && (strings.EqualFold(strings.TrimSpace(normalized), "auto") || strings.EqualFold(strings.TrimSpace(normalized), "any")) {
continue
}
}
intent.Options[name] = normalizeModelRequestOption(name, value)
}
}

View File

@@ -38,6 +38,19 @@ func TestImagePriceTiersMatchResolutionAndActualReferences(t *testing.T) {
}
}
func TestAutoQualityIsOmittedFromCapabilityIntent(t *testing.T) {
intent := ModelRequestIntentFromTaskInput(map[string]any{
"mode": "image",
"capabilityOptions": map[string]any{"quality": "auto", "size": "2048x878"},
}, "canvas_image", "image")
if _, exists := intent.Options["quality"]; exists {
t.Fatalf("quality option = %#v, want omitted for auto", intent.Options["quality"])
}
if intent.Options["size"] != "2048x878" {
t.Fatalf("size option = %#v, want preserved", intent.Options["size"])
}
}
func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
input := map[string]any{
"mode": "video",

View File

@@ -49,16 +49,16 @@ export const CanvasDrawingExcalidrawEditor = forwardRef<CanvasDrawingEditorHandl
return (
<Excalidraw
initialData={initialData}
langCode="zh-CN"
theme={colorScheme}
autoFocus
excalidrawAPI={(api) => {
apiRef.current = api;
api.setActiveTool({ type: "freedraw" });
onReady();
}}
UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false } }}
initialData={initialData}
langCode="zh-CN"
theme={colorScheme}
autoFocus
excalidrawAPI={(api) => {
apiRef.current = api;
api.setActiveTool({ type: "freedraw" });
onReady();
}}
UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false } }}
/>
);
});

View File

@@ -41,10 +41,10 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon
setBrushSize(defaultBrushSize);
setMode("paint");
setError("");
setAdvancedOpen(false);
setAdvancedOpen(true);
setGenerationConfig(config);
void readImageMeta(dataUrl).then(setImage);
}, [config, dataUrl, open]);
}, [dataUrl, open]);
useEffect(() => {
clearCanvas(maskCanvasRef.current);
@@ -115,7 +115,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_340px]">
<div className="flex min-h-[360px] items-center justify-center rounded-lg bg-surface-active p-0">
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none">
<img src={dataUrl} alt="" className="block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
<img src={dataUrl} alt="" className="relative z-0 block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
{image ? (
<>
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
@@ -123,7 +123,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon
ref={previewCanvasRef}
width={image.width}
height={image.height}
className="absolute inset-0 h-full w-full cursor-crosshair touch-none"
className="absolute inset-0 z-10 h-full w-full cursor-crosshair touch-none"
onPointerDown={startDraw}
onPointerMove={moveDraw}
onPointerUp={stopDraw}
@@ -173,17 +173,6 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon
{error ? <div className="text-xs font-medium text-destructive">{error}</div> : null}
</div>
<ImageSettingsPanel
config={generationConfig}
showTitle={false}
showQuality={false}
showTransparent={false}
showSize={false}
className="space-y-2"
theme={theme}
onConfigChange={(key, value) => setGenerationConfig((current) => ({ ...current, [key]: value }))}
/>
<div className="rounded-xl border border-border/60">
<button
type="button"
@@ -211,6 +200,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon
config={generationConfig}
showTitle={false}
showCount={false}
bypassPriceGuard
className="space-y-3"
theme={theme}
onConfigChange={(key, value) => setGenerationConfig((current) => ({ ...current, [key]: value }))}

View File

@@ -53,9 +53,11 @@ type ImageSettingsPanelProps = {
className?: string;
maxCount?: number;
quickCount?: number;
/** 局部编辑等场景需要先允许选择参数,由后端负责最终计费校验。 */
bypassPriceGuard?: boolean;
};
export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, showQuality = true, showTransparent = true, showSize = true, showCount = true, className = "w-[304px] space-y-3 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 3 }: ImageSettingsPanelProps) {
export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, showQuality = true, showTransparent = true, showSize = true, showCount = true, className = "w-[304px] space-y-3 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 3, bypassPriceGuard = false }: ImageSettingsPanelProps) {
const profile = mergedImageCapabilityConfig(config, config.model || config.imageModel);
const normalized = normalizeImageValue(profile, config);
const quality = normalized.quality;
@@ -82,7 +84,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
<SettingTitle color={theme.node.muted}>{isGrokResolutionQuality(profile) ? "分辨率" : "质量"}</SettingTitle>
<div className={`grid gap-1.5 ${activeQualityOptions.length <= 2 ? "grid-cols-2" : "grid-cols-4"}`}>
{activeQualityOptions.map((item) => (
<OptionPill key={item.value} selected={quality === item.value} disabled={!hasPriceTierForImageSelection(priceTiers, item.value, activeSize)} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
<OptionPill key={item.value} selected={quality === item.value} disabled={!bypassPriceGuard && !hasPriceTierForImageSelection(priceTiers, item.value, activeSize)} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
{item.label}
</OptionPill>
))}
@@ -103,7 +105,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
/>
</span>
</div> : null}
{showSize ? <ImageSizePicker profile={profile} size={activeSize} quality={quality} onChange={(size, nextQuality) => { onConfigChange("size", size); if (nextQuality) onConfigChange("quality", nextQuality); }} /> : null}
{showSize ? <ImageSizePicker profile={profile} size={activeSize} quality={quality} onChange={(size) => onConfigChange("size", size)} /> : null}
{showCount && effectiveMaxCount > 1 ? (
<div className="space-y-2">
<SettingTitle color={theme.node.muted}>生成张数</SettingTitle>

View File

@@ -1,6 +1,7 @@
import { useId, useState } from "react";
import { Button, Input } from "antd";
import { Check, Plus } from "lucide-react";
import { Switch } from "@/components/ui/base/switch";
import "./image-size-picker.css";
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
@@ -11,6 +12,10 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
const id = useId();
const [drafts, setDrafts] = useState<Partial<Record<ImageResolutionTier, string>>>({});
const [errors, setErrors] = useState<Partial<Record<ImageResolutionTier, string>>>({});
const toggleTier = (tier: ImageResolutionTier, ratios: string[], enabled: boolean) => {
if (disabled) return;
update(tier, enabled ? ratios : []);
};
const update = (tier: ImageResolutionTier, ratios: string[]) => {
if (disabled) return false;
try {
@@ -50,13 +55,10 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
return (
<section key={tier} className="image-size-preset-group" aria-labelledby={`${id}-${tier}-title`}>
<header className="image-size-preset-heading">
<div>
<div className="image-size-preset-heading-label">
<strong id={`${id}-${tier}-title`}>{tier.toUpperCase()}</strong>
<span>{items.length ? `${items.length} 个比例` : "未启用"}</span>
</div>
<div className="image-size-preset-bulk-actions">
<Button type="link" size="small" disabled={disabled || !items.length} onClick={() => update(tier, [])}>全不选</Button>
<Button type="link" size="small" disabled={disabled || items.length === ratios.length} onClick={() => update(tier, ratios)}>全选</Button>
<Switch size="sm" checked={items.length === ratios.length} disabled={disabled} aria-label={`启用 ${tier.toUpperCase()} 规格`} onChange={(enabled) => toggleTier(tier, ratios, enabled)} />
</div>
</header>
<div className="image-size-preset-body">

View File

@@ -2,7 +2,7 @@ import { buildImageResolutionOptions, type ImageResolutionOption, type ImageReso
import type { ImageCapabilityConfig } from "./model-capabilities";
export const IMAGE_RESOLUTIONS: ImageResolutionTier[] = ["1k", "2k", "4k"];
export const IMAGE_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "21:9"];
export const IMAGE_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9"];
const standardSizes: Record<string, string[]> = {
"1:1": ["1024x1024", "2048x2048", "2880x2880"],
"16:9": ["1824x1024", "2752x1536", "3840x2160"],
@@ -11,6 +11,8 @@ const standardSizes: Record<string, string[]> = {
"3:4": ["1024x1360", "1728x2304", "2448x3264"],
"3:2": ["1536x1024", "2496x1664", "3504x2336"],
"2:3": ["1024x1536", "1664x2496", "2336x3504"],
"4:5": ["1024x1280", "1792x2240", "2560x3200"],
"5:4": ["1280x1024", "2240x1792", "3200x2560"],
"21:9": ["2048x878", "3136x1344", "3808x1632"],
};

View File

@@ -896,7 +896,14 @@ function matchWorkflowValue(value: string, options: string[]) {
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
const size = normalizeImageSizeSetting(profile, value.size);
const quality = profile.quality.supported ? (value.quality && profile.quality.values.includes(value.quality) ? value.quality : profile.quality.default || "auto") : profile.quality.default || "auto";
const requestedQuality = String(value.quality || "").trim().toLowerCase();
const quality = profile.quality.supported
? requestedQuality === "auto" || requestedQuality === "any"
? "auto"
: value.quality && profile.quality.values.includes(value.quality)
? value.quality
: profile.quality.default || "auto"
: profile.quality.default || "auto";
const count = String(Math.max(1, Math.min(profile.maxOutputs, Math.floor(Math.abs(Number(value.count)) || 1))));
const transparentBackground = profile.transparentBackground.supported && value.transparentBackground === "true" ? "true" : "false";
return { size, quality, count, transparentBackground };

View File

@@ -47,7 +47,7 @@ export function CanvasProjectMediaDialogs({
<>
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open onClose={onCloseCrop} onConfirm={(crop) => onCrop(cropNode, crop)} /> : null}
{annotationNode?.metadata?.content ? <CanvasNodeAnnotationDialog image={{ url: annotationNode.metadata.content, storageKey: annotationNode.metadata.storageKey }} open onClose={onCloseAnnotation} onConfirm={(dataUrl) => onAnnotate(annotationNode, dataUrl)} /> : null}
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} config={config} open onClose={onCloseMaskEdit} onConfirm={(payload) => onMaskEdit(maskEditNode, payload)} /> : null}
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} config={{ ...config, model: maskEditNode.metadata.model || config.model, imageModel: maskEditNode.metadata.model || config.imageModel, size: maskEditNode.metadata.size || config.size, quality: maskEditNode.metadata.quality || config.quality, count: String(maskEditNode.metadata.count || config.count) }} open onClose={onCloseMaskEdit} onConfirm={(payload) => onMaskEdit(maskEditNode, payload)} /> : null}
{splitNode?.metadata?.content ? <CanvasNodeSplitDialog dataUrl={splitNode.metadata.content} open onClose={onCloseSplit} onConfirm={(params) => onSplit(splitNode, params)} /> : null}
{upscaleNode?.metadata?.content ? <CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open onClose={onCloseUpscale} onConfirm={(params) => onUpscale(upscaleNode, params)} /> : null}
</>

View File

@@ -36,12 +36,22 @@ import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-
import { extractVideoAudio, trimVideoSegment } from "@/lib/canvas/canvas-video-segment";
import { generationErrorMessage } from "@/lib/generation-error";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { defaultImageParamsForModel } from "@/lib/model-selection";
import { navigateToSettings } from "@/lib/settings-navigation";
import { storeGeneratedVideo } from "@/services/api/video";
import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import type { GenerationTask } from "@/services/api/task-center";
function normalizeMaskEditQuality(quality: string | undefined, size: string | undefined) {
const value = String(quality || "").trim().toLowerCase();
if (value && value !== "auto" && value !== "any") return value;
const match = String(size || "").trim().toLowerCase().match(/^(\d+)x(\d+)$/);
if (!match) return quality || "auto";
const pixels = Number(match[1]) * Number(match[2]);
return pixels <= 2_000_000 ? "1k" : pixels <= 4_300_000 ? "2k" : pixels <= 8_294_400 ? "4k" : quality || "auto";
}
import { defaultConfig, resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState } from "@/types/canvas";
import type { StartCanvasUploadStatus } from "./use-canvas-upload";
@@ -576,13 +586,22 @@ export function useCanvasMediaTools({
const maskEditImageNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => {
if (!node.metadata?.content) return;
const baseGenerationConfig = buildGenerationConfig(effectiveConfig, node, "image");
const selectedModel = payload.generationConfig?.model || payload.generationConfig?.imageModel || baseGenerationConfig.model;
const modelDefaults = defaultImageParamsForModel(baseGenerationConfig, selectedModel);
const selectedImageProfile = modelCapabilityConfigFor(baseGenerationConfig, selectedModel).image;
if (!selectedImageProfile?.references.maskSupported) {
message.error("当前图片模型不支持局部重绘蒙版,请选择支持蒙版编辑的模型");
return;
}
const generationConfig = {
...baseGenerationConfig,
...payload.generationConfig,
model: payload.generationConfig?.model || payload.generationConfig?.imageModel || baseGenerationConfig.model,
model: selectedModel,
imageModel: payload.generationConfig?.imageModel || payload.generationConfig?.model || effectiveConfig.imageModel,
quality: normalizeMaskEditQuality(payload.generationConfig?.quality || node.metadata?.quality || baseGenerationConfig.quality || modelDefaults.quality, payload.generationConfig?.size || node.metadata?.size || baseGenerationConfig.size || modelDefaults.size),
count: String(payload.generationConfig?.count || 1),
size: payload.generationConfig?.size || node.metadata?.size || "auto",
// 原图像素尺寸不是模型的输出尺寸合同;非高级设置时使用模型默认尺寸,避免把节点尺寸误发给上游。
size: payload.generationConfig?.size || node.metadata?.size || modelDefaults.size,
};
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });

View File

@@ -538,7 +538,7 @@ export function backendProviderConfig(config: AiConfig, mode: BackendGenerationM
if (workflow) return workflowProviderConfig(config, requestConfig, workflow);
const generationOptions = {
size: config.size,
quality: config.quality,
quality: omittedImageQuality(config.quality),
transparentBackground: config.transparentBackground,
count: config.count,
videoSeconds: config.videoSeconds,
@@ -623,13 +623,28 @@ function logicalCapabilityOptions(config: AiConfig, mode: BackendGenerationMode)
const channel = resolveModelChannel(config, config.model);
const spec = channel.modelCosts?.find((item) => item.model === modelOptionName(config.model))?.logicalCapabilitySpec;
const candidates: Record<string, unknown> = mode === "image"
? { size: config.size, quality: config.quality, transparentBackground: config.transparentBackground === "true", count: Number(config.count) }
? { size: config.size, quality: omittedImageQuality(config.quality), transparentBackground: config.transparentBackground === "true", count: Number(config.count) }
: mode === "video"
? { size: config.size, videoSeconds: Number(config.videoSeconds), vquality: config.vquality, videoGenerateAudio: config.videoGenerateAudio === "true", videoWatermark: config.videoWatermark === "true" }
: mode === "audio"
? { audioVoice: config.audioVoice, audioFormat: config.audioFormat, audioSpeed: Number(config.audioSpeed) }
: {};
return Object.fromEntries(Object.entries(candidates).filter(([key]) => Boolean(spec?.options?.[key])));
const filtered = Object.fromEntries(Object.entries(candidates).filter(([key]) => Boolean(spec?.options?.[key])));
// 图片质量和画幅同时参与按规格计费匹配。即使逻辑模型能力声明只把其中一项
// 暴露给供应线路,报价仍需要看到客户端最终选择,避免局部重绘等编辑请求落到
// “未配置所选规格”的错误分支。
if (mode === "image") {
for (const key of ["quality", "size"] as const) {
const value = candidates[key];
if (value !== undefined && value !== null && String(value).trim() !== "") filtered[key] = value;
}
}
return filtered;
}
function omittedImageQuality(value: string | undefined) {
const normalized = String(value || "").trim().toLowerCase();
return normalized === "auto" || normalized === "any" ? undefined : value;
}
export function parseBackendGenerationResult(task: GenerationTask): BackendGenerationResult {

View File

@@ -42,6 +42,17 @@ describe("统一图片分辨率与宽高比", () => {
}
});
test("固定比例编辑器包含 4:5 和 5:4 三档尺寸", () => {
expect(IMAGE_RATIOS).toContain("4:5");
expect(IMAGE_RATIOS).toContain("5:4");
expect(imagePresetForRatio("1k", "4:5").size).toBe("1024x1280");
expect(imagePresetForRatio("2k", "4:5").size).toBe("1792x2240");
expect(imagePresetForRatio("4k", "4:5").size).toBe("2560x3200");
expect(imagePresetForRatio("1k", "5:4").size).toBe("1280x1024");
expect(imagePresetForRatio("2k", "5:4").size).toBe("2240x1792");
expect(imagePresetForRatio("4k", "5:4").size).toBe("3200x2560");
});
test("管理员设置同步支持值、默认值,并保持精确尺寸", () => {
const profile = defaultImageCapabilityConfig();
profile.size = { parameter: "size", values: ["1920x1080"], default: "1920x1080", allowCustom: false };
@@ -139,8 +150,11 @@ describe("统一图片分辨率与宽高比", () => {
expect(editor).toContain(`aria-label="${tier} 16:9"`);
}
expect(editor).toContain('aria-label="默认输出"');
expect(editor).toContain("全不选");
expect(editor).toContain("全选");
expect(editor).not.toContain("全不选");
expect(editor).not.toContain("全选");
expect(editor).toContain('aria-label="启用 1K 规格"');
expect(editor).toContain('aria-label="启用 2K 规格"');
expect(editor).toContain('aria-label="启用 4K 规格"');
const readOnly = renderToStaticMarkup(<ImageSizePresetsEditor profile={profile} disabled onChange={() => {}} />);
const buttons = [...readOnly.matchAll(/<button\b[^>]*>/g)];
expect(buttons.length).toBeGreaterThan(24);