diff --git a/frontend/src/domain/design.ts b/frontend/src/domain/design.ts index d4cb1c4..3055058 100644 --- a/frontend/src/domain/design.ts +++ b/frontend/src/domain/design.ts @@ -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"; diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index f4fba6f..43fbd63 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -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", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index e1f7b04..ddd4e51 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -251,6 +251,7 @@ "qualityHigh": "高", "qualityMedium": "中", "qualityLow": "低", + "imageResolutionLabel": "基准分辨率", "imageSizeLabel": "尺寸", "imageModeDirective": "图片生成参数:质量 {quality},尺寸 {width}x{height},比例 {ratio},数量 {count} 张。纯生图模式,请直接围绕用户输入生成图片,不展开额外聊天。", "quickEdit": "快捷编辑", diff --git a/frontend/src/infrastructure/designGateway.ts b/frontend/src/infrastructure/designGateway.ts index 70ae6b6..0195d97 100644 --- a/frontend/src/infrastructure/designGateway.ts +++ b/frontend/src/infrastructure/designGateway.ts @@ -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; }; diff --git a/frontend/src/ui/components/CanvasAgentComposer/index.css b/frontend/src/ui/components/CanvasAgentComposer/index.css index 988ca7d..f7783da 100644 --- a/frontend/src/ui/components/CanvasAgentComposer/index.css +++ b/frontend/src/ui/components/CanvasAgentComposer/index.css @@ -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; } diff --git a/frontend/src/ui/components/CanvasAgentComposer/index.tsx b/frontend/src/ui/components/CanvasAgentComposer/index.tsx index cce0896..38263e0 100644 --- a/frontend/src/ui/components/CanvasAgentComposer/index.tsx +++ b/frontend/src/ui/components/CanvasAgentComposer/index.tsx @@ -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 { - 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({ ))} +
+

{t("imageResolutionLabel")}

+
+ {resolutionOptions.map((option) => ( + + ))} +
+

{t("imageSizeLabel")}

@@ -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