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