"use client"; import { useEffect, useMemo, useRef, 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; viewportScale?: number; } const PHASE_TITLES: Record = { 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 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 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 { index, title: titles[index] || titles[0] || PHASE_TITLES.processing, elapsedInTitle: elapsedAfterThreshold % duration, }; } 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; uniform float uPointSize; uniform float uEffectIntensity; attribute vec3 randomOffset; varying float vDistance; vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } vec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0) * x); } float snoise(vec2 v) { const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439); vec2 i = floor(v + dot(v, C.yy)); vec2 x0 = v - i + dot(i, C.xx); vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1; i = mod289(i); vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0)); vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); m = m * m; m = m * m; vec3 x = 2.0 * fract(p * C.www) - 1.0; vec3 h = abs(x) - 0.5; vec3 ox = floor(x + 0.5); vec3 a0 = x - ox; m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); vec3 g; g.x = a0.x * x0.x + h.x * x0.y; g.yz = a0.yz * x12.xz + h.yz * x12.yw; return 130.0 * dot(m, g); } void main() { vec3 pos = position; vec3 scatterDir = normalize(pos + randomOffset * 0.5); float scatterDist = length(pos) * 0.5 + randomOffset.x * 3.0; vec3 scattered = pos + scatterDir * scatterDist * uEffectIntensity * 1.85; float turb = snoise(pos.xy * 0.34 + uTime * 0.45); scattered += vec3(turb, turb * 0.55, turb * 0.35) * uEffectIntensity * 1.2; pos = mix(pos, scattered, 0.72); vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); float dist = length(pos); vDistance = dist; gl_PointSize = (uPointSize / -mvPosition.z) * (1.0 + sin(uTime * 3.0 + dist * 0.16) * 0.42); gl_Position = projectionMatrix * mvPosition; } `; const scatterFragmentShader = ` uniform float uTime; uniform float uOpacity; varying float vDistance; void main() { float dist = distance(gl_PointCoord, vec2(0.5)); if (dist > 0.5) discard; float edge = 1.0 - smoothstep(0.42, 0.5, dist); float core = 0.92 + (1.0 - smoothstep(0.0, 0.34, dist)) * 0.08; float shimmer = 0.78 + sin(vDistance * 0.33 + uTime) * 0.18; gl_FragColor = vec4(vec3(0.11, 0.12, 0.13), edge * core * shimmer * uOpacity); } `; function clampProgress(value: number) { if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(100, Math.round(value))); } function normalizeViewportScale(value: number | undefined) { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { return 1; } return Math.max(0.1, Math.min(4, value)); } function getParticlePointSize(compact: boolean, viewportScale: number) { return (compact ? 76 : 94) * viewportScale; } 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)); } function ScatterParticleLayer({ compact, opacity, viewportScale, }: { compact: boolean; opacity: number; viewportScale: number; }) { const containerRef = useRef(null); const pointSizeUniformRef = useRef<{ value: number } | null>(null); const viewportScaleRef = useRef(viewportScale); useEffect(() => { viewportScaleRef.current = viewportScale; if (pointSizeUniformRef.current) { pointSizeUniformRef.current.value = getParticlePointSize( compact, viewportScale ); } }, [compact, viewportScale]); useEffect(() => { let cancelled = false; let cleanup = () => {}; async function setup() { const container = containerRef.current; if (!container) return; const THREE = await import("three"); if (cancelled || !containerRef.current) return; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 1000); camera.position.set(0, 0, compact ? 48 : 44); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true, powerPreference: "high-performance", }); renderer.setClearColor(0xffffff, 0); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2.5)); renderer.domElement.style.display = "block"; renderer.domElement.style.height = "100%"; renderer.domElement.style.width = "100%"; container.appendChild(renderer.domElement); const particleCount = compact ? 7200 : 12800; const positions = new Float32Array(particleCount * 3); const randomOffsets = new Float32Array(particleCount * 3); for (let i = 0; i < particleCount; i += 1) { const i3 = i * 3; const t = (Math.random() - 0.5) * 4.7; const angle = Math.random() * Math.PI * 2; const radiusBase = 0.35 + Math.pow(Math.abs(t), 2.18); const radius = radiusBase * (0.74 + Math.random() * 0.58); positions[i3] = Math.cos(angle) * radius * 2.25; positions[i3 + 1] = t * (compact ? 5.2 : 6.8); positions[i3 + 2] = Math.sin(angle) * radius * 2.25; randomOffsets[i3] = (Math.random() - 0.5) * 2; randomOffsets[i3 + 1] = (Math.random() - 0.5) * 2; randomOffsets[i3 + 2] = (Math.random() - 0.5) * 2; } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("randomOffset", new THREE.BufferAttribute(randomOffsets, 3)); const material = new THREE.ShaderMaterial({ vertexShader: scatterVertexShader, fragmentShader: scatterFragmentShader, transparent: true, depthWrite: false, blending: THREE.NormalBlending, uniforms: { uTime: { value: 0 }, uPointSize: { value: getParticlePointSize(compact, viewportScaleRef.current), }, uEffectIntensity: { value: 1 }, uOpacity: { value: opacity }, }, }); pointSizeUniformRef.current = material.uniforms.uPointSize as { value: number; }; const points = new THREE.Points(geometry, material); points.rotation.x = -0.06; const baseX = 0; const baseY = 0; points.position.set(baseX, baseY, 0); scene.add(points); const resize = () => { const rect = container.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); }; resize(); const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(container); let frameId = 0; let time = 0; const animate = () => { time += 0.008; points.rotation.y += 0.0048; points.rotation.z += 0.0015; points.rotation.x = -0.08 + Math.sin(time * 0.17) * 0.18; points.position.x = baseX + Math.sin(time * 0.16) * 0.04; points.position.y = baseY + Math.sin(time * 0.22) * 0.04; material.uniforms.uTime.value = time; renderer.render(scene, camera); frameId = window.requestAnimationFrame(animate); }; animate(); cleanup = () => { window.cancelAnimationFrame(frameId); resizeObserver.disconnect(); geometry.dispose(); material.dispose(); renderer.dispose(); renderer.domElement.remove(); pointSizeUniformRef.current = null; }; } setup(); return () => { cancelled = true; cleanup(); }; }, [compact, opacity]); return
; } export function GenerationDotPlaceholder({ className, title, phase = "processing", progress, compact = false, showProgress = true, viewportScale, }: GenerationDotPlaceholderProps) { const [elapsedMs, setElapsedMs] = useState(0); const resolvedViewportScale = normalizeViewportScale(viewportScale); 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 dotOpacity = phase === "failed" ? 0.18 : 0.56; const progressStyle = useMemo( () => ({ width: `${resolvedProgress}%` }), [resolvedProgress] ); return (
{showProgress ? (
{phase === "failed" ? "已停止" : "生成进度"} {resolvedProgress}%
) : null}
); }