336 lines
9.8 KiB
TypeScript
336 lines
9.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { BloubAvatar } from "@/components/bloub-avatar";
|
|
import type { StateId } from "@/lib/bloub-official/states";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type GenerationPhase =
|
|
| "idle"
|
|
| "queued"
|
|
| "processing"
|
|
| "finalizing"
|
|
| "failed"
|
|
| "completed";
|
|
|
|
interface GenerationDotPlaceholderProps {
|
|
className?: string;
|
|
compact?: boolean;
|
|
phase?: GenerationPhase;
|
|
progress?: number | null;
|
|
showProgress?: boolean;
|
|
title?: string;
|
|
viewportScale?: number;
|
|
}
|
|
|
|
const PHASE_TITLES: Record<GenerationPhase, string> = {
|
|
idle: "正在创建图片",
|
|
queued: "等待开始生成",
|
|
processing: "生成初稿中",
|
|
finalizing: "正在润饰细节",
|
|
failed: "生成失败",
|
|
completed: "生成完成",
|
|
};
|
|
|
|
const GENERATION_STATUS_TITLES = [
|
|
"正在理解画面关系",
|
|
"正在读取参考图片",
|
|
"正在分析主体轮廓",
|
|
"正在识别关键元素",
|
|
"正在整理构图线索",
|
|
"正在确认画面比例",
|
|
"正在匹配视觉风格",
|
|
"正在提取材质细节",
|
|
"正在梳理光影方向",
|
|
"正在构建初始画面",
|
|
"正在生成第一版构图",
|
|
"正在安排主体位置",
|
|
"正在校准画面重心",
|
|
"正在补全背景层次",
|
|
"正在保持主体一致",
|
|
"正在优化边缘结构",
|
|
"正在调整透视关系",
|
|
"正在细化空间层次",
|
|
"正在增强画面清晰度",
|
|
"正在统一色彩倾向",
|
|
"正在检查参考一致性",
|
|
"正在处理局部细节",
|
|
"正在补充纹理信息",
|
|
"正在修正不自然区域",
|
|
"正在平衡明暗对比",
|
|
"正在优化产品质感",
|
|
"正在调整阴影落点",
|
|
"正在整理背景干扰",
|
|
"正在增强真实感",
|
|
"正在重绘细小结构",
|
|
"正在合成高清结果",
|
|
"正在检查主体完整性",
|
|
"正在优化画面边缘",
|
|
"正在处理反光细节",
|
|
"正在提升材质表现",
|
|
"正在微调构图节奏",
|
|
"正在清理多余噪点",
|
|
"正在强化视觉焦点",
|
|
"正在校正颜色偏差",
|
|
"正在保留参考特征",
|
|
"正在生成高分辨率版本",
|
|
"正在压实画面层次",
|
|
"正在修饰局部瑕疵",
|
|
"正在检查文字与图形",
|
|
"正在稳定画面结构",
|
|
"正在优化细节密度",
|
|
"正在完善背景过渡",
|
|
"正在增强主体质感",
|
|
"正在统一整体光感",
|
|
"正在检查比例关系",
|
|
"正在修正边缘锯齿",
|
|
"正在提升清晰观感",
|
|
"正在处理最后细节",
|
|
"正在准备预览结果",
|
|
"正在同步生成进度",
|
|
"正在确认输出尺寸",
|
|
"正在整理结果文件",
|
|
"正在校验图片可用性",
|
|
"正在生成最终预览",
|
|
"正在完成高清渲染",
|
|
"正在保存生成结果",
|
|
"正在写入画布节点",
|
|
"正在同步到对话框",
|
|
"正在检查结果完整性",
|
|
"正在收尾生成任务",
|
|
"正在等待服务商返回",
|
|
"正在保持连接状态",
|
|
"正在确认生成结果",
|
|
"正在拉取图片地址",
|
|
"正在准备展示图片",
|
|
"正在完成最后处理",
|
|
"正在刷新画布内容",
|
|
"正在整理图片信息",
|
|
"正在完成本次生成",
|
|
"正在即将展示结果",
|
|
];
|
|
|
|
const LONG_WAIT_TITLE_THRESHOLD_MS = 5 * 60 * 1000;
|
|
const LONG_WAIT_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 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" || 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));
|
|
}
|
|
|
|
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 {
|
|
elapsedInTitle: cursor,
|
|
index,
|
|
title:
|
|
titles[index] ||
|
|
titles[titles.length - 1] ||
|
|
PHASE_TITLES.processing,
|
|
};
|
|
}
|
|
cursor -= duration;
|
|
}
|
|
|
|
return {
|
|
elapsedInTitle: durations[durations.length - 1] || 4600,
|
|
index: titles.length - 1,
|
|
title: titles[titles.length - 1] || PHASE_TITLES.processing,
|
|
};
|
|
}
|
|
|
|
function getLongWaitTitleState(elapsedMs: number, initialTitle?: string) {
|
|
const titles = initialTitle
|
|
? [
|
|
initialTitle,
|
|
...LONG_WAIT_STATUS_TITLES.filter((item) => item !== initialTitle),
|
|
]
|
|
: LONG_WAIT_STATUS_TITLES;
|
|
const elapsedAfterThreshold = Math.max(
|
|
0,
|
|
elapsedMs - LONG_WAIT_TITLE_THRESHOLD_MS
|
|
);
|
|
const duration = 7600;
|
|
const index = Math.floor(elapsedAfterThreshold / duration) % titles.length;
|
|
|
|
return {
|
|
elapsedInTitle: elapsedAfterThreshold % duration,
|
|
index,
|
|
title: titles[index] || titles[0] || PHASE_TITLES.processing,
|
|
};
|
|
}
|
|
|
|
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("");
|
|
}
|
|
|
|
function getBloubState(phase: GenerationPhase): StateId {
|
|
if (phase === "failed") return "alert";
|
|
if (phase === "completed") return "wink";
|
|
if (phase === "queued") return "thinking";
|
|
return "idle";
|
|
}
|
|
|
|
export function GenerationDotPlaceholder({
|
|
className,
|
|
compact = false,
|
|
phase = "processing",
|
|
progress,
|
|
showProgress = true,
|
|
title,
|
|
}: GenerationDotPlaceholderProps) {
|
|
const [elapsedMs, setElapsedMs] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const startedAt = Date.now();
|
|
const timer = window.setInterval(() => {
|
|
setElapsedMs(Date.now() - startedAt);
|
|
}, 90);
|
|
return () => window.clearInterval(timer);
|
|
}, [phase]);
|
|
|
|
const resolvedProgress = clampProgress(
|
|
typeof progress === "number" ? progress : estimateProgress(phase, elapsedMs)
|
|
);
|
|
const isLongWaitTitle =
|
|
(phase === "processing" || phase === "finalizing") &&
|
|
elapsedMs >= LONG_WAIT_TITLE_THRESHOLD_MS;
|
|
const statusTitleState = isLongWaitTitle
|
|
? getLongWaitTitleState(elapsedMs, title)
|
|
: getStatusTitleState(elapsedMs, title);
|
|
const rotatingTitle = getTypewriterText(
|
|
statusTitleState.title,
|
|
statusTitleState.elapsedInTitle,
|
|
statusTitleState.index
|
|
);
|
|
const resolvedTitle =
|
|
phase === "processing" || phase === "finalizing"
|
|
? rotatingTitle
|
|
: title || PHASE_TITLES[phase];
|
|
const playSequence = phase === "processing" || phase === "finalizing";
|
|
const progressStyle = useMemo(
|
|
() => ({ width: `${resolvedProgress}%` }),
|
|
[resolvedProgress]
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative isolate overflow-hidden rounded-[24px] bg-[#f4f4f4] text-[#565b63] dark:bg-[#08090b] dark:text-zinc-200",
|
|
compact ? "min-h-[190px] p-5" : "min-h-[360px] p-8",
|
|
className
|
|
)}
|
|
>
|
|
<div className="flex h-full min-h-[inherit] flex-col">
|
|
<div
|
|
aria-live="polite"
|
|
className={cn(
|
|
"font-semibold",
|
|
compact
|
|
? "text-[12px] leading-[1.35]"
|
|
: "text-[18px] leading-[1.35]"
|
|
)}
|
|
>
|
|
{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"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-1 items-center justify-center py-3">
|
|
<BloubAvatar
|
|
label={resolvedTitle}
|
|
playSequence={playSequence}
|
|
size={compact ? 88 : 120}
|
|
state={getBloubState(phase)}
|
|
surface="page"
|
|
/>
|
|
</div>
|
|
|
|
{showProgress ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-[12px] font-medium text-[#7d838b] dark:text-zinc-400">
|
|
<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)] dark:bg-white/10 dark:shadow-none">
|
|
<div
|
|
className={cn(
|
|
"h-full rounded-full transition-[width] duration-700 ease-out",
|
|
phase === "failed"
|
|
? "bg-[#c7ccd3] dark:bg-zinc-600"
|
|
: "bg-[#0a0a0c] dark:bg-[#3ecf8e]"
|
|
)}
|
|
style={progressStyle}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<style jsx>{`
|
|
@keyframes generationCursorBlink {
|
|
0%,
|
|
48% {
|
|
opacity: 1;
|
|
}
|
|
49%,
|
|
100% {
|
|
opacity: 0;
|
|
}
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|