Animation pipeline: AnimeScene engine, Ghibli-style compositions, audio energy tool, and README showcase
Add anime_scene rendering engine (AnimeScene + ParticleOverlay components) with multi-image crossfade, 9 camera motion types, 5 particle systems, and cinematic lighting overlays. Fix critical Remotion durationInFrames footgun by passing sceneDurationSeconds from parent. Add audio offset/loop support in Explainer for skipping quiet music intros. New tools: audio_energy.py analyzes per-second loudness via ebur128 to find optimal music offset and detect when looping is needed. Update all 6 animation pipeline skills (proposal, scene, asset, compose, executive-producer, remotion.md) with battle-tested image_animation workflow including tool availability scan, FLUX multi-image generation, composition JSON format, pre-render validation, and post-render self-review. Add 3 demo compositions (Candyland, Mori no Seishin, Deep Ocean) and anime-ghibli style playbook. Update README with 3 anime video showcases and animation prompts. Add Animation Pipeline section to PROMPT_GALLERY.md.
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Img,
|
||||
interpolate,
|
||||
spring,
|
||||
staticFile,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { ParticleOverlay, type ParticleType } from "./ParticleOverlay";
|
||||
|
||||
/**
|
||||
* Resolve asset path — use staticFile() for local paths, passthrough URLs.
|
||||
* Duplicated from Explainer.tsx to keep the component self-contained.
|
||||
*/
|
||||
function resolveAsset(src: string): string {
|
||||
if (
|
||||
src.startsWith("http://") ||
|
||||
src.startsWith("https://") ||
|
||||
src.startsWith("data:")
|
||||
) {
|
||||
return src;
|
||||
}
|
||||
const clean = src.replace(/^file:\/\/\/?/, "");
|
||||
return staticFile(clean);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CameraMotion =
|
||||
| "zoom-in"
|
||||
| "zoom-out"
|
||||
| "pan-left"
|
||||
| "pan-right"
|
||||
| "ken-burns"
|
||||
| "drift-up"
|
||||
| "drift-down"
|
||||
| "parallax"
|
||||
| "static";
|
||||
|
||||
export interface AnimeSceneProps {
|
||||
/** Array of 1-4 image paths — crossfaded sequentially within the scene */
|
||||
images: string[];
|
||||
/** Camera motion applied to all image layers */
|
||||
animation?: CameraMotion;
|
||||
/** Particle effect overlay */
|
||||
particles?: ParticleType;
|
||||
/** Particle color (default: warm yellow) */
|
||||
particleColor?: string;
|
||||
/** Number of particles (default: 20) */
|
||||
particleCount?: number;
|
||||
/** Particle opacity multiplier 0-1 (default: 0.6) */
|
||||
particleIntensity?: number;
|
||||
/** Scene background color behind images (default: dark navy) */
|
||||
backgroundColor?: string;
|
||||
/** Show cinematic vignette (default: true) */
|
||||
vignette?: boolean;
|
||||
/** Starting gradient color for animated lighting shift */
|
||||
lightingFrom?: string;
|
||||
/** Ending gradient color for animated lighting shift */
|
||||
lightingTo?: string;
|
||||
/**
|
||||
* Actual scene duration in seconds.
|
||||
* CRITICAL: useVideoConfig().durationInFrames returns the FULL composition
|
||||
* duration, not the Sequence duration. This prop provides the real scene
|
||||
* length so crossfade/camera/lighting calculations use the correct range.
|
||||
*/
|
||||
sceneDurationSeconds?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cinematic vignette — slightly stronger than the Explainer default
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AnimeVignette: React.FC = () => (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse at center, transparent 35%, rgba(0,0,0,0.6) 100%)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Camera motion calculator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useCameraMotion(animation: CameraMotion, effectiveDuration: number) {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
const progress = interpolate(frame, [0, effectiveDuration], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
let scale = 1;
|
||||
let translateX = 0;
|
||||
let translateY = 0;
|
||||
|
||||
switch (animation) {
|
||||
case "zoom-in":
|
||||
scale = 1 + progress * 0.15;
|
||||
break;
|
||||
case "zoom-out":
|
||||
scale = 1.15 - progress * 0.15;
|
||||
break;
|
||||
case "pan-left":
|
||||
translateX = interpolate(progress, [0, 1], [35, -35]);
|
||||
scale = 1.12;
|
||||
break;
|
||||
case "pan-right":
|
||||
translateX = interpolate(progress, [0, 1], [-35, 35]);
|
||||
scale = 1.12;
|
||||
break;
|
||||
case "ken-burns":
|
||||
scale = 1 + progress * 0.18;
|
||||
translateX = interpolate(progress, [0, 1], [0, -22]);
|
||||
translateY = interpolate(progress, [0, 1], [0, -14]);
|
||||
break;
|
||||
case "drift-up":
|
||||
translateY = interpolate(progress, [0, 1], [22, -22]);
|
||||
scale = 1.1;
|
||||
break;
|
||||
case "drift-down":
|
||||
translateY = interpolate(progress, [0, 1], [-22, 22]);
|
||||
scale = 1.1;
|
||||
break;
|
||||
case "parallax":
|
||||
translateY = interpolate(progress, [0, 1], [14, -14]);
|
||||
translateX = interpolate(progress, [0, 1], [6, -6]);
|
||||
scale = 1.12;
|
||||
break;
|
||||
case "static":
|
||||
default:
|
||||
scale = 1.02; // tiny scale to avoid edge artifacts
|
||||
break;
|
||||
}
|
||||
|
||||
return { scale, translateX, translateY };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AnimeScene: React.FC<AnimeSceneProps> = ({
|
||||
images,
|
||||
animation = "ken-burns",
|
||||
particles,
|
||||
particleColor = "#FFE082",
|
||||
particleCount = 20,
|
||||
particleIntensity = 0.6,
|
||||
backgroundColor = "#0A0A1A",
|
||||
vignette = true,
|
||||
lightingFrom,
|
||||
lightingTo,
|
||||
sceneDurationSeconds,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
// CRITICAL FIX: useVideoConfig().durationInFrames returns the FULL
|
||||
// composition duration (e.g. 930 for a 31s video), NOT the Sequence
|
||||
// duration (e.g. 150 for a 5s scene). This caused multi-image crossfade
|
||||
// segments to span the wrong range, making images invisible.
|
||||
const effectiveDuration = sceneDurationSeconds
|
||||
? Math.round(sceneDurationSeconds * fps)
|
||||
: durationInFrames;
|
||||
|
||||
const { scale, translateX, translateY } = useCameraMotion(
|
||||
animation,
|
||||
effectiveDuration
|
||||
);
|
||||
|
||||
const imageCount = images.length;
|
||||
|
||||
// Cross-fade duration in frames (~1.2 seconds)
|
||||
const crossfadeDur = Math.round(fps * 1.2);
|
||||
|
||||
/**
|
||||
* Compute opacity for image at index `idx`.
|
||||
*
|
||||
* Single image → simple spring fade-in, gentle fade-out at end.
|
||||
* Multi-image → each image fades in at its segment start and fades out
|
||||
* as the next image fades in. Creates a continuous morph
|
||||
* that simulates subtle motion within the scene.
|
||||
*/
|
||||
const getOpacity = (idx: number): number => {
|
||||
// Scene-level fade-in (first 0.5s) and fade-out (last 0.3s)
|
||||
const sceneIn = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 18, stiffness: 80 },
|
||||
});
|
||||
const sceneOut = interpolate(
|
||||
frame,
|
||||
[effectiveDuration - 10, effectiveDuration],
|
||||
[1, 0.25],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
if (imageCount <= 1) {
|
||||
return sceneIn * sceneOut;
|
||||
}
|
||||
|
||||
// Each image owns a time segment; crossfade regions OVERLAP so there's
|
||||
// never a gap where both images are at zero opacity.
|
||||
//
|
||||
// Segment boundaries: [0, segDur, 2*segDur, ...]
|
||||
// Image N fades OUT over [segEnd - xfade, segEnd]
|
||||
// Image N+1 fades IN over [segEnd - xfade, segEnd] (same window!)
|
||||
//
|
||||
// This ensures a smooth blend at every boundary.
|
||||
const segmentDur = effectiveDuration / imageCount;
|
||||
const segStart = idx * segmentDur;
|
||||
const segEnd = segStart + segmentDur;
|
||||
|
||||
// Fade in — first image uses spring, others overlap with prev image's fade-out
|
||||
const fadeIn =
|
||||
idx === 0
|
||||
? sceneIn
|
||||
: interpolate(
|
||||
frame,
|
||||
[segStart - crossfadeDur, segStart],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
// Fade out — last image uses scene-level fade; others fade as next blends in
|
||||
const fadeOut =
|
||||
idx === imageCount - 1
|
||||
? sceneOut
|
||||
: interpolate(
|
||||
frame,
|
||||
[segEnd - crossfadeDur, segEnd],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return Math.max(0, Math.min(1, fadeIn * fadeOut));
|
||||
};
|
||||
|
||||
// Lighting shift progress
|
||||
const lightProgress = interpolate(frame, [0, effectiveDuration], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const lightOpacity =
|
||||
lightingFrom && lightingTo
|
||||
? interpolate(lightProgress, [0, 0.3, 0.7, 1], [0, 0.25, 0.25, 0.1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
})
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ overflow: "hidden", background: backgroundColor }}>
|
||||
{/* Layer 1: Image stack with crossfade + camera motion */}
|
||||
{images.map((src, i) => (
|
||||
<AbsoluteFill key={i}>
|
||||
<Img
|
||||
src={resolveAsset(src)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
opacity: getOpacity(i),
|
||||
transform: `scale(${scale}) translate(${translateX}px, ${translateY}px)`,
|
||||
willChange: "transform, opacity",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
))}
|
||||
|
||||
{/* Layer 2: Animated lighting gradient */}
|
||||
{lightingFrom && lightingTo && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${lightingFrom}, ${lightingTo})`,
|
||||
opacity: lightOpacity,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Layer 3: Cinematic vignette */}
|
||||
{vignette && <AnimeVignette />}
|
||||
|
||||
{/* Layer 4: Particle effects */}
|
||||
{particles && (
|
||||
<ParticleOverlay
|
||||
type={particles}
|
||||
count={particleCount}
|
||||
color={particleColor}
|
||||
intensity={particleIntensity}
|
||||
/>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
/**
|
||||
* Deterministic pseudo-random based on seed index.
|
||||
* Produces the same value every frame for the same seed — required for Remotion.
|
||||
*/
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed * 12.9898 + seed * 78.233) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
export type ParticleType =
|
||||
| "fireflies"
|
||||
| "petals"
|
||||
| "sparkles"
|
||||
| "mist"
|
||||
| "light-rays";
|
||||
|
||||
interface ParticleOverlayProps {
|
||||
type: ParticleType;
|
||||
count?: number;
|
||||
color?: string;
|
||||
intensity?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fireflies — glowing dots on sine-wave paths with pulsing opacity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Fireflies: React.FC<{
|
||||
count: number;
|
||||
color: string;
|
||||
intensity: number;
|
||||
}> = ({ count, color, intensity }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
const globalFadeIn = interpolate(frame, [0, fps * 0.8], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const globalFadeOut = interpolate(
|
||||
frame,
|
||||
[durationInFrames - fps * 0.5, durationInFrames],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ pointerEvents: "none" }}>
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
const baseX = seededRandom(i * 7 + 1) * 90 + 5;
|
||||
const baseY = seededRandom(i * 13 + 2) * 80 + 10;
|
||||
const speed = 0.4 + seededRandom(i * 3 + 5) * 1.2;
|
||||
const phase = seededRandom(i * 11 + 3) * Math.PI * 2;
|
||||
const size = 3 + seededRandom(i * 17 + 4) * 7;
|
||||
|
||||
const t = (frame / fps) * speed;
|
||||
const xOffset = Math.sin(t + phase) * 25;
|
||||
const yOffset = Math.cos(t * 0.7 + phase) * 18;
|
||||
const glowPulse = 0.3 + (Math.sin(t * 2.5 + phase) * 0.35 + 0.35);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `calc(${baseX}% + ${xOffset}px)`,
|
||||
top: `calc(${baseY}% + ${yOffset}px)`,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: color,
|
||||
opacity: glowPulse * intensity * globalFadeIn * globalFadeOut,
|
||||
boxShadow: `0 0 ${size * 3}px ${size * 1.5}px ${color}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Petals — elliptical shapes drifting diagonally with rotation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Petals: React.FC<{
|
||||
count: number;
|
||||
color: string;
|
||||
intensity: number;
|
||||
}> = ({ count, color, intensity }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
const globalFadeOut = interpolate(
|
||||
frame,
|
||||
[durationInFrames - fps * 0.5, durationInFrames],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ pointerEvents: "none", overflow: "hidden" }}>
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
const startX = seededRandom(i * 7 + 1) * 110 - 5;
|
||||
const speed = 0.3 + seededRandom(i * 3 + 5) * 0.5;
|
||||
const phase = seededRandom(i * 11 + 3) * Math.PI * 2;
|
||||
const size = 5 + seededRandom(i * 17 + 4) * 9;
|
||||
const delay = seededRandom(i * 19 + 6) * durationInFrames * 0.6;
|
||||
|
||||
const elapsed = Math.max(0, frame - delay);
|
||||
const t = (elapsed / fps) * speed;
|
||||
|
||||
const x = startX + Math.sin(t * 1.3 + phase) * 12 + t * 8;
|
||||
const y = -5 + t * 35;
|
||||
const rotation = t * 50 + phase * 57.3;
|
||||
|
||||
const fadeIn = interpolate(
|
||||
frame,
|
||||
[delay, delay + fps * 0.4],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
if (y > 110) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${x}%`,
|
||||
top: `${y}%`,
|
||||
width: size,
|
||||
height: size * 0.55,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: color,
|
||||
opacity: fadeIn * globalFadeOut * intensity * 0.75,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sparkles — brief cross-shaped flashes at staggered timings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Sparkles: React.FC<{
|
||||
count: number;
|
||||
color: string;
|
||||
intensity: number;
|
||||
}> = ({ count, color, intensity }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ pointerEvents: "none" }}>
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
const x = seededRandom(i * 7 + 1) * 90 + 5;
|
||||
const y = seededRandom(i * 13 + 2) * 85 + 5;
|
||||
const size = 6 + seededRandom(i * 17 + 4) * 10;
|
||||
|
||||
const cycleLen = Math.round(fps * (1.2 + seededRandom(i * 23 + 8) * 2));
|
||||
const offset = Math.round(seededRandom(i * 29 + 9) * durationInFrames);
|
||||
const cycleFrame =
|
||||
((frame - offset) % cycleLen + cycleLen) % cycleLen;
|
||||
|
||||
const sparkleAlpha = interpolate(
|
||||
cycleFrame,
|
||||
[0, cycleLen * 0.15, cycleLen * 0.4, cycleLen],
|
||||
[0, 1, 0.2, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
const sparkleScale = interpolate(
|
||||
cycleFrame,
|
||||
[0, cycleLen * 0.25, cycleLen],
|
||||
[0.3, 1, 0.6],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${x}%`,
|
||||
top: `${y}%`,
|
||||
width: size,
|
||||
height: size,
|
||||
opacity: sparkleAlpha * intensity,
|
||||
transform: `scale(${sparkleScale}) rotate(45deg)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: 2,
|
||||
top: "50%",
|
||||
marginTop: -1,
|
||||
backgroundColor: color,
|
||||
borderRadius: 1,
|
||||
boxShadow: `0 0 ${size * 0.8}px ${color}`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 2,
|
||||
height: "100%",
|
||||
left: "50%",
|
||||
marginLeft: -1,
|
||||
backgroundColor: color,
|
||||
borderRadius: 1,
|
||||
boxShadow: `0 0 ${size * 0.8}px ${color}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mist — translucent gradient layers drifting horizontally
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Mist: React.FC<{
|
||||
count: number;
|
||||
color: string;
|
||||
intensity: number;
|
||||
}> = ({ count, color, intensity }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const layers = Math.min(count, 5);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ pointerEvents: "none", overflow: "hidden" }}>
|
||||
{Array.from({ length: layers }, (_, i) => {
|
||||
const baseY = 55 + seededRandom(i * 7 + 1) * 35;
|
||||
const speed = 0.8 + seededRandom(i * 13 + 2) * 1.2;
|
||||
const xDrift = ((frame / fps) * speed * 3) % 200 - 50;
|
||||
const pulse = 0.08 + Math.sin(frame / fps * 0.4 + i * 1.8) * 0.05;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${xDrift}%`,
|
||||
top: `${baseY}%`,
|
||||
width: "160%",
|
||||
height: "25%",
|
||||
background: `radial-gradient(ellipse at center, rgba(255,255,255,${pulse}) 0%, transparent 70%)`,
|
||||
opacity: intensity,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Light Rays — angled gradient beams with gentle pulsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LightRays: React.FC<{
|
||||
count: number;
|
||||
color: string;
|
||||
intensity: number;
|
||||
}> = ({ count, color, intensity }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
const rays = Math.min(count, 5);
|
||||
|
||||
const globalFadeIn = interpolate(frame, [0, fps * 1.2], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ pointerEvents: "none", overflow: "hidden" }}>
|
||||
{Array.from({ length: rays }, (_, i) => {
|
||||
const angle = -35 + seededRandom(i * 7 + 1) * 25;
|
||||
const xPos = 15 + seededRandom(i * 13 + 2) * 65;
|
||||
const beamWidth = 4 + seededRandom(i * 17 + 3) * 8;
|
||||
const pulse =
|
||||
0.06 + Math.sin(frame / fps * 0.6 + i * 2.2) * 0.04;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${xPos}%`,
|
||||
top: "-10%",
|
||||
width: `${beamWidth}%`,
|
||||
height: "120%",
|
||||
background: `linear-gradient(180deg, rgba(255,255,240,${pulse}) 0%, transparent 80%)`,
|
||||
transform: `rotate(${angle}deg)`,
|
||||
transformOrigin: "top center",
|
||||
opacity: intensity * globalFadeIn,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main export — dispatches to the right particle renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ParticleOverlay: React.FC<ParticleOverlayProps> = ({
|
||||
type,
|
||||
count = 20,
|
||||
color = "#FFE082",
|
||||
intensity = 0.6,
|
||||
}) => {
|
||||
switch (type) {
|
||||
case "fireflies":
|
||||
return <Fireflies count={count} color={color} intensity={intensity} />;
|
||||
case "petals":
|
||||
return <Petals count={count} color={color} intensity={intensity} />;
|
||||
case "sparkles":
|
||||
return <Sparkles count={count} color={color} intensity={intensity} />;
|
||||
case "mist":
|
||||
return <Mist count={count} color={color} intensity={intensity} />;
|
||||
case "light-rays":
|
||||
return <LightRays count={count} color={color} intensity={intensity} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -8,3 +8,7 @@ export { CaptionOverlay } from "./CaptionOverlay";
|
||||
export { SectionTitle } from "./SectionTitle";
|
||||
export { StatReveal } from "./StatReveal";
|
||||
export { HeroTitle } from "./HeroTitle";
|
||||
export { ParticleOverlay } from "./ParticleOverlay";
|
||||
export { AnimeScene } from "./AnimeScene";
|
||||
export type { ParticleType } from "./ParticleOverlay";
|
||||
export type { CameraMotion, AnimeSceneProps } from "./AnimeScene";
|
||||
|
||||
Reference in New Issue
Block a user