diff --git a/components/generation-dot-placeholder.tsx b/components/generation-dot-placeholder.tsx index f3778a829..a55d2cd6f 100644 --- a/components/generation-dot-placeholder.tsx +++ b/components/generation-dot-placeholder.tsx @@ -23,7 +23,132 @@ const PHASE_TITLES: Record = { 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({ )} >
-
- {resolvedTitle} +
+
{showProgress ? ( @@ -299,6 +463,45 @@ export function GenerationDotPlaceholder({ ) : null}
+
); } diff --git a/components/image-generation-loading.tsx b/components/image-generation-loading.tsx index 469564688..d3ff25c31 100644 --- a/components/image-generation-loading.tsx +++ b/components/image-generation-loading.tsx @@ -40,6 +40,38 @@ const PHASE_HINT: Record, stri failed: "生成失败,请重试。", }; +const LONG_PROCESSING_HINTS = [ + "正在读取参考图与文字需求。", + "正在分析主体、场景和构图关系。", + "正在提取画面中的关键视觉特征。", + "正在确认图片比例和输出尺寸。", + "正在匹配当前模型的最佳生成参数。", + "正在把参考图信息提交给模型。", + "正在等待模型完成第一阶段推理。", + "正在生成初始画面结构。", + "正在保持主体特征和参考一致。", + "正在补全画面中的空间层次。", + "正在调整光影方向和明暗关系。", + "正在优化主体边缘与背景融合。", + "正在细化材质、纹理和局部细节。", + "正在减少画面噪点和不自然区域。", + "正在提升清晰度与整体质感。", + "正在检查生成结果的完整性。", + "正在处理高分辨率输出。", + "正在等待服务商返回最终图片。", + "正在保存图片并同步到对话。", + "正在准备把结果显示到画布。", +]; + +function getLongProcessingHint(phase: NonNullable, 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 ?
{queueDetailLine}
: null} {(phase !== "processing" || elapsedSeconds > 0) && (
- {PHASE_HINT[phase]} {elapsedLabel} + {phaseHint} {elapsedLabel}
)}
diff --git a/lib/ai/providers/openai-image.ts b/lib/ai/providers/openai-image.ts index ff4c0d4c1..f44033961 100644 --- a/lib/ai/providers/openai-image.ts +++ b/lib/ai/providers/openai-image.ts @@ -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 = { "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,