Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m0s
- 全新 Landing Page:Canvas 极光动画(5色光球 + additive blending + blur 融合) - 暗角/胶片颗粒/渐变遮罩 4 层视觉架构 - 鼠标推动光球交互(lerp 缓动)+ 呼吸效果 - 登录弹窗(磨砂玻璃 backdrop-filter blur)替代独立登录页 - Air Spark 全屏毛玻璃弹窗 + 音乐彩蛋(SVG 音波 + BGM) - 品牌名 AIRFLOW STUDIO / AI VISUAL NARRATIVE + Space Grotesk 字体 - 路由重构:/ → LandingPage, /login → 自动弹登录框 - 自适应视频播放器比例修复(读取 videoWidth/videoHeight) - adaptive 英文显示改为中文「自适应」 - 首页音乐泄漏修复(组件卸载时 pause+reset) - 登录弹窗添加「目前仅限受邀创作者体验」提示 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
251 lines
8.0 KiB
TypeScript
251 lines
8.0 KiB
TypeScript
import { useRef, useEffect, useCallback } from 'react';
|
|
|
|
/**
|
|
* Aurora background — 5 large diffuse orbs with additive blending.
|
|
* Orbs are deliberately offset from center to avoid uniform "blue blob" look.
|
|
* Mouse gently pushes nearby orbs (10-20px, very subtle).
|
|
*/
|
|
|
|
interface Orb {
|
|
cx: number; // base center X ratio (0-1)
|
|
cy: number; // base center Y ratio (0-1)
|
|
color: [number, number, number];
|
|
alpha: number; // peak alpha — varies per orb for brightness contrast
|
|
phase: number;
|
|
freqX: number;
|
|
freqY: number;
|
|
ampX: number;
|
|
ampY: number;
|
|
radius: number; // ratio of max(w,h)
|
|
// breathing
|
|
breathFreq: number;
|
|
breathAmp: number;
|
|
}
|
|
|
|
// Orbs deliberately NOT centered — some in corners, some offset,
|
|
// creating uneven light distribution with dark pockets.
|
|
const ORBS: Orb[] = [
|
|
// Cyan — upper-left area, large and bright
|
|
{ cx: 0.25, cy: 0.30, color: [126, 220, 200], alpha: 0.28,
|
|
phase: 0, freqX: 0.00012, freqY: 0.00010, ampX: 0.12, ampY: 0.10,
|
|
radius: 0.50, breathFreq: 0.0004, breathAmp: 0.06 },
|
|
// Purple — lower-right, medium
|
|
{ cx: 0.72, cy: 0.65, color: [108, 99, 255], alpha: 0.22,
|
|
phase: 1.8, freqX: 0.00010, freqY: 0.00014, ampX: 0.14, ampY: 0.12,
|
|
radius: 0.45, breathFreq: 0.0003, breathAmp: 0.08 },
|
|
// Blue — center-right, smaller and dimmer (fills gap)
|
|
{ cx: 0.58, cy: 0.38, color: [59, 130, 246], alpha: 0.18,
|
|
phase: 3.2, freqX: 0.00015, freqY: 0.00008, ampX: 0.10, ampY: 0.15,
|
|
radius: 0.38, breathFreq: 0.0005, breathAmp: 0.07 },
|
|
// Light purple — bottom-left corner, dim accent
|
|
{ cx: 0.20, cy: 0.70, color: [167, 139, 250], alpha: 0.15,
|
|
phase: 4.5, freqX: 0.00008, freqY: 0.00012, ampX: 0.16, ampY: 0.08,
|
|
radius: 0.40, breathFreq: 0.00035, breathAmp: 0.10 },
|
|
// Bright cyan — upper-right, small bright accent
|
|
{ cx: 0.78, cy: 0.25, color: [34, 211, 238], alpha: 0.20,
|
|
phase: 5.8, freqX: 0.00014, freqY: 0.00016, ampX: 0.08, ampY: 0.12,
|
|
radius: 0.35, breathFreq: 0.00045, breathAmp: 0.09 },
|
|
];
|
|
|
|
// Mouse influence radius (px) and max push distance (px)
|
|
const MOUSE_RADIUS = 400;
|
|
const MOUSE_PUSH = 28;
|
|
|
|
export function AuroraCanvas() {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const grainRef = useRef<HTMLCanvasElement>(null);
|
|
const mouseRef = useRef({ x: -9999, y: -9999, active: false });
|
|
|
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
|
mouseRef.current.x = e.clientX;
|
|
mouseRef.current.y = e.clientY;
|
|
mouseRef.current.active = true;
|
|
}, []);
|
|
|
|
const handleMouseLeave = useCallback(() => {
|
|
mouseRef.current.active = false;
|
|
}, []);
|
|
|
|
// ── Aurora animation ──
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
let w = window.innerWidth;
|
|
let h = window.innerHeight;
|
|
|
|
function resize() {
|
|
w = window.innerWidth;
|
|
h = window.innerHeight;
|
|
const dpr = w < 768 ? 0.5 : Math.min(window.devicePixelRatio, 1.5);
|
|
canvas!.width = w * dpr;
|
|
canvas!.height = h * dpr;
|
|
canvas!.style.width = w + 'px';
|
|
canvas!.style.height = h + 'px';
|
|
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
}
|
|
|
|
resize();
|
|
|
|
let animId: number;
|
|
const t0 = performance.now();
|
|
// Smoothed mouse position for gentle push
|
|
let smoothMx = -9999;
|
|
let smoothMy = -9999;
|
|
|
|
function draw(now: number) {
|
|
const t = now - t0;
|
|
ctx!.clearRect(0, 0, w, h);
|
|
ctx!.globalCompositeOperation = 'lighter';
|
|
|
|
// Smooth mouse tracking (lerp toward actual position)
|
|
const mouse = mouseRef.current;
|
|
if (mouse.active) {
|
|
smoothMx += (mouse.x - smoothMx) * 0.035;
|
|
smoothMy += (mouse.y - smoothMy) * 0.035;
|
|
} else {
|
|
// Slowly drift smoothed mouse away (return to no-influence)
|
|
smoothMx += (-9999 - smoothMx) * 0.01;
|
|
smoothMy += (-9999 - smoothMy) * 0.01;
|
|
}
|
|
|
|
for (const orb of ORBS) {
|
|
// Base position from slow sinusoidal movement
|
|
let x = w * (orb.cx + Math.sin(t * orb.freqX + orb.phase) * orb.ampX);
|
|
let y = h * (orb.cy + Math.cos(t * orb.freqY + orb.phase * 0.7) * orb.ampY);
|
|
|
|
// Mouse push — gently offset orb away from cursor
|
|
const dx = x - smoothMx;
|
|
const dy = y - smoothMy;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist < MOUSE_RADIUS && dist > 1) {
|
|
const strength = (1 - dist / MOUSE_RADIUS) * MOUSE_PUSH;
|
|
x += (dx / dist) * strength;
|
|
y += (dy / dist) * strength;
|
|
}
|
|
|
|
// Breathing: radius and alpha pulse slowly
|
|
const breathT = Math.sin(t * orb.breathFreq + orb.phase * 1.3);
|
|
const r = Math.max(w, h) * orb.radius * (1 + breathT * orb.breathAmp);
|
|
const a = orb.alpha * (1 + breathT * 0.15);
|
|
|
|
const [cr, cg, cb] = orb.color;
|
|
const grad = ctx!.createRadialGradient(x, y, 0, x, y, r);
|
|
grad.addColorStop(0, `rgba(${cr},${cg},${cb},${(a).toFixed(3)})`);
|
|
grad.addColorStop(0.25, `rgba(${cr},${cg},${cb},${(a * 0.5).toFixed(3)})`);
|
|
grad.addColorStop(0.5, `rgba(${cr},${cg},${cb},${(a * 0.15).toFixed(3)})`);
|
|
grad.addColorStop(0.8, `rgba(${cr},${cg},${cb},${(a * 0.03).toFixed(3)})`);
|
|
grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
|
|
|
|
ctx!.fillStyle = grad;
|
|
ctx!.beginPath();
|
|
ctx!.arc(x, y, r, 0, Math.PI * 2);
|
|
ctx!.fill();
|
|
}
|
|
|
|
ctx!.globalCompositeOperation = 'source-over';
|
|
animId = requestAnimationFrame(draw);
|
|
}
|
|
|
|
animId = requestAnimationFrame(draw);
|
|
window.addEventListener('resize', resize);
|
|
window.addEventListener('mousemove', handleMouseMove, { passive: true });
|
|
document.addEventListener('mouseleave', handleMouseLeave);
|
|
|
|
return () => {
|
|
cancelAnimationFrame(animId);
|
|
window.removeEventListener('resize', resize);
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseleave', handleMouseLeave);
|
|
};
|
|
}, [handleMouseMove, handleMouseLeave]);
|
|
|
|
// ── Film grain — 4 FPS low-noise ──
|
|
useEffect(() => {
|
|
const canvas = grainRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
canvas.width = 256;
|
|
canvas.height = 256;
|
|
|
|
let animId: number;
|
|
let lastFrame = 0;
|
|
|
|
function drawGrain(now: number) {
|
|
if (now - lastFrame > 250) {
|
|
lastFrame = now;
|
|
const img = ctx!.createImageData(256, 256);
|
|
const d = img.data;
|
|
for (let i = 0; i < d.length; i += 4) {
|
|
const v = Math.random() * 255;
|
|
d[i] = d[i + 1] = d[i + 2] = v;
|
|
d[i + 3] = 255;
|
|
}
|
|
ctx!.putImageData(img, 0, 0);
|
|
}
|
|
animId = requestAnimationFrame(drawGrain);
|
|
}
|
|
|
|
animId = requestAnimationFrame(drawGrain);
|
|
return () => cancelAnimationFrame(animId);
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
{/* Layer 1: Vignette — radial darkening, heavy at edges */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
zIndex: 1,
|
|
background: 'radial-gradient(ellipse 65% 55% at 50% 50%, transparent 0%, rgba(0,0,0,0.8) 100%)',
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
|
|
{/* Layer 2: Film grain */}
|
|
<canvas
|
|
ref={grainRef}
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
zIndex: 2,
|
|
width: '100%',
|
|
height: '100%',
|
|
opacity: 0.035,
|
|
pointerEvents: 'none',
|
|
mixBlendMode: 'overlay',
|
|
}}
|
|
/>
|
|
|
|
{/* Layer 3: Aurora — blur merges orbs into organic glow */}
|
|
<canvas
|
|
ref={canvasRef}
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
zIndex: 3,
|
|
filter: 'blur(50px)',
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
|
|
{/* Layer 4: Top/bottom gradient mask */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
zIndex: 4,
|
|
pointerEvents: 'none',
|
|
background:
|
|
'linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.5) 100%)',
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|