fix: grok-image 补齐 aspect_ratio 与 resolution 传参 (#184)
* fix: grok-image 补齐 aspect_ratio 与 resolution 传参 - 能力配置开放比例与 1k/2k 分辨率 - 请求体映射 size→aspect_ratio、quality→resolution - 画布设置展示「分辨率」选项 - 补充单测 Closes #182 * fix(create): 创作页图片质量支持 1k/2k 分辨率选项 创作页原先硬编码 auto/low/medium/high,导致 grok 能力配置里的 1k/2k 无法显示;改为按模型 quality.values 动态渲染,有 1k/2k 时标题显示「分辨率」。
This commit is contained in:
@@ -10,6 +10,8 @@ const qualityOptions = [
|
||||
{ value: "high", label: "高" },
|
||||
{ value: "medium", label: "中" },
|
||||
{ value: "low", label: "低" },
|
||||
{ value: "1k", label: "1K" },
|
||||
{ value: "2k", label: "2K" },
|
||||
];
|
||||
const DIMENSION_STEP = 16;
|
||||
|
||||
@@ -78,8 +80,8 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
>
|
||||
{showTitle ? <div className="text-base font-semibold">图像设置</div> : null}
|
||||
{profile.quality.supported ? <div className="space-y-2">
|
||||
<SettingTitle color={theme.node.muted}>质量</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
<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} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
|
||||
{item.label}
|
||||
@@ -181,7 +183,12 @@ export function ImageSettingsTheme({ theme, children }: { theme: CanvasTheme; ch
|
||||
}
|
||||
|
||||
export function imageQualityLabel(value: string) {
|
||||
return ({ auto: "自动", high: "高", medium: "中", low: "低" } as Record<string, string>)[value] || "默认";
|
||||
return ({ auto: "自动", high: "高", medium: "中", low: "低", "1k": "1K", "2k": "2K" } as Record<string, string>)[value] || value || "默认";
|
||||
}
|
||||
|
||||
function isGrokResolutionQuality(profile: ImageCapabilityConfig) {
|
||||
const values = profile.quality.values.map((item) => item.toLowerCase());
|
||||
return values.includes("1k") || values.includes("2k");
|
||||
}
|
||||
|
||||
export function imageSizeLabel(size: string) {
|
||||
|
||||
@@ -77,8 +77,14 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
if (protocol === "grok-image") {
|
||||
image.references.maxImages = 1;
|
||||
image.references.maskSupported = false;
|
||||
image.size = { parameter: "none", values: [], default: "auto", allowCustom: false };
|
||||
image.quality = { supported: false, values: [], default: "auto" };
|
||||
// grok2api / xAI Imagine:size→aspect_ratio,quality→resolution(1k/2k)。
|
||||
image.size = {
|
||||
parameter: "aspect_ratio",
|
||||
values: ["1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"],
|
||||
default: "1:1",
|
||||
allowCustom: false,
|
||||
};
|
||||
image.quality = { supported: true, values: ["1k", "2k"], default: "2k" };
|
||||
image.transparentBackground = { supported: false, default: false };
|
||||
image.responseFormat = { supported: true };
|
||||
image.outputFormat = { supported: false };
|
||||
@@ -101,8 +107,13 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
if (protocol !== "grok-image" && model.trim().toLowerCase().startsWith("grok-imagine-image")) {
|
||||
image.references.maxImages = 0;
|
||||
image.references.maskSupported = false;
|
||||
image.size = { parameter: "none", values: [], default: "auto", allowCustom: false };
|
||||
image.quality = { supported: false, values: [], default: "auto" };
|
||||
image.size = {
|
||||
parameter: "aspect_ratio",
|
||||
values: ["1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"],
|
||||
default: "1:1",
|
||||
allowCustom: false,
|
||||
};
|
||||
image.quality = { supported: true, values: ["1k", "2k"], default: "2k" };
|
||||
image.transparentBackground = { supported: false, default: false };
|
||||
image.responseFormat = { supported: true };
|
||||
image.outputFormat = { supported: false };
|
||||
@@ -166,7 +177,9 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: 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 && profile.quality.values.includes(value.quality || "") ? value.quality! : profile.quality.default || "auto";
|
||||
const quality = profile.quality.supported
|
||||
? (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 };
|
||||
|
||||
@@ -61,6 +61,9 @@ const qualityOptions = [
|
||||
{ value: "low", label: "低", description: "更快生成" },
|
||||
{ value: "medium", label: "中", description: "均衡模式" },
|
||||
{ value: "high", label: "高", description: "优先细节" },
|
||||
// grok2api / xAI Imagine:quality 映射 resolution
|
||||
{ value: "1k", label: "1K", description: "标准清晰度" },
|
||||
{ value: "2k", label: "2K", description: "更高清晰度" },
|
||||
];
|
||||
const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: videoResolutionLabel(value) }));
|
||||
const countOptions = ["1", "2", "3", "4"];
|
||||
@@ -777,7 +780,8 @@ function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange:
|
||||
function GenerationSettingsMenu(props: ComposerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [customRatioOpen, setCustomRatioOpen] = useState(!ratioOptions.some((option) => option.value === props.ratio));
|
||||
const qualityLabel = qualityOptions.find((item) => item.value === props.quality)?.label || "自动";
|
||||
const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value.toUpperCase(), description: "模型支持的质量/分辨率" });
|
||||
const qualityLabel = activeQualityOptions.find((item) => item.value === props.quality)?.label || qualityOptions.find((item) => item.value === props.quality)?.label || props.quality || "自动";
|
||||
const ratios = props.mode === "video" ? props.videoProfile.ratios : props.imageProfile.size.values.length ? props.imageProfile.size.values : ratioOptions.map((item) => item.value);
|
||||
const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions;
|
||||
const imageSummary = [
|
||||
@@ -789,7 +793,7 @@ function GenerationSettingsMenu(props: ComposerProps) {
|
||||
const panel = <div className="creation-parameter-menu">
|
||||
{props.mode === "video" || props.imageProfile.size.parameter !== "none" ? <SettingSection title="画幅" value={props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{ratios.map((value) => <button key={value} type="button" aria-pressed={value === props.ratio} className={value === props.ratio ? "is-selected" : ""} onClick={() => { props.setRatio(value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>)}</div>{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? <label className="creation-custom-value"><span>宽 : 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义画幅,支持宽x高或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义比例</button>)}</div></SettingSection> : null}
|
||||
{props.mode === "video" ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : <>
|
||||
{props.imageProfile.quality.supported ? <SettingSection title="图片质量" value={qualityLabel}><div className="creation-choice-grid is-quality">{qualityOptions.filter((option) => props.imageProfile.quality.values.includes(option.value)).map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.quality} className={option.value === props.quality ? "is-selected" : ""} onClick={() => props.setQuality(option.value)}><span>{option.label}</span><small>{option.description}</small></button>)}</div></SettingSection> : null}
|
||||
{props.imageProfile.quality.supported ? <SettingSection title={activeQualityOptions.some((item) => item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}><div className="creation-choice-grid is-quality">{activeQualityOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.quality} className={option.value === props.quality ? "is-selected" : ""} onClick={() => props.setQuality(option.value)}><span>{option.label}</span><small>{option.description}</small></button>)}</div></SettingSection> : null}
|
||||
{props.imageProfile.maxOutputs > 1 ? <SettingSection title="生成数量" value={`${props.count} 张`}><div className="creation-parameter-content"><div className="creation-choice-grid is-count">{countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => <button key={option} type="button" aria-pressed={option === props.count} className={option === props.count ? "is-selected" : ""} onClick={() => props.setCount(option)}>{option}</button>)}</div><label className="creation-custom-value"><span>自定义</span><input inputMode="numeric" pattern="[0-9]*" value={props.count} onChange={(event) => props.setCount(String(Math.max(1, Math.min(props.imageProfile.maxOutputs, Number(event.target.value) || 1))))} aria-label={`生成数量,范围 1 到 ${props.imageProfile.maxOutputs}`} /><em>张</em></label></div></SettingSection> : null}
|
||||
</>}
|
||||
</div>;
|
||||
|
||||
@@ -124,6 +124,15 @@ function normalizeQuality(quality: string) {
|
||||
return QUALITY_BASE[normalized] ? normalized : undefined;
|
||||
}
|
||||
|
||||
/** grok2api / xAI Imagine:画布 quality 映射为 resolution(1k/2k)。 */
|
||||
function normalizeGrokImageResolution(quality: string | undefined) {
|
||||
const value = (quality || "").trim().toLowerCase();
|
||||
if (!value || value === "auto") return undefined;
|
||||
if (value === "1k" || value === "low" || value === "standard") return "1k";
|
||||
if (value === "2k" || value === "medium" || value === "hd" || value === "high" || value === "4k") return "2k";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Map "quality + ratio" to an explicit pixel dimension like "3840x2160". */
|
||||
function resolveSize(quality: string | undefined, ratio: string): string {
|
||||
const parsedRatio = parseImageRatio(ratio);
|
||||
@@ -814,6 +823,9 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
|
||||
}
|
||||
if (requestConfig.interfaceType === "grok-image") {
|
||||
try {
|
||||
const size = normalizedImage.size && normalizedImage.size !== "auto" ? normalizedImage.size : undefined;
|
||||
const aspectRatio = size?.includes(":") ? size : undefined;
|
||||
const resolution = normalizeGrokImageResolution(normalizedImage.quality);
|
||||
const responseData = await postChannelJSON<ImageApiResponse>(
|
||||
requestConfig,
|
||||
aiApiUrl(requestConfig, "/images/generations"),
|
||||
@@ -822,6 +834,9 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
|
||||
prompt: withSystemPrompt(requestConfig, prompt),
|
||||
n,
|
||||
response_format: "url",
|
||||
...(size ? { size } : {}),
|
||||
...(aspectRatio ? { aspect_ratio: aspectRatio } : {}),
|
||||
...(resolution ? { resolution } : {}),
|
||||
},
|
||||
options,
|
||||
);
|
||||
@@ -898,6 +913,9 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
if (references.length !== 1) throw new Error("Grok 图片编辑必须提供且仅支持 1 张参考图");
|
||||
try {
|
||||
const imageUrl = await grokImageInputURL(references[0]);
|
||||
const size = normalizedImage.size && normalizedImage.size !== "auto" ? normalizedImage.size : undefined;
|
||||
const aspectRatio = size?.includes(":") ? size : undefined;
|
||||
const resolution = normalizeGrokImageResolution(normalizedImage.quality);
|
||||
const response = await postChannelJSON<ImageApiResponse>(
|
||||
requestConfig,
|
||||
aiApiUrl(requestConfig, "/images/edits"),
|
||||
@@ -907,6 +925,9 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
image: { url: imageUrl },
|
||||
n,
|
||||
response_format: "url",
|
||||
...(size ? { size } : {}),
|
||||
...(aspectRatio ? { aspect_ratio: aspectRatio } : {}),
|
||||
...(resolution ? { resolution } : {}),
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user