feat(canvas): resolution/ratio composer settings and quality/count controls

Replace the ratio-baked size presets with a resolution tier (1K/2K/4K) plus
ratio selection and normalized dimensions, send imageQuality/imageCount to
the agent, and add the output_format field to GeneratorTaskInputArgs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 20:43:35 +08:00
parent f0ae237da5
commit 4ff246a294
9 changed files with 174 additions and 42 deletions
+3
View File
@@ -137,6 +137,7 @@ export type GeneratorTaskInputArgs = {
aspect_ratio?: string;
image?: string[];
image_url?: string;
output_format?: string;
prompt?: string;
resolution?: string;
src_box?: NormalizedBox;
@@ -327,6 +328,8 @@ export type AgentChatPayload = {
mode?: string;
imageModel?: string;
imageSize?: string;
imageQuality?: string;
imageCount?: number;
enableWebSearch?: boolean;
messages: Array<{
role: "user";
+1
View File
@@ -251,6 +251,7 @@
"qualityHigh": "High",
"qualityMedium": "Medium",
"qualityLow": "Low",
"imageResolutionLabel": "Base resolution",
"imageSizeLabel": "Size",
"imageModeDirective": "Image generation settings: quality {quality}, size {width}x{height}, ratio {ratio}, count {count}. Pure image mode: generate the image directly from the user's input without extra chat.",
"quickEdit": "Quick edit",
+1
View File
@@ -251,6 +251,7 @@
"qualityHigh": "高",
"qualityMedium": "中",
"qualityLow": "低",
"imageResolutionLabel": "基准分辨率",
"imageSizeLabel": "尺寸",
"imageModeDirective": "图片生成参数:质量 {quality},尺寸 {width}x{height},比例 {ratio},数量 {count} 张。纯生图模式,请直接围绕用户输入生成图片,不展开额外聊天。",
"quickEdit": "快捷编辑",
@@ -27,6 +27,8 @@ import { createApiFailure, createNetworkFailure, USER_MESSAGE_KEYS, httpStatusOf
type ImageGenerationOptions = {
imageModel?: string;
imageSize?: string;
imageQuality?: string;
imageCount?: number;
enableWebSearch?: boolean;
allowEmptyPrompt?: boolean;
};
@@ -433,15 +433,24 @@
font-weight: 600;
}
.composer-quality-control {
.composer-quality-control,
.composer-resolution-control {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden;
border-radius: 999px;
background: #f4f4f4;
}
.composer-quality-control button {
.composer-quality-control {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.composer-resolution-control {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.composer-quality-control button,
.composer-resolution-control button {
height: 40px;
border-radius: 999px;
color: #3a414d;
@@ -449,7 +458,8 @@
font-size: 14px;
}
.composer-quality-control button.selected {
.composer-quality-control button.selected,
.composer-resolution-control button.selected {
border: 1px solid #c9ced7;
background: #fff;
}
@@ -25,10 +25,12 @@ import { PromptComposer, type PromptComposerHandle, type UploadedReferenceImage,
export type ComposerMode = "agent" | "image";
export type ComposerImageQuality = "auto" | "high" | "medium" | "low";
export type ComposerImageRatio = "1:1" | "3:2" | "2:3" | "4:3" | "3:4" | "9:16" | "1:1-2k" | "16:9-2k" | "9:16-2k" | "16:9-4k" | "9:16-4k" | "auto";
export type ComposerImageResolution = "1K" | "2K" | "4K";
export type ComposerImageRatio = "1:1" | "3:2" | "2:3" | "4:3" | "3:4" | "16:9" | "9:16" | "4:5" | "5:4" | "auto";
export type ComposerImageSettings = {
quality: ComposerImageQuality;
resolution: ComposerImageResolution;
ratio: ComposerImageRatio;
width: number;
height: number;
@@ -78,37 +80,141 @@ const qualityOptions: Array<{ id: ComposerImageQuality; labelKey: string }> = [
{ id: "low", labelKey: "qualityLow" }
];
const resolutionOptions: Array<{ id: ComposerImageResolution; label: string }> = [
{ id: "1K", label: "1K" },
{ id: "2K", label: "2K" },
{ id: "4K", label: "4K" }
];
export const composerRatioOptions: Array<{ id: ComposerImageRatio; label: string; width: number; height: number }> = [
{ id: "1:1", label: "1:1", width: 1024, height: 1024 },
{ id: "3:2", label: "3:2", width: 1536, height: 1024 },
{ id: "2:3", label: "2:3", width: 1024, height: 1536 },
{ id: "4:3", label: "4:3", width: 1365, height: 1024 },
{ id: "3:4", label: "3:4", width: 1024, height: 1365 },
{ id: "9:16", label: "9:16", width: 1024, height: 1820 },
{ id: "1:1-2k", label: "1:1(2k)", width: 2048, height: 2048 },
{ id: "16:9-2k", label: "16:9(2k)", width: 2048, height: 1152 },
{ id: "9:16-2k", label: "9:16(2k)", width: 1152, height: 2048 },
{ id: "16:9-4k", label: "16:9(4k)", width: 4096, height: 2304 },
{ id: "9:16-4k", label: "9:16(4k)", width: 2304, height: 4096 },
{ id: "16:9", label: "16:9", width: 1824, height: 1024 },
{ id: "9:16", label: "9:16", width: 1024, height: 1824 },
{ id: "4:5", label: "4:5", width: 1024, height: 1280 },
{ id: "5:4", label: "5:4", width: 1280, height: 1024 },
{ id: "auto", label: "auto", width: 1024, height: 1024 }
];
export const defaultComposerImageSettings: ComposerImageSettings = {
quality: "auto",
resolution: "1K",
ratio: "1:1",
width: 1024,
height: 1024,
count: 1
};
const sizePattern = /^\s*(\d+)\s*[xX×]\s*(\d+)\s*$/;
const sizeMultiple = 16;
const maxEdge = 3840;
const maxAspectRatio = 3;
const minPixels = 655_360;
const maxPixels = 8_294_400;
function roundToMultiple(value: number, multiple: number) {
return Math.max(multiple, Math.round(value / multiple) * multiple);
}
function floorToMultiple(value: number, multiple: number) {
return Math.max(multiple, Math.floor(value / multiple) * multiple);
}
function ceilToMultiple(value: number, multiple: number) {
return Math.max(multiple, Math.ceil(value / multiple) * multiple);
}
function normalizeDimensions(width: number, height: number) {
let normalizedWidth = roundToMultiple(width, sizeMultiple);
let normalizedHeight = roundToMultiple(height, sizeMultiple);
const scaleToFit = (scale: number) => {
normalizedWidth = floorToMultiple(normalizedWidth * scale, sizeMultiple);
normalizedHeight = floorToMultiple(normalizedHeight * scale, sizeMultiple);
};
const scaleToFill = (scale: number) => {
normalizedWidth = ceilToMultiple(normalizedWidth * scale, sizeMultiple);
normalizedHeight = ceilToMultiple(normalizedHeight * scale, sizeMultiple);
};
for (let index = 0; index < 4; index += 1) {
const currentMaxEdge = Math.max(normalizedWidth, normalizedHeight);
if (currentMaxEdge > maxEdge) {
scaleToFit(maxEdge / currentMaxEdge);
}
if (normalizedWidth / normalizedHeight > maxAspectRatio) {
normalizedWidth = floorToMultiple(normalizedHeight * maxAspectRatio, sizeMultiple);
} else if (normalizedHeight / normalizedWidth > maxAspectRatio) {
normalizedHeight = floorToMultiple(normalizedWidth * maxAspectRatio, sizeMultiple);
}
const pixels = normalizedWidth * normalizedHeight;
if (pixels > maxPixels) {
scaleToFit(Math.sqrt(maxPixels / pixels));
} else if (pixels < minPixels) {
scaleToFill(Math.sqrt(minPixels / pixels));
}
}
return { width: normalizedWidth, height: normalizedHeight };
}
function normalizeImageSize(size: string) {
const match = size.trim().match(sizePattern);
if (!match) return null;
return normalizeDimensions(Number(match[1]), Number(match[2]));
}
function ratioParts(ratio: ComposerImageRatio, fallbackWidth: number, fallbackHeight: number) {
if (ratio === "auto") {
return {
width: Math.max(1, fallbackWidth),
height: Math.max(1, fallbackHeight)
};
}
const [width, height] = ratio.split(":").map(Number);
return { width, height };
}
export function composerImageDimensionsFor(resolution: ComposerImageResolution, ratio: ComposerImageRatio, fallbackWidth = 1024, fallbackHeight = 1024) {
const { width: ratioWidth, height: ratioHeight } = ratioParts(ratio, fallbackWidth, fallbackHeight);
if (ratio === "auto") {
const size = normalizeImageSize(`${ratioWidth}x${ratioHeight}`);
return size ?? { width: 1024, height: 1024 };
}
if (ratioWidth === ratioHeight) {
const side = resolution === "1K" ? 1024 : resolution === "2K" ? 2048 : 3840;
return normalizeImageSize(`${side}x${side}`) ?? { width: side, height: side };
}
if (resolution === "1K") {
const shortSide = 1024;
const width = ratioWidth > ratioHeight ? roundToMultiple((shortSide * ratioWidth) / ratioHeight, sizeMultiple) : shortSide;
const height = ratioWidth > ratioHeight ? shortSide : roundToMultiple((shortSide * ratioHeight) / ratioWidth, sizeMultiple);
return { width, height };
}
const longSide = resolution === "2K" ? 2048 : 3840;
const width = ratioWidth > ratioHeight ? longSide : roundToMultiple((longSide * ratioWidth) / ratioHeight, sizeMultiple);
const height = ratioWidth > ratioHeight ? roundToMultiple((longSide * ratioHeight) / ratioWidth, sizeMultiple) : longSide;
return normalizeImageSize(`${width}x${height}`) ?? { width, height };
}
export function composerImageDimensions(settings: ComposerImageSettings) {
return composerImageDimensionsFor(settings.resolution, settings.ratio, settings.width, settings.height);
}
export function composerImageSize(settings: ComposerImageSettings) {
return `${Math.max(1, Math.round(settings.width))}x${Math.max(1, Math.round(settings.height))}`;
const { width, height } = composerImageDimensions(settings);
return `${width}x${height}`;
}
export function composerSettingsSummary(settings: ComposerImageSettings) {
const ratioLabel = composerRatioOptions.find((option) => option.id === settings.ratio)?.label ?? settings.ratio;
const sizeLabel = settings.ratio === "auto" ? composerImageSize(settings).replace("x", "×") : ratioLabel;
return `${qualityLabel(settings.quality)} · ${sizeLabel} · ${settings.count} img`;
const sizeLabel = composerImageSize(settings).replace("x", "×");
return `${qualityLabel(settings.quality)} · ${settings.resolution} · ${ratioLabel} · ${sizeLabel} · ${settings.count} img`;
}
function qualityLabel(quality: ComposerImageQuality) {
@@ -167,8 +273,8 @@ export const CanvasAgentComposer = forwardRef<PromptComposerHandle, CanvasAgentC
};
const selectRatio = (ratio: ComposerImageRatio) => {
const nextRatio = composerRatioOptions.find((option) => option.id === ratio) ?? composerRatioOptions[0];
patchSettings({ ratio, width: nextRatio.width, height: nextRatio.height });
const nextSize = composerImageDimensionsFor(settings.resolution, ratio, settings.width, settings.height);
patchSettings({ ratio, ...nextSize });
};
const composerCard = (
@@ -372,6 +478,21 @@ export function ComposerSettingsPanel({
))}
</div>
</section>
<section>
<h4>{t("imageResolutionLabel")}</h4>
<div className="composer-resolution-control">
{resolutionOptions.map((option) => (
<button
key={option.id}
type="button"
className={settings.resolution === option.id ? "selected" : ""}
onClick={() => onPatch({ resolution: option.id, ...composerImageDimensionsFor(option.id, settings.ratio, settings.width, settings.height) })}
>
{option.label}
</button>
))}
</div>
</section>
<section>
<h4>{t("imageSizeLabel")}</h4>
<div className="composer-dimensions">
@@ -409,7 +530,7 @@ export function ComposerSettingsPanel({
}
function RatioGlyph({ ratio }: { ratio: ComposerImageRatio }) {
const portrait = ratio.startsWith("2:3") || ratio.startsWith("3:4") || ratio.startsWith("9:16");
const wide = ratio.startsWith("3:2") || ratio.startsWith("4:3") || ratio.startsWith("16:9");
const portrait = ratio.startsWith("2:3") || ratio.startsWith("3:4") || ratio.startsWith("9:16") || ratio.startsWith("4:5");
const wide = ratio.startsWith("3:2") || ratio.startsWith("4:3") || ratio.startsWith("16:9") || ratio.startsWith("5:4");
return <i className={`ratio-glyph ${portrait ? "portrait" : wide ? "wide" : ""}`} aria-hidden="true" />;
}
@@ -255,7 +255,8 @@
line-height: 20px;
}
.image-generator-settings-popover-panel .composer-quality-control {
.image-generator-settings-popover-panel .composer-quality-control,
.image-generator-settings-popover-panel .composer-resolution-control {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
overflow: visible;
@@ -264,6 +265,7 @@
}
.image-generator-settings-popover-panel .composer-quality-control button,
.image-generator-settings-popover-panel .composer-resolution-control button,
.image-generator-settings-popover-panel .composer-count-grid button {
height: 32px;
border: 1px solid #d8dbe1;
@@ -274,7 +276,12 @@
line-height: 20px;
}
.image-generator-settings-popover-panel .composer-quality-control button.selected {
.image-generator-settings-popover-panel .composer-resolution-control {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.image-generator-settings-popover-panel .composer-quality-control button.selected,
.image-generator-settings-popover-panel .composer-resolution-control button.selected {
border-color: #2d3036;
background: #fff;
}
@@ -9,6 +9,7 @@ import type { AgentContent } from "@/domain/design";
import { useI18n } from "@/i18n/i18n";
import {
ComposerSettingsPanel,
composerImageDimensionsFor,
composerImageSize,
composerRatioOptions,
type CanvasComposerModel,
@@ -86,9 +87,9 @@ function composerStyle(bounds: NodeScreenBounds, stageWidth: number, stageHeight
function imageSettingsSummary(settings: ComposerImageSettings, t: (key: string) => string) {
const ratioLabel = composerRatioOptions.find((option) => option.id === settings.ratio)?.label ?? settings.ratio;
const sizeLabel = settings.ratio === "auto" ? composerImageSize(settings).replace("x", "×") : ratioLabel;
const sizeLabel = composerImageSize(settings).replace("x", "×");
const countLabel = t("imageCountUnit").replace("{count}", String(settings.count));
return `${t(`quality${settings.quality[0].toUpperCase()}${settings.quality.slice(1)}`)} · ${sizeLabel} · ${countLabel}`;
return `${t(`quality${settings.quality[0].toUpperCase()}${settings.quality.slice(1)}`)} · ${settings.resolution} · ${ratioLabel} · ${sizeLabel} · ${countLabel}`;
}
export function ImageGeneratorFloatingComposer({
@@ -146,8 +147,8 @@ export function ImageGeneratorFloatingComposer({
};
const selectRatio = (ratio: ComposerImageRatio) => {
const nextRatio = composerRatioOptions.find((option) => option.id === ratio) ?? composerRatioOptions[0];
patchSettings({ ratio, width: nextRatio.width, height: nextRatio.height });
const nextSize = composerImageDimensionsFor(settings.resolution, ratio, settings.width, settings.height);
patchSettings({ ratio, ...nextSize });
};
const resetComposer = () => {
@@ -357,25 +357,9 @@ function expandImageSize(ratio: ExpandOptions["ratio"], fallbackRatio: number) {
return `1024x${Math.round(1024 / safeRatio)}`;
}
function withImageModeSettings(
contents: AgentContent[],
settings: ComposerImageSettings,
t: (key: string, values?: Record<string, string | number>) => string,
targetNodeId?: string
) {
function withImageGeneratorTarget(contents: AgentContent[], targetNodeId?: string) {
return [
...contents,
{
type: "text" as const,
text: t("imageModeDirective", {
quality: t(`quality${settings.quality === "auto" ? "Auto" : settings.quality === "high" ? "High" : settings.quality === "medium" ? "Medium" : "Low"}`),
ratio: settings.ratio,
width: settings.width,
height: settings.height,
count: settings.count
}),
textSource: "settings"
},
...(targetNodeId
? [
{
@@ -2637,7 +2621,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const hasUserText = baseContents.some((content) => content.type === "text" && content.text.replace(/\u00a0/g, " ").trim());
const contents =
activeMode === "image"
? withImageModeSettings(baseContents, composerImageSettings, t, selectedGeneratorNode?.id)
? withImageGeneratorTarget(baseContents, selectedGeneratorNode?.id)
: baseContents;
const visibleContents = activeMode === "image" ? baseContents : contents;
if (!project || !hasUserText) return;
@@ -2687,6 +2671,8 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
threadIdType: 9,
imageModel: toImageApiModel(selectedCanvasModel.id),
imageSize: activeMode === "image" ? composerImageSize(composerImageSettings) : undefined,
imageQuality: activeMode === "image" ? composerImageSettings.quality : undefined,
imageCount: activeMode === "image" ? composerImageSettings.count : undefined,
enableWebSearch: enableSearch,
toolConfig: {
preferToolCategories: {