305 lines
9.9 KiB
TypeScript
305 lines
9.9 KiB
TypeScript
"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;
|
|
}
|
|
|
|
const PHASE_TITLES: Record<GenerationPhase, string> = {
|
|
idle: "正在创建图片",
|
|
queued: "等待开始生成",
|
|
processing: "生成初稿中",
|
|
finalizing: "正在润饰细节",
|
|
failed: "生成失败",
|
|
completed: "生成完成",
|
|
};
|
|
|
|
const ROTATING_TITLES = ["正在创建图片", "生成初稿中", "正在润饰细节"];
|
|
|
|
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 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,
|
|
}: {
|
|
compact: boolean;
|
|
opacity: number;
|
|
}) {
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
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: compact ? 76 : 94 },
|
|
uEffectIntensity: { value: 1 },
|
|
uOpacity: { value: opacity },
|
|
},
|
|
});
|
|
|
|
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();
|
|
};
|
|
}
|
|
|
|
setup();
|
|
return () => {
|
|
cancelled = true;
|
|
cleanup();
|
|
};
|
|
}, [compact, opacity]);
|
|
|
|
return <div ref={containerRef} className="absolute inset-0" />;
|
|
}
|
|
|
|
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.18 : 0.56;
|
|
|
|
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>
|
|
<ScatterParticleLayer compact={compact} opacity={dotOpacity} />
|
|
</div>
|
|
);
|
|
}
|