improve canvas generation placeholders

This commit is contained in:
134 2026-06-12 11:14:18 +08:00
parent 36654a6fa3
commit 1947aae685
10 changed files with 409 additions and 89 deletions

View File

@ -16,7 +16,7 @@ import {
type ImageToolCreditFeature,
} from "@/lib/points/config";
export const maxDuration = 120;
export const maxDuration = 600;
interface GenerateImageRequest extends CanvasGenerateImageRequest {
async?: boolean;

View File

@ -1036,6 +1036,7 @@ export function CanvasChatPanel({
} | null>(null);
const [streamImageGenerationStateByChatId, setStreamImageGenerationStateByChatId] =
useState<Record<string, ImageGenerationStatusStreamPayload | null>>({});
const canvasGenerationPlaceholderByChatIdRef = useRef<Record<string, string>>({});
const streamImageGenerationState = useMemo(
() => streamImageGenerationStateByChatId[activeChatId] || null,
[activeChatId, streamImageGenerationStateByChatId]
@ -3247,6 +3248,29 @@ export function CanvasChatPanel({
// 合并 initialMessages 和历史消息
const mergedInitialMessages = historyData?.messages ?? (activeChatId === chatId ? initialMessages : []);
const removeCanvasGenerationPlaceholders = useCallback(
(chatSessionId?: string) => {
const state = useCanvasStore.getState();
const ids = state.nodes
.filter((node) => {
if (node.type !== "image") return false;
const metadata = ((node.data as any)?.metadata || {}) as Record<string, unknown>;
if (metadata.canvasGenerationPlaceholder !== true) return false;
if (!chatSessionId) return true;
return String(metadata.chatId || "") === chatSessionId;
})
.map((node) => node.id);
ids.forEach((nodeId) => deleteNode(nodeId));
if (chatSessionId) {
delete canvasGenerationPlaceholderByChatIdRef.current[chatSessionId];
} else {
canvasGenerationPlaceholderByChatIdRef.current = {};
}
},
[deleteNode]
);
const appendStreamMediaToCanvas = useCallback(
async (payload: CanvasMediaStreamPayload) => {
const imageUrls = Array.isArray(payload.imageUrls)
@ -3259,6 +3283,8 @@ export function CanvasChatPanel({
return;
}
removeCanvasGenerationPlaceholders();
const latestNodes = useCanvasStore.getState().nodes;
const suppressedMediaKeys = new Set(
(useCanvasStore.getState().getCanvasState().suppressedMediaKeys || [])
@ -3420,7 +3446,7 @@ export function CanvasChatPanel({
}
},
[addNode, autoNameGeneratedImageNode]
[addNode, autoNameGeneratedImageNode, removeCanvasGenerationPlaceholders]
);
const {
@ -3545,6 +3571,7 @@ export function CanvasChatPanel({
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return;
void appendStreamMediaToCanvas(payload);
clearStreamImageGenerationStateForChat(eventChatId);
} catch (e) {
console.error("[CanvasChat] Failed to parse data-canvasMedia:", e);
}
@ -7862,6 +7889,107 @@ export function CanvasChatPanel({
streamImageGenerationState?.sameModelRunningCount;
const loadingConcurrencyLimit = streamImageGenerationState?.concurrencyLimit;
const loadingEstimatedWaitMs = streamImageGenerationState?.estimatedWaitMs;
useEffect(() => {
const streamState = streamImageGenerationStateByChatId[activeChatId] || null;
const placeholderId = canvasGenerationPlaceholderByChatIdRef.current[activeChatId];
if (!streamState) {
removeCanvasGenerationPlaceholders(activeChatId);
return;
}
const phase = streamState.phase || "processing";
const label =
phase === "queued"
? "等待开始生成"
: phase === "finalizing"
? "正在润饰细节"
: phase === "failed"
? "生成失败"
: "正在创建图片";
const status = phase === "failed" ? "failed" : "generating";
const imageParamSnapshot = imageParamsRef.current || imageParams;
const aspectRatio =
imageParamSnapshot.aspectRatio && imageParamSnapshot.aspectRatio !== "auto"
? imageParamSnapshot.aspectRatio
: "1:1";
const [ratioWidthRaw, ratioHeightRaw] = aspectRatio.split(":").map((item) => Number(item));
const ratioWidth = Number.isFinite(ratioWidthRaw) && ratioWidthRaw > 0 ? ratioWidthRaw : 1;
const ratioHeight = Number.isFinite(ratioHeightRaw) && ratioHeightRaw > 0 ? ratioHeightRaw : 1;
const baseSide = 340;
const placeholderSize =
ratioWidth >= ratioHeight
? {
width: baseSide,
height: Math.max(190, Math.round((baseSide * ratioHeight) / ratioWidth)),
}
: {
width: Math.max(190, Math.round((baseSide * ratioWidth) / ratioHeight)),
height: baseSide,
};
const metadata = {
canvasGenerationPlaceholder: true,
chatId: activeChatId,
detailProgressLabel: label,
generationProgress:
phase === "queued" ? 12 : phase === "finalizing" ? 96 : phase === "failed" ? 100 : undefined,
modelName: streamState.modelName || currentModel?.displayName || undefined,
providerType: streamState.providerType || currentModel?.providerType || undefined,
};
if (placeholderId) {
const existingNode = useCanvasStore
.getState()
.nodes.find((node) => node.id === placeholderId);
if (existingNode) {
updateNode(placeholderId, {
data: {
...((existingNode.data as any) || {}),
src: "",
status,
title: label,
prompt: label,
metadata: {
...(((existingNode.data as any)?.metadata || {}) as Record<string, unknown>),
...metadata,
},
},
size: placeholderSize,
});
return;
}
delete canvasGenerationPlaceholderByChatIdRef.current[activeChatId];
}
const currentNodes = useCanvasStore.getState().nodes;
const existingNodes = currentNodes.length;
const nodeId = addNode({
type: "image",
position: {
x: 100 + (existingNodes % 3) * (placeholderSize.width + 20),
y: 100 + Math.floor(existingNodes / 3) * (placeholderSize.height + 20),
},
size: placeholderSize,
data: {
src: "",
status,
title: label,
prompt: label,
metadata,
},
connections: [],
});
canvasGenerationPlaceholderByChatIdRef.current[activeChatId] = nodeId;
}, [
activeChatId,
addNode,
currentModel?.displayName,
currentModel?.providerType,
imageParams,
removeCanvasGenerationPlaceholders,
streamImageGenerationStateByChatId,
updateNode,
]);
const renderableVisibleMessages = useMemo(() => {
return visibleMessages.reduce<ChatMessage[]>((acc, message) => {
const rawParts = (message.parts || []) as any[];

View File

@ -1277,6 +1277,11 @@ export function ImageNode({
typeof data.metadata?.detailProgressLabel === "string"
? data.metadata.detailProgressLabel.trim()
: "";
const generationProgress =
typeof data.metadata?.generationProgress === "number" &&
Number.isFinite(data.metadata.generationProgress)
? Math.max(0, Math.min(100, Math.round(data.metadata.generationProgress)))
: null;
const flipRotateOriginalSrc =
data.metadata && typeof data.metadata === "object"
? String((data.metadata as Record<string, unknown>).flipRotateOriginalSrc || "").trim()
@ -8301,6 +8306,7 @@ export function ImageNode({
<ImageNodeEmptyState
detailProgressLabel={detailProgressLabel}
fileInputRef={fileInputRef}
generationProgress={generationProgress}
onImageUpload={handleImageUpload}
onSelectFile={handleSelectFile}
status={data.status}

View File

@ -2,10 +2,12 @@
import { AlertCircle, ImagePlus, Loader2 } from "lucide-react";
import type React from "react";
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
interface ImageNodeEmptyStateProps {
detailProgressLabel?: string;
fileInputRef: React.Ref<HTMLInputElement>;
generationProgress?: number | null;
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
status?: string;
@ -38,21 +40,20 @@ export function ImageNodeProcessingOverlay() {
export function ImageNodeEmptyState({
detailProgressLabel,
fileInputRef,
generationProgress,
onImageUpload,
onSelectFile,
status,
}: ImageNodeEmptyStateProps) {
if (status === "generating") {
return (
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center dark:bg-zinc-900">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="select-none px-3 text-sm font-medium text-[#3f4b63]">
{detailProgressLabel || "生成中..."}
</span>
<span className="mt-1 text-xs text-[#7a8395]">图片处理中</span>
</div>
<GenerationDotPlaceholder
className="h-full w-full rounded-none"
compact
phase="processing"
progress={generationProgress}
title={detailProgressLabel || undefined}
/>
);
}

View File

@ -0,0 +1,152 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
type GenerationPhase = "idle" | "queued" | "processing" | "finalizing" | "failed" | "completed";
interface GenerationDotPlaceholderProps {
className?: string;
title?: string;
phase?: GenerationPhase;
progress?: number | null;
compact?: boolean;
showProgress?: boolean;
}
const PHASE_TITLES: Record<GenerationPhase, string> = {
idle: "正在创建图片",
queued: "等待开始生成",
processing: "生成初稿中",
finalizing: "正在润饰细节",
failed: "生成失败",
completed: "生成完成",
};
const ROTATING_TITLES = ["正在创建图片", "生成初稿中", "正在润饰细节"];
function clampProgress(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
}
function estimateProgress(phase: GenerationPhase, elapsedMs: number) {
if (phase === "completed") return 100;
if (phase === "failed") return 100;
if (phase === "finalizing") return 96;
if (phase === "queued") {
return clampProgress(6 + Math.min(12, elapsedMs / 3500));
}
if (phase === "processing") {
const eased = 1 - Math.exp(-elapsedMs / 85_000);
return clampProgress(18 + eased * 78);
}
return clampProgress(Math.min(8, elapsedMs / 1000));
}
export function GenerationDotPlaceholder({
className,
title,
phase = "processing",
progress,
compact = false,
showProgress = true,
}: GenerationDotPlaceholderProps) {
const [elapsedMs, setElapsedMs] = useState(0);
useEffect(() => {
const startedAt = Date.now();
const timer = window.setInterval(() => {
setElapsedMs(Date.now() - startedAt);
}, 600);
return () => window.clearInterval(timer);
}, [phase]);
const resolvedProgress = clampProgress(
typeof progress === "number" ? progress : estimateProgress(phase, elapsedMs)
);
const rotatingTitle = ROTATING_TITLES[Math.floor(elapsedMs / 3200) % ROTATING_TITLES.length];
const resolvedTitle = title || (phase === "processing" ? rotatingTitle : PHASE_TITLES[phase]);
const dotOpacity = phase === "failed" ? 0.22 : 0.34;
const progressStyle = useMemo(
() => ({ width: `${resolvedProgress}%` }),
[resolvedProgress]
);
return (
<div
className={cn(
"relative isolate overflow-hidden rounded-[32px] bg-[#f4f4f4] text-[#565b63]",
compact ? "min-h-[190px] p-5" : "min-h-[360px] p-8",
className
)}
>
<div className="relative z-10 flex h-full min-h-[inherit] flex-col">
<div className={cn("font-semibold tracking-tight", compact ? "text-[15px]" : "text-[22px]")}>
{resolvedTitle}
</div>
<div className="flex-1" />
{showProgress ? (
<div className="space-y-2">
<div className="flex items-center justify-between text-[12px] font-medium text-[#7d838b]">
<span>{phase === "failed" ? "已停止" : "生成进度"}</span>
<span>{resolvedProgress}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-white/80 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)]">
<div
className={cn(
"h-full rounded-full transition-[width] duration-700 ease-out",
phase === "failed" ? "bg-[#c7ccd3]" : "bg-[#9fa4aa]"
)}
style={progressStyle}
/>
</div>
</div>
) : null}
</div>
<div
className="generation-dot-field generation-dot-field-a absolute inset-0"
style={{ opacity: dotOpacity }}
/>
<div
className="generation-dot-field generation-dot-field-b absolute inset-0"
style={{ opacity: dotOpacity * 0.9 }}
/>
<style jsx>{`
.generation-dot-field {
background-image: radial-gradient(circle, rgba(40, 44, 50, 0.42) 1.35px, transparent 1.55px);
background-size: ${compact ? "14px 14px" : "18px 18px"};
animation: generation-dot-drift 7.5s ease-in-out infinite alternate;
mask-image:
radial-gradient(circle at 32% 69%, black 0 19%, rgba(0, 0, 0, 0.72) 27%, transparent 48%),
radial-gradient(circle at 68% 36%, black 0 15%, rgba(0, 0, 0, 0.58) 25%, transparent 45%);
-webkit-mask-image:
radial-gradient(circle at 32% 69%, black 0 19%, rgba(0, 0, 0, 0.72) 27%, transparent 48%),
radial-gradient(circle at 68% 36%, black 0 15%, rgba(0, 0, 0, 0.58) 25%, transparent 45%);
mask-composite: add;
-webkit-mask-composite: source-over;
}
.generation-dot-field-b {
background-size: ${compact ? "18px 18px" : "23px 23px"};
animation-duration: 9s;
animation-direction: alternate-reverse;
filter: blur(0.1px);
transform: translate3d(6px, -4px, 0);
}
@keyframes generation-dot-drift {
0% {
background-position: 0 0;
transform: translate3d(-3px, 2px, 0) scale(0.985);
}
100% {
background-position: 26px 18px;
transform: translate3d(5px, -4px, 0) scale(1.015);
}
}
`}</style>
</div>
);
}

View File

@ -1,8 +1,8 @@
"use client";
import { memo } from "react";
import { ChevronDown, Grid2x2, CircleDot } from "lucide-react";
import { cn } from "@/lib/utils";
import { ChevronDown, CircleDot } from "lucide-react";
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
type ColorTheme = "purple" | "blue" | "green" | "orange" | "red";
@ -72,10 +72,9 @@ export const ImageGenerationLoading = memo(
const count = Math.min(Math.max(imageCount, 1), maxImages);
const resolvedModelName =
modelName?.trim() || PROVIDER_MODEL_LABEL[providerType || ""] || "AI 图片模型";
const estimateRangeLabel = "60~120秒";
const estimateRangeLabel = providerType === "grsai-gpt-image" ? "2~5分钟" : "60~120秒";
const elapsedLabel =
elapsedSeconds > 0 ? `已等待 ${elapsedSeconds}s` : `预计 ${estimateRangeLabel}`;
const isFailed = phase === "failed";
const queueLine =
phase === "queued" && Number.isFinite(queuePosition)
? Math.max(0, Number(queuePosition || 0)) > 0
@ -98,7 +97,7 @@ export const ImageGenerationLoading = memo(
data-role="assistant"
data-testid="message-assistant-image-loading"
>
<div className="w-full max-w-[560px] space-y-2.5">
<div className="w-full max-w-[520px] space-y-2.5">
<div className="flex items-center gap-2 text-[13px] text-[#9aa3b2]">
<ChevronDown className="h-4 w-4" />
<span className="font-medium">开始生成</span>
@ -109,38 +108,11 @@ export const ImageGenerationLoading = memo(
<span className="text-[14px]">{resolvedModelName}</span>
</div>
<div
className={cn(
"relative overflow-hidden rounded-[22px] border border-[#e1e4ea] bg-[#eaedf2]",
count === 1 ? "h-[420px] w-full max-w-[340px]" : "grid gap-3 p-3",
count > 1 && "grid-cols-2"
)}
>
{count === 1 ? (
<>
<div className="absolute inset-0 bg-gradient-to-r from-[#e9ecf1] via-[#dfe4eb] to-[#e9ecf1] opacity-90" />
<div className="generation-skeleton-shimmer absolute inset-0 bg-[linear-gradient(105deg,transparent_0%,rgba(255,255,255,0.45)_48%,transparent_100%)]" />
</>
) : (
Array.from({ length: count }).map((_, index) => (
<div
key={index}
className="relative h-[180px] overflow-hidden rounded-2xl border border-[#dfe3e9] bg-[#e7ebf0]"
>
<div className="absolute inset-0 bg-gradient-to-r from-[#e7ebf0] via-[#dce2ea] to-[#e7ebf0]" />
<div
className="generation-skeleton-shimmer absolute inset-0 bg-[linear-gradient(105deg,transparent_0%,rgba(255,255,255,0.42)_48%,transparent_100%)]"
style={{ animationDelay: `${index * 0.2}s` }}
/>
</div>
))
)}
</div>
<div className="flex items-center gap-2 pt-1 text-[13px] text-[#9aa3b2]">
<Grid2x2 className="h-5 w-5" />
<span className="font-medium">{isFailed ? "生成失败" : "设计图处理中"}</span>
</div>
<GenerationDotPlaceholder
className={count === 1 ? "h-[420px] w-full max-w-[420px]" : "h-[360px] w-full"}
compact={count > 1}
phase={phase}
/>
<div className="space-y-0.5 text-[#9aa3b2]">
<div className="text-[13px] font-medium">
@ -155,22 +127,6 @@ export const ImageGenerationLoading = memo(
)}
</div>
</div>
<style jsx>{`
.generation-skeleton-shimmer {
transform: translateX(-100%);
animation: generation-shimmer 1.8s ease-in-out infinite;
}
@keyframes generation-shimmer {
0% {
transform: translateX(-110%);
}
100% {
transform: translateX(110%);
}
}
`}</style>
</div>
);
}

View File

@ -122,14 +122,14 @@ export function ImageParamsBar({
};
const getImageCountOptions = () => {
if (inlineTheme === "minimal") {
return Array.from({ length: 10 }, (_, i) => i + 1);
}
if (imageGenConfig?.maxImages) {
return Array.from({ length: imageGenConfig.maxImages }, (_, i) => i + 1);
}
if (inlineTheme === "minimal") {
return Array.from({ length: 10 }, (_, i) => i + 1);
}
if (providerType === "ruxa-image") {
return [1];
}
@ -154,22 +154,25 @@ export function ImageParamsBar({
// Gemini:1~2 张
return [1, 2];
};
const isImageSizeAllowed = (s: ImageSizeValue) => {
if (!imageGenConfig) return true;
if (s === "1K") return imageGenConfig.supports1K !== false;
if (s === "2K") return imageGenConfig.supports2K !== false;
if (s === "4K") return imageGenConfig.supports4K !== false;
return true;
};
const getFallbackImageSize = () =>
(["2K", "1K", "4K"] as const).find(isImageSizeAllowed) || "2K";
const clampParamsByProvider = (raw: ImageGenerationParams): ImageGenerationParams => {
const options = getImageCountOptions();
const fallbackCount = options[0] ?? 1;
const imageCount = options.includes(raw.imageCount || fallbackCount) ? (raw.imageCount || fallbackCount) : fallbackCount;
// 根据模型配置校正分辨率:如果当前选择的档位被禁用,则降级到可用的档位
let imageSize = normalizeImageSizeValue(raw.imageSize);
if (imageGenConfig) {
const sizeAllowed = (s: ImageSizeValue) => {
if (s === "1K") return imageGenConfig.supports1K !== false;
if (s === "2K") return imageGenConfig.supports2K !== false;
if (s === "4K") return imageGenConfig.supports4K !== false;
return true;
};
if (!sizeAllowed(imageSize)) {
imageSize = (["2K", "1K", "4K"] as const).find(sizeAllowed) || "2K";
}
if (!isImageSizeAllowed(imageSize)) {
imageSize = getFallbackImageSize();
}
return {
...raw,
@ -218,11 +221,7 @@ export function ImageParamsBar({
// 根据模型配置过滤可用的分辨率档位
const availableSizeOptions = IMAGE_SIZE_OPTIONS.filter((option) => {
if (option.disabled) return false;
if (!imageGenConfig) return true; // 无配置时全部显示
if (option.value === "1K" && imageGenConfig.supports1K === false) return false;
if (option.value === "2K" && imageGenConfig.supports2K === false) return false;
if (option.value === "4K" && imageGenConfig.supports4K === false) return false;
return true;
return isImageSizeAllowed(option.value);
});
const getComputedOutputSize = (aspectRatio: string, imageSize: string) => {
@ -337,8 +336,10 @@ export function ImageParamsBar({
{ label: "16:9(4K)", aspectRatio: "16:9", imageSize: "4K" },
{ label: "9:16(4K)", aspectRatio: "9:16", imageSize: "4K" },
{ label: "auto", aspectRatio: "auto", imageSize: params.imageSize || "1K" },
] as const;
const imageCountOptions = Array.from({ length: 10 }, (_, index) => index + 1);
].filter((option) =>
option.aspectRatio === "auto" || isImageSizeAllowed(normalizeImageSizeValue(option.imageSize))
);
const imageCountOptions = getImageCountOptions();
const selectedAspectLabel = params.aspectRatio === "auto" ? "auto" : params.aspectRatio;
const selectedImageCount = params.imageCount || 1;
const renderMinimalRatioGlyph = (ratioValue: string) => {

View File

@ -533,8 +533,8 @@ function formatImageResponse(
const GRSAI_NANO_BANANA_SUBMIT_TIMEOUT_MS = 30000;
const GRSAI_NANO_BANANA_POLL_TIMEOUT_MS = 30000;
const GRSAI_NANO_BANANA_POLL_INTERVAL_MS = 5000;
const GRSAI_GPT_IMAGE_TIMEOUT_MS = 300000;
const GRSAI_GPT_IMAGE_4K_TIMEOUT_MS = 420000;
const GRSAI_GPT_IMAGE_TIMEOUT_MS = 540000;
const GRSAI_GPT_IMAGE_4K_TIMEOUT_MS = 570000;
function isRecord(value: unknown): value is Record<string, any> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));

View File

@ -75,6 +75,8 @@ export function classifyAiModelHealthFailure(error: unknown): {
normalized.includes("review result") ||
normalized.includes("content policy") ||
normalized.includes("safety") ||
normalized.includes("generate image failed") ||
normalized.includes("could not generate images with the given prompts and images") ||
normalized.includes("user abort") ||
normalized.includes("aborted") ||
normalized.includes("cancelled")

View File

@ -310,6 +310,60 @@ type CanvasModelWithProvider = Awaited<
ReturnType<typeof getEnabledAiModelsWithProvider>
>[number];
type CanvasImageSizeValue = "1K" | "2K" | "4K";
interface CanvasImageGenConfig {
maxImages?: number;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
}
function normalizeCanvasImageSizeValue(
value?: string,
fallback: CanvasImageSizeValue = "2K"
): CanvasImageSizeValue {
const normalized = String(value || "").trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
return normalized;
}
return fallback;
}
function isCanvasImageSizeSupported(
config: CanvasImageGenConfig | null | undefined,
size: CanvasImageSizeValue
) {
if (!config) return true;
if (size === "1K") return config.supports1K !== false;
if (size === "2K") return config.supports2K !== false;
if (size === "4K") return config.supports4K !== false;
return true;
}
function normalizeCanvasImageParamsByModel(params: {
imageCount: number;
imageSize: string;
imageGenConfig: CanvasImageGenConfig | null | undefined;
}) {
const maxImages =
typeof params.imageGenConfig?.maxImages === "number" &&
Number.isFinite(params.imageGenConfig.maxImages)
? Math.max(1, Math.floor(params.imageGenConfig.maxImages))
: 12;
const imageCount = Math.max(1, Math.min(maxImages, Math.floor(params.imageCount)));
let imageSize = normalizeCanvasImageSizeValue(params.imageSize);
if (!isCanvasImageSizeSupported(params.imageGenConfig, imageSize)) {
imageSize =
(["2K", "1K", "4K"] as const).find((candidate) =>
isCanvasImageSizeSupported(params.imageGenConfig, candidate)
) || "2K";
}
return { imageCount, imageSize };
}
function getCanvasModelRefKeys(item: CanvasModelWithProvider) {
return [
String(item.model.id || "").trim(),
@ -376,11 +430,11 @@ export async function generateCanvasImage(
typeof request.aspectRatio === "string" && request.aspectRatio.trim()
? request.aspectRatio.trim()
: "auto";
const imageSize =
const requestedImageSize =
typeof request.imageSize === "string" && request.imageSize.trim()
? request.imageSize.trim()
: "2K";
const imageCount =
const requestedImageCount =
typeof request.imageCount === "number" && Number.isFinite(request.imageCount)
? Math.max(1, Math.min(12, Math.floor(request.imageCount)))
: 1;
@ -454,6 +508,26 @@ export async function generateCanvasImage(
provider = fallbackProvider;
}
const normalizedImageParams = normalizeCanvasImageParamsByModel({
imageCount: requestedImageCount,
imageSize: requestedImageSize,
imageGenConfig: selectedModel.model.imageGenConfig as CanvasImageGenConfig | null | undefined,
});
const imageSize = normalizedImageParams.imageSize;
const imageCount = normalizedImageParams.imageCount;
if (imageSize !== requestedImageSize || imageCount !== requestedImageCount) {
console.info("[Canvas Generate Image] Normalized model image params:", {
debugLabel,
requestedImageSize,
normalizedImageSize: imageSize,
requestedImageCount,
normalizedImageCount: imageCount,
selectedModelRef: selectedModel.model.id,
selectedModelId: selectedModel.model.modelId,
providerType: provider.type,
});
}
const normalizedReferenceImages = Array.from(
new Set(
[