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
@@ -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" />;
}