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:
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user