fix(canvas): 局部重绘、图片规格与任务参数修复
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 }))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user