Fix Comfly GPT image sizing
This commit is contained in:
parent
748c468013
commit
a265a77095
@ -23,7 +23,132 @@ const PHASE_TITLES: Record<GenerationPhase, string> = {
|
||||
completed: "生成完成",
|
||||
};
|
||||
|
||||
const ROTATING_TITLES = ["正在创建图片", "生成初稿中", "正在润饰细节"];
|
||||
const GENERATION_STATUS_TITLES = [
|
||||
"正在理解画面关系",
|
||||
"正在读取参考图片",
|
||||
"正在分析主体轮廓",
|
||||
"正在识别关键元素",
|
||||
"正在整理构图线索",
|
||||
"正在确认画面比例",
|
||||
"正在匹配视觉风格",
|
||||
"正在提取材质细节",
|
||||
"正在梳理光影方向",
|
||||
"正在构建初始画面",
|
||||
"正在生成第一版构图",
|
||||
"正在安排主体位置",
|
||||
"正在校准画面重心",
|
||||
"正在补全背景层次",
|
||||
"正在保持主体一致",
|
||||
"正在优化边缘结构",
|
||||
"正在调整透视关系",
|
||||
"正在细化空间层次",
|
||||
"正在增强画面清晰度",
|
||||
"正在统一色彩倾向",
|
||||
"正在检查参考一致性",
|
||||
"正在处理局部细节",
|
||||
"正在补充纹理信息",
|
||||
"正在修正不自然区域",
|
||||
"正在平衡明暗对比",
|
||||
"正在优化产品质感",
|
||||
"正在调整阴影落点",
|
||||
"正在整理背景干扰",
|
||||
"正在增强真实感",
|
||||
"正在重绘细小结构",
|
||||
"正在合成高清结果",
|
||||
"正在检查主体完整性",
|
||||
"正在优化画面边缘",
|
||||
"正在处理反光细节",
|
||||
"正在提升材质表现",
|
||||
"正在微调构图节奏",
|
||||
"正在清理多余噪点",
|
||||
"正在强化视觉焦点",
|
||||
"正在校正颜色偏差",
|
||||
"正在保留参考特征",
|
||||
"正在生成高分辨率版本",
|
||||
"正在压实画面层次",
|
||||
"正在修饰局部瑕疵",
|
||||
"正在检查文字与图形",
|
||||
"正在稳定画面结构",
|
||||
"正在优化细节密度",
|
||||
"正在完善背景过渡",
|
||||
"正在增强主体质感",
|
||||
"正在统一整体光感",
|
||||
"正在检查比例关系",
|
||||
"正在修正边缘锯齿",
|
||||
"正在提升清晰观感",
|
||||
"正在处理最后细节",
|
||||
"正在准备预览结果",
|
||||
"正在同步生成进度",
|
||||
"正在确认输出尺寸",
|
||||
"正在整理结果文件",
|
||||
"正在校验图片可用性",
|
||||
"正在生成最终预览",
|
||||
"正在完成高清渲染",
|
||||
"正在保存生成结果",
|
||||
"正在写入画布节点",
|
||||
"正在同步到对话框",
|
||||
"正在检查结果完整性",
|
||||
"正在收尾生成任务",
|
||||
"正在等待服务商返回",
|
||||
"正在保持连接状态",
|
||||
"正在确认生成结果",
|
||||
"正在拉取图片地址",
|
||||
"正在准备展示图片",
|
||||
"正在完成最后处理",
|
||||
"正在刷新画布内容",
|
||||
"正在整理图片信息",
|
||||
"正在完成本次生成",
|
||||
"正在即将展示结果",
|
||||
];
|
||||
|
||||
const STATUS_TITLE_DURATIONS_MS = GENERATION_STATUS_TITLES.map((_, index) => {
|
||||
const rhythm = [3600, 5200, 4100, 6100, 3300, 4700, 5700, 3900, 6800, 4400];
|
||||
return rhythm[index % rhythm.length];
|
||||
});
|
||||
const TYPEWRITER_START_DELAY_MS = 120;
|
||||
const TYPEWRITER_BASE_STEP_MS = 46;
|
||||
|
||||
function getStatusTitleState(elapsedMs: number, initialTitle?: string) {
|
||||
const titles = initialTitle
|
||||
? [initialTitle, ...GENERATION_STATUS_TITLES.filter((item) => item !== initialTitle)]
|
||||
: GENERATION_STATUS_TITLES;
|
||||
const durations = initialTitle
|
||||
? [4200, ...STATUS_TITLE_DURATIONS_MS]
|
||||
: STATUS_TITLE_DURATIONS_MS;
|
||||
|
||||
let cursor = Math.max(0, elapsedMs);
|
||||
for (let index = 0; index < titles.length; index += 1) {
|
||||
const duration = durations[index] || 4600;
|
||||
if (cursor < duration) {
|
||||
return {
|
||||
index,
|
||||
title: titles[index] || titles[titles.length - 1] || PHASE_TITLES.processing,
|
||||
elapsedInTitle: cursor,
|
||||
};
|
||||
}
|
||||
cursor -= duration;
|
||||
}
|
||||
|
||||
return {
|
||||
index: titles.length - 1,
|
||||
title: titles[titles.length - 1] || PHASE_TITLES.processing,
|
||||
elapsedInTitle: durations[durations.length - 1] || 4600,
|
||||
};
|
||||
}
|
||||
|
||||
function getTypewriterText(text: string, elapsedInTitle: number, index: number) {
|
||||
const chars = Array.from(text);
|
||||
if (chars.length === 0) return "";
|
||||
const step = TYPEWRITER_BASE_STEP_MS + (index % 5) * 8;
|
||||
const visibleCount = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
chars.length,
|
||||
Math.floor(Math.max(0, elapsedInTitle - TYPEWRITER_START_DELAY_MS) / step) + 1
|
||||
)
|
||||
);
|
||||
return chars.slice(0, visibleCount).join("");
|
||||
}
|
||||
|
||||
const scatterVertexShader = `
|
||||
uniform float uTime;
|
||||
@ -251,15 +376,21 @@ export function GenerationDotPlaceholder({
|
||||
const startedAt = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedMs(Date.now() - startedAt);
|
||||
}, 600);
|
||||
}, 90);
|
||||
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 statusTitleState = getStatusTitleState(elapsedMs, title);
|
||||
const rotatingTitle = getTypewriterText(
|
||||
statusTitleState.title,
|
||||
statusTitleState.elapsedInTitle,
|
||||
statusTitleState.index
|
||||
);
|
||||
const resolvedTitle =
|
||||
phase === "processing" || phase === "finalizing" ? rotatingTitle : title || PHASE_TITLES[phase];
|
||||
const dotOpacity = phase === "failed" ? 0.18 : 0.56;
|
||||
|
||||
const progressStyle = useMemo(
|
||||
@ -276,8 +407,41 @@ export function GenerationDotPlaceholder({
|
||||
)}
|
||||
>
|
||||
<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
|
||||
className={cn(
|
||||
"flex items-center gap-2 font-semibold tracking-tight",
|
||||
compact ? "text-[12px] leading-[1.35]" : "text-[18px] leading-[1.35]"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"relative inline-flex shrink-0 items-center justify-center text-[#5b6068]",
|
||||
compact ? "h-5 w-5" : "h-7 w-7"
|
||||
)}
|
||||
>
|
||||
<span className="absolute inset-[18%] rounded-full bg-current opacity-15 blur-[3px]" />
|
||||
<span className="absolute left-1/2 top-1/2 h-[18%] w-[18%] -translate-x-1/2 -translate-y-1/2 rounded-full bg-current opacity-80" />
|
||||
<span className="absolute inset-0 animate-[generationSparkSpin_3.8s_linear_infinite]">
|
||||
<span className="absolute left-1/2 top-[4%] h-[28%] w-[10%] -translate-x-1/2 rounded-full bg-current opacity-80" />
|
||||
<span className="absolute bottom-[4%] left-1/2 h-[28%] w-[10%] -translate-x-1/2 rounded-full bg-current opacity-55" />
|
||||
<span className="absolute left-[4%] top-1/2 h-[10%] w-[28%] -translate-y-1/2 rounded-full bg-current opacity-65" />
|
||||
<span className="absolute right-[4%] top-1/2 h-[10%] w-[28%] -translate-y-1/2 rounded-full bg-current opacity-70" />
|
||||
</span>
|
||||
<span className="absolute inset-[8%] animate-[generationSparkPulse_1.9s_ease-in-out_infinite]">
|
||||
<span className="absolute left-[16%] top-[16%] h-[9%] w-[30%] rotate-45 rounded-full bg-current opacity-55" />
|
||||
<span className="absolute right-[16%] top-[16%] h-[9%] w-[30%] -rotate-45 rounded-full bg-current opacity-65" />
|
||||
<span className="absolute bottom-[16%] left-[16%] h-[9%] w-[30%] -rotate-45 rounded-full bg-current opacity-60" />
|
||||
<span className="absolute bottom-[16%] right-[16%] h-[9%] w-[30%] rotate-45 rounded-full bg-current opacity-50" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
{resolvedTitle}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-1 inline-block h-[1em] w-[0.08em] translate-y-[0.15em] animate-[generationCursorBlink_1s_steps(1,end)_infinite] bg-current"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{showProgress ? (
|
||||
@ -299,6 +463,45 @@ export function GenerationDotPlaceholder({
|
||||
) : null}
|
||||
</div>
|
||||
<ScatterParticleLayer compact={compact} opacity={dotOpacity} />
|
||||
<style jsx>{`
|
||||
@keyframes generationCursorBlink {
|
||||
0%,
|
||||
48% {
|
||||
opacity: 1;
|
||||
}
|
||||
49%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes generationSparkSpin {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(0.9);
|
||||
opacity: 0.55;
|
||||
}
|
||||
45% {
|
||||
transform: rotate(155deg) scale(1.06);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg) scale(0.9);
|
||||
opacity: 0.58;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes generationSparkPulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg) scale(0.72);
|
||||
opacity: 0.35;
|
||||
}
|
||||
50% {
|
||||
transform: rotate(-18deg) scale(1.08);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -40,6 +40,38 @@ const PHASE_HINT: Record<NonNullable<ImageGenerationLoadingProps["phase"]>, stri
|
||||
failed: "生成失败,请重试。",
|
||||
};
|
||||
|
||||
const LONG_PROCESSING_HINTS = [
|
||||
"正在读取参考图与文字需求。",
|
||||
"正在分析主体、场景和构图关系。",
|
||||
"正在提取画面中的关键视觉特征。",
|
||||
"正在确认图片比例和输出尺寸。",
|
||||
"正在匹配当前模型的最佳生成参数。",
|
||||
"正在把参考图信息提交给模型。",
|
||||
"正在等待模型完成第一阶段推理。",
|
||||
"正在生成初始画面结构。",
|
||||
"正在保持主体特征和参考一致。",
|
||||
"正在补全画面中的空间层次。",
|
||||
"正在调整光影方向和明暗关系。",
|
||||
"正在优化主体边缘与背景融合。",
|
||||
"正在细化材质、纹理和局部细节。",
|
||||
"正在减少画面噪点和不自然区域。",
|
||||
"正在提升清晰度与整体质感。",
|
||||
"正在检查生成结果的完整性。",
|
||||
"正在处理高分辨率输出。",
|
||||
"正在等待服务商返回最终图片。",
|
||||
"正在保存图片并同步到对话。",
|
||||
"正在准备把结果显示到画布。",
|
||||
];
|
||||
|
||||
function getLongProcessingHint(phase: NonNullable<ImageGenerationLoadingProps["phase"]>, elapsedSeconds: number) {
|
||||
if (phase !== "processing" && phase !== "finalizing") return PHASE_HINT[phase];
|
||||
const index = Math.min(
|
||||
LONG_PROCESSING_HINTS.length - 1,
|
||||
Math.floor(Math.max(0, elapsedSeconds) / 15)
|
||||
);
|
||||
return LONG_PROCESSING_HINTS[index];
|
||||
}
|
||||
|
||||
function formatImageQueueWaitLabel(estimatedWaitMs?: number) {
|
||||
const numeric = Number(estimatedWaitMs || 0);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
@ -75,6 +107,7 @@ export const ImageGenerationLoading = memo(
|
||||
const estimateRangeLabel = providerType === "grsai-gpt-image" ? "2~5分钟" : "60~120秒";
|
||||
const elapsedLabel =
|
||||
elapsedSeconds > 0 ? `已等待 ${elapsedSeconds}s` : `预计 ${estimateRangeLabel}`;
|
||||
const phaseHint = getLongProcessingHint(phase, elapsedSeconds);
|
||||
const queueLine =
|
||||
phase === "queued" && Number.isFinite(queuePosition)
|
||||
? Math.max(0, Number(queuePosition || 0)) > 0
|
||||
@ -126,7 +159,7 @@ export const ImageGenerationLoading = memo(
|
||||
{queueDetailLine ? <div className="text-[13px]">{queueDetailLine}</div> : null}
|
||||
{(phase !== "processing" || elapsedSeconds > 0) && (
|
||||
<div className="text-[13px]">
|
||||
{PHASE_HINT[phase]} {elapsedLabel}
|
||||
{phaseHint} {elapsedLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -28,6 +28,8 @@ const DEFAULT_TIMEOUT_MS = 300000;
|
||||
const LONG_RUNNING_COMFLY_TIMEOUT_MS = 590000;
|
||||
const OPENAI_IMAGE_MIN_TOTAL_PIXELS = 3_686_400;
|
||||
const OPENAI_IMAGE_DIMENSION_MULTIPLE = 16;
|
||||
const COMFLY_GPT_IMAGE_MAX_EDGE = 3840;
|
||||
const COMFLY_GPT_IMAGE_MAX_TOTAL_PIXELS = 8_294_400;
|
||||
const BLTCY_HOST_REGEX = /(^|\.)bltcy\.ai$/i;
|
||||
const COMFLY_HOST_REGEX = /(^|\.)ai\.comfly\.org$/i;
|
||||
const SUPPORTED_ASPECT_RATIOS = [
|
||||
@ -122,6 +124,10 @@ function ceilToMultiple(value: number, multiple: number) {
|
||||
return Math.max(multiple, Math.ceil(value / multiple) * multiple);
|
||||
}
|
||||
|
||||
function floorToMultiple(value: number, multiple: number) {
|
||||
return Math.max(multiple, Math.floor(value / multiple) * multiple);
|
||||
}
|
||||
|
||||
function inferAspectRatioFromPrompt(prompt: string) {
|
||||
const normalized = String(prompt || "").replace(/:/g, ":");
|
||||
const match = normalized.match(/(\d{1,2})\s*(?::|比)\s*(\d{1,2})/);
|
||||
@ -200,17 +206,40 @@ function resolveOpenAISize(aspectRatio: string, imageSize: "1K" | "2K" | "4K") {
|
||||
return `${width}x${height}`;
|
||||
}
|
||||
|
||||
function resolveLongestEdgeSize(aspectRatio: string, imageSize: "1K" | "2K" | "4K") {
|
||||
function resolveComflyGptImageSize(aspectRatio: string, imageSize: "1K" | "2K" | "4K") {
|
||||
const edgeMap: Record<typeof imageSize, number> = {
|
||||
"1K": 1024,
|
||||
"2K": 2048,
|
||||
"4K": 4096,
|
||||
"4K": COMFLY_GPT_IMAGE_MAX_EDGE,
|
||||
};
|
||||
const ratioPair = parseAspectRatioPair(aspectRatio) || { width: 1, height: 1 };
|
||||
const longestEdge = edgeMap[imageSize] || edgeMap["2K"];
|
||||
const scale = longestEdge / Math.max(ratioPair.width, ratioPair.height);
|
||||
const width = ceilToMultiple(ratioPair.width * scale, OPENAI_IMAGE_DIMENSION_MULTIPLE);
|
||||
const height = ceilToMultiple(ratioPair.height * scale, OPENAI_IMAGE_DIMENSION_MULTIPLE);
|
||||
let width = floorToMultiple(ratioPair.width * scale, OPENAI_IMAGE_DIMENSION_MULTIPLE);
|
||||
let height = floorToMultiple(ratioPair.height * scale, OPENAI_IMAGE_DIMENSION_MULTIPLE);
|
||||
|
||||
while (
|
||||
width > COMFLY_GPT_IMAGE_MAX_EDGE ||
|
||||
height > COMFLY_GPT_IMAGE_MAX_EDGE ||
|
||||
width * height > COMFLY_GPT_IMAGE_MAX_TOTAL_PIXELS
|
||||
) {
|
||||
const scaleDown = Math.min(
|
||||
COMFLY_GPT_IMAGE_MAX_EDGE / width,
|
||||
COMFLY_GPT_IMAGE_MAX_EDGE / height,
|
||||
Math.sqrt(COMFLY_GPT_IMAGE_MAX_TOTAL_PIXELS / (width * height)),
|
||||
1
|
||||
);
|
||||
const nextWidth = floorToMultiple(width * scaleDown, OPENAI_IMAGE_DIMENSION_MULTIPLE);
|
||||
const nextHeight = floorToMultiple(height * scaleDown, OPENAI_IMAGE_DIMENSION_MULTIPLE);
|
||||
|
||||
if (nextWidth === width && nextHeight === height) {
|
||||
if (width >= height) width -= OPENAI_IMAGE_DIMENSION_MULTIPLE;
|
||||
else height -= OPENAI_IMAGE_DIMENSION_MULTIPLE;
|
||||
} else {
|
||||
width = nextWidth;
|
||||
height = nextHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return `${width}x${height}`;
|
||||
}
|
||||
@ -221,7 +250,7 @@ function resolveProviderImageSize(
|
||||
imageSize: "1K" | "2K" | "4K"
|
||||
) {
|
||||
if (isComflyBaseURL(baseURL)) {
|
||||
return resolveLongestEdgeSize(aspectRatio, imageSize);
|
||||
return resolveComflyGptImageSize(aspectRatio, imageSize);
|
||||
}
|
||||
return resolveOpenAISize(aspectRatio, imageSize);
|
||||
}
|
||||
@ -622,7 +651,7 @@ export function createOpenAIImage({ apiKey, baseURL }: OpenAIImageConfig) {
|
||||
imageSize,
|
||||
requestedImageSize,
|
||||
modelImageSize,
|
||||
resolutionMode: isComflyBaseURL(baseURL) ? "longest-edge" : "pixel-area",
|
||||
resolutionMode: isComflyBaseURL(baseURL) ? "comfly-gpt-image-constraints" : "pixel-area",
|
||||
timeoutMs: resolveRequestTimeoutMs(baseURL, imageSize, activeModelId),
|
||||
useBltcyImageCompatParams: shouldUseBltcyImageCompatParams(baseURL),
|
||||
imageCount: imageUrls.length,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user