fix(canvas): 局部重绘、图片规格与任务参数修复
This commit is contained in:
1 parent
bacd502f5c
commit
cd2ad4d700
13 files changed
+124
-45
No files matched your search
@@ -822,10 +822,11 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp
|
|||||||
return BadAuthRequest(err.Error())
|
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("图片质量不在当前模型支持范围内")
|
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
|
return err
|
||||||
}
|
}
|
||||||
count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count))
|
count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count))
|
||||||
@@ -835,6 +836,13 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp
|
|||||||
return nil
|
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 {
|
func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio string) error {
|
||||||
if profile.Size.Parameter != "aspect_ratio" || profile.Size.AllowCustom || len(profile.Size.Presets) == 0 || ratio == "auto" {
|
if profile.Size.Parameter != "aspect_ratio" || profile.Size.AllowCustom || len(profile.Size.Presets) == 0 || ratio == "auto" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper
|
|||||||
explicitOptions = true
|
explicitOptions = true
|
||||||
for key, value := range options {
|
for key, value := range options {
|
||||||
name := canonicalCapabilityOptionName(key)
|
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)
|
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) {
|
func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
|
||||||
input := map[string]any{
|
input := map[string]any{
|
||||||
"mode": "video",
|
"mode": "video",
|
||||||
|
|||||||
@@ -49,16 +49,16 @@ export const CanvasDrawingExcalidrawEditor = forwardRef<CanvasDrawingEditorHandl
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
langCode="zh-CN"
|
langCode="zh-CN"
|
||||||
theme={colorScheme}
|
theme={colorScheme}
|
||||||
autoFocus
|
autoFocus
|
||||||
excalidrawAPI={(api) => {
|
excalidrawAPI={(api) => {
|
||||||
apiRef.current = api;
|
apiRef.current = api;
|
||||||
api.setActiveTool({ type: "freedraw" });
|
api.setActiveTool({ type: "freedraw" });
|
||||||
onReady();
|
onReady();
|
||||||
}}
|
}}
|
||||||
UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false } }}
|
UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false } }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -41,10 +41,10 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon
|
|||||||
setBrushSize(defaultBrushSize);
|
setBrushSize(defaultBrushSize);
|
||||||
setMode("paint");
|
setMode("paint");
|
||||||
setError("");
|
setError("");
|
||||||
setAdvancedOpen(false);
|
setAdvancedOpen(true);
|
||||||
setGenerationConfig(config);
|
setGenerationConfig(config);
|
||||||
void readImageMeta(dataUrl).then(setImage);
|
void readImageMeta(dataUrl).then(setImage);
|
||||||
}, [config, dataUrl, open]);
|
}, [dataUrl, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearCanvas(maskCanvasRef.current);
|
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="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="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">
|
<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 ? (
|
{image ? (
|
||||||
<>
|
<>
|
||||||
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
|
<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}
|
ref={previewCanvasRef}
|
||||||
width={image.width}
|
width={image.width}
|
||||||
height={image.height}
|
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}
|
onPointerDown={startDraw}
|
||||||
onPointerMove={moveDraw}
|
onPointerMove={moveDraw}
|
||||||
onPointerUp={stopDraw}
|
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}
|
{error ? <div className="text-xs font-medium text-destructive">{error}</div> : null}
|
||||||
</div>
|
</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">
|
<div className="rounded-xl border border-border/60">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -211,6 +200,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon
|
|||||||
config={generationConfig}
|
config={generationConfig}
|
||||||
showTitle={false}
|
showTitle={false}
|
||||||
showCount={false}
|
showCount={false}
|
||||||
|
bypassPriceGuard
|
||||||
className="space-y-3"
|
className="space-y-3"
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onConfigChange={(key, value) => setGenerationConfig((current) => ({ ...current, [key]: value }))}
|
onConfigChange={(key, value) => setGenerationConfig((current) => ({ ...current, [key]: value }))}
|
||||||
|
|||||||
@@ -53,9 +53,11 @@ type ImageSettingsPanelProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
quickCount?: 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 profile = mergedImageCapabilityConfig(config, config.model || config.imageModel);
|
||||||
const normalized = normalizeImageValue(profile, config);
|
const normalized = normalizeImageValue(profile, config);
|
||||||
const quality = normalized.quality;
|
const quality = normalized.quality;
|
||||||
@@ -82,7 +84,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
|||||||
<SettingTitle color={theme.node.muted}>{isGrokResolutionQuality(profile) ? "分辨率" : "质量"}</SettingTitle>
|
<SettingTitle color={theme.node.muted}>{isGrokResolutionQuality(profile) ? "分辨率" : "质量"}</SettingTitle>
|
||||||
<div className={`grid gap-1.5 ${activeQualityOptions.length <= 2 ? "grid-cols-2" : "grid-cols-4"}`}>
|
<div className={`grid gap-1.5 ${activeQualityOptions.length <= 2 ? "grid-cols-2" : "grid-cols-4"}`}>
|
||||||
{activeQualityOptions.map((item) => (
|
{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}
|
{item.label}
|
||||||
</OptionPill>
|
</OptionPill>
|
||||||
))}
|
))}
|
||||||
@@ -103,7 +105,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div> : null}
|
</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 ? (
|
{showCount && effectiveMaxCount > 1 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<SettingTitle color={theme.node.muted}>生成张数</SettingTitle>
|
<SettingTitle color={theme.node.muted}>生成张数</SettingTitle>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { Button, Input } from "antd";
|
import { Button, Input } from "antd";
|
||||||
import { Check, Plus } from "lucide-react";
|
import { Check, Plus } from "lucide-react";
|
||||||
|
import { Switch } from "@/components/ui/base/switch";
|
||||||
import "./image-size-picker.css";
|
import "./image-size-picker.css";
|
||||||
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
|
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||||
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
|
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 id = useId();
|
||||||
const [drafts, setDrafts] = useState<Partial<Record<ImageResolutionTier, string>>>({});
|
const [drafts, setDrafts] = useState<Partial<Record<ImageResolutionTier, string>>>({});
|
||||||
const [errors, setErrors] = 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[]) => {
|
const update = (tier: ImageResolutionTier, ratios: string[]) => {
|
||||||
if (disabled) return false;
|
if (disabled) return false;
|
||||||
try {
|
try {
|
||||||
@@ -50,13 +55,10 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
|
|||||||
return (
|
return (
|
||||||
<section key={tier} className="image-size-preset-group" aria-labelledby={`${id}-${tier}-title`}>
|
<section key={tier} className="image-size-preset-group" aria-labelledby={`${id}-${tier}-title`}>
|
||||||
<header className="image-size-preset-heading">
|
<header className="image-size-preset-heading">
|
||||||
<div>
|
<div className="image-size-preset-heading-label">
|
||||||
<strong id={`${id}-${tier}-title`}>{tier.toUpperCase()}</strong>
|
<strong id={`${id}-${tier}-title`}>{tier.toUpperCase()}</strong>
|
||||||
<span>{items.length ? `${items.length} 个比例` : "未启用"}</span>
|
<span>{items.length ? `${items.length} 个比例` : "未启用"}</span>
|
||||||
</div>
|
<Switch size="sm" checked={items.length === ratios.length} disabled={disabled} aria-label={`启用 ${tier.toUpperCase()} 规格`} onChange={(enabled) => toggleTier(tier, ratios, enabled)} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="image-size-preset-body">
|
<div className="image-size-preset-body">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { buildImageResolutionOptions, type ImageResolutionOption, type ImageReso
|
|||||||
import type { ImageCapabilityConfig } from "./model-capabilities";
|
import type { ImageCapabilityConfig } from "./model-capabilities";
|
||||||
|
|
||||||
export const IMAGE_RESOLUTIONS: ImageResolutionTier[] = ["1k", "2k", "4k"];
|
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[]> = {
|
const standardSizes: Record<string, string[]> = {
|
||||||
"1:1": ["1024x1024", "2048x2048", "2880x2880"],
|
"1:1": ["1024x1024", "2048x2048", "2880x2880"],
|
||||||
"16:9": ["1824x1024", "2752x1536", "3840x2160"],
|
"16:9": ["1824x1024", "2752x1536", "3840x2160"],
|
||||||
@@ -11,6 +11,8 @@ const standardSizes: Record<string, string[]> = {
|
|||||||
"3:4": ["1024x1360", "1728x2304", "2448x3264"],
|
"3:4": ["1024x1360", "1728x2304", "2448x3264"],
|
||||||
"3:2": ["1536x1024", "2496x1664", "3504x2336"],
|
"3:2": ["1536x1024", "2496x1664", "3504x2336"],
|
||||||
"2:3": ["1024x1536", "1664x2496", "2336x3504"],
|
"2:3": ["1024x1536", "1664x2496", "2336x3504"],
|
||||||
|
"4:5": ["1024x1280", "1792x2240", "2560x3200"],
|
||||||
|
"5:4": ["1280x1024", "2240x1792", "3200x2560"],
|
||||||
"21:9": ["2048x878", "3136x1344", "3808x1632"],
|
"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 }) {
|
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
|
||||||
const size = normalizeImageSizeSetting(profile, value.size);
|
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 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";
|
const transparentBackground = profile.transparentBackground.supported && value.transparentBackground === "true" ? "true" : "false";
|
||||||
return { size, quality, count, transparentBackground };
|
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}
|
{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}
|
{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}
|
{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}
|
{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 { extractVideoAudio, trimVideoSegment } from "@/lib/canvas/canvas-video-segment";
|
||||||
import { generationErrorMessage } from "@/lib/generation-error";
|
import { generationErrorMessage } from "@/lib/generation-error";
|
||||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||||
|
import { defaultImageParamsForModel } from "@/lib/model-selection";
|
||||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||||
import { storeGeneratedVideo } from "@/services/api/video";
|
import { storeGeneratedVideo } from "@/services/api/video";
|
||||||
import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
|
import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
|
||||||
import { uploadImage } from "@/services/image-storage";
|
import { uploadImage } from "@/services/image-storage";
|
||||||
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
|
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
|
||||||
import type { GenerationTask } from "@/services/api/task-center";
|
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 { defaultConfig, resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState } from "@/types/canvas";
|
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState } from "@/types/canvas";
|
||||||
import type { StartCanvasUploadStatus } from "./use-canvas-upload";
|
import type { StartCanvasUploadStatus } from "./use-canvas-upload";
|
||||||
@@ -576,13 +586,22 @@ export function useCanvasMediaTools({
|
|||||||
const maskEditImageNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => {
|
const maskEditImageNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => {
|
||||||
if (!node.metadata?.content) return;
|
if (!node.metadata?.content) return;
|
||||||
const baseGenerationConfig = buildGenerationConfig(effectiveConfig, node, "image");
|
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 = {
|
const generationConfig = {
|
||||||
...baseGenerationConfig,
|
...baseGenerationConfig,
|
||||||
...payload.generationConfig,
|
...payload.generationConfig,
|
||||||
model: payload.generationConfig?.model || payload.generationConfig?.imageModel || baseGenerationConfig.model,
|
model: selectedModel,
|
||||||
imageModel: payload.generationConfig?.imageModel || payload.generationConfig?.model || effectiveConfig.imageModel,
|
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),
|
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)) {
|
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
|
||||||
navigateToSettings({ continueCreation: true });
|
navigateToSettings({ continueCreation: true });
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ export function backendProviderConfig(config: AiConfig, mode: BackendGenerationM
|
|||||||
if (workflow) return workflowProviderConfig(config, requestConfig, workflow);
|
if (workflow) return workflowProviderConfig(config, requestConfig, workflow);
|
||||||
const generationOptions = {
|
const generationOptions = {
|
||||||
size: config.size,
|
size: config.size,
|
||||||
quality: config.quality,
|
quality: omittedImageQuality(config.quality),
|
||||||
transparentBackground: config.transparentBackground,
|
transparentBackground: config.transparentBackground,
|
||||||
count: config.count,
|
count: config.count,
|
||||||
videoSeconds: config.videoSeconds,
|
videoSeconds: config.videoSeconds,
|
||||||
@@ -623,13 +623,28 @@ function logicalCapabilityOptions(config: AiConfig, mode: BackendGenerationMode)
|
|||||||
const channel = resolveModelChannel(config, config.model);
|
const channel = resolveModelChannel(config, config.model);
|
||||||
const spec = channel.modelCosts?.find((item) => item.model === modelOptionName(config.model))?.logicalCapabilitySpec;
|
const spec = channel.modelCosts?.find((item) => item.model === modelOptionName(config.model))?.logicalCapabilitySpec;
|
||||||
const candidates: Record<string, unknown> = mode === "image"
|
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"
|
: mode === "video"
|
||||||
? { size: config.size, videoSeconds: Number(config.videoSeconds), vquality: config.vquality, videoGenerateAudio: config.videoGenerateAudio === "true", videoWatermark: config.videoWatermark === "true" }
|
? { size: config.size, videoSeconds: Number(config.videoSeconds), vquality: config.vquality, videoGenerateAudio: config.videoGenerateAudio === "true", videoWatermark: config.videoWatermark === "true" }
|
||||||
: mode === "audio"
|
: mode === "audio"
|
||||||
? { audioVoice: config.audioVoice, audioFormat: config.audioFormat, audioSpeed: Number(config.audioSpeed) }
|
? { 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 {
|
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("管理员设置同步支持值、默认值,并保持精确尺寸", () => {
|
test("管理员设置同步支持值、默认值,并保持精确尺寸", () => {
|
||||||
const profile = defaultImageCapabilityConfig();
|
const profile = defaultImageCapabilityConfig();
|
||||||
profile.size = { parameter: "size", values: ["1920x1080"], default: "1920x1080", allowCustom: false };
|
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="${tier} 16:9"`);
|
||||||
}
|
}
|
||||||
expect(editor).toContain('aria-label="默认输出"');
|
expect(editor).toContain('aria-label="默认输出"');
|
||||||
expect(editor).toContain("全不选");
|
expect(editor).not.toContain("全不选");
|
||||||
expect(editor).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 readOnly = renderToStaticMarkup(<ImageSizePresetsEditor profile={profile} disabled onChange={() => {}} />);
|
||||||
const buttons = [...readOnly.matchAll(/<button\b[^>]*>/g)];
|
const buttons = [...readOnly.matchAll(/<button\b[^>]*>/g)];
|
||||||
expect(buttons.length).toBeGreaterThan(24);
|
expect(buttons.length).toBeGreaterThan(24);
|
||||||
|
|||||||
Reference in new issue
Block a user