import React, { useMemo, useState, useEffect, useRef } from 'react'; import { useCurrentFrame, useVideoConfig, interpolate, Easing, AbsoluteFill, delayRender, continueRender, } from 'remotion'; import * as THREE from 'three'; // ============================================================================= // COMPOSITION CONFIG // ============================================================================= export const compositionConfig = { id: 'ImageMorph3D', durationInSeconds: 5, fps: 60, width: 1080, height: 1920, }; // ============================================================================= // ⚡ TEMPLATE CONFIGURATION — CHANGE THESE ⚡ // ============================================================================= const IMAGE_A_URL = 'https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1080&q=80'; const IMAGE_B_URL = 'https://images.unsplash.com/photo-1511884642898-4c92249e20b6?w=1080&q=80'; const MORPH_CONFIG = { /** When the morph transition begins (seconds) */ startTime: 1.0, /** Duration of the morph transition (seconds) */ duration: 2.5, /** RGB glow color at dissolve edges [r, g, b] — values 0–1 */ edgeGlowColor: [0.5, 0.7, 1.0] as [number, number, number], /** Intensity of the edge glow (0–2) */ edgeGlowIntensity: 1.0, /** How much the mesh warps outward during morph (0–5) */ displacementStrength: 1.8, /** Scale of the noise pattern — lower = larger blobs (1–10) */ noiseScale: 4.0, /** Width of the soft dissolve edge (0.05–0.3) */ edgeWidth: 0.12, /** Background color behind the morph */ backgroundColor: '#050510', /** Number of floating particles */ particleCount: 80, }; // ============================================================================= // STYLE CONSTANTS // ============================================================================= const EASINGS = { gentle: Easing.bezier(0.25, 0.1, 0.25, 1), morphIn: Easing.bezier(0.4, 0, 0.2, 1), morphOut: Easing.bezier(0.8, 0, 0.6, 1), easeInOut: Easing.bezier(0.37, 0, 0.63, 1), }; // ============================================================================= // SEEDED RANDOM // ============================================================================= const seededRandom = (seed: number): number => { const x = Math.sin(seed * 9999) * 10000; return x - Math.floor(x); }; // ============================================================================= // GLSL SHADERS // ============================================================================= const VERTEX_SHADER = /* glsl */ ` uniform float uProgress; uniform float uTime; uniform float uDisplacementStrength; varying vec2 vUv; varying float vDisplacement; // Simplex 2D noise vec3 mod289v3(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } vec2 mod289v2(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } vec3 permute(vec3 x) { return mod289v3(((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 = mod289v2(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() { vUv = uv; // Bell curve: max displacement at progress=0.5 float morphBell = sin(uProgress * 3.14159); // Multi-layered noise displacement float n1 = snoise(uv * 3.0 + uTime * 0.5); float n2 = snoise(uv * 6.0 - uTime * 0.3) * 0.5; float noiseVal = n1 + n2; // Displace along Z (toward camera) with wave pattern float displacement = noiseVal * morphBell * uDisplacementStrength; // Add ripple from center float distFromCenter = length(uv - 0.5) * 2.0; displacement += sin(distFromCenter * 8.0 - uTime * 4.0) * morphBell * 0.3; vDisplacement = displacement; vec3 pos = position; pos.z += displacement; // Subtle XY wobble pos.x += sin(uv.y * 5.0 + uTime * 2.0) * morphBell * 0.08; pos.y += cos(uv.x * 5.0 + uTime * 1.5) * morphBell * 0.08; gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } `; const FRAGMENT_SHADER = /* glsl */ ` uniform sampler2D uTextureA; uniform sampler2D uTextureB; uniform float uProgress; uniform float uTime; uniform float uNoiseScale; uniform float uEdgeWidth; uniform vec3 uEdgeGlowColor; uniform float uEdgeGlowIntensity; varying vec2 vUv; varying float vDisplacement; // Simplex 2D noise (duplicated for fragment shader) vec3 mod289v3(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } vec2 mod289v2(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } vec3 permute(vec3 x) { return mod289v3(((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 = mod289v2(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() { // Sample both textures vec4 colorA = texture2D(uTextureA, vUv); vec4 colorB = texture2D(uTextureB, vUv); // Multi-octave noise for organic dissolve pattern float n1 = snoise(vUv * uNoiseScale + uTime * 0.3) * 0.6; float n2 = snoise(vUv * uNoiseScale * 2.0 - uTime * 0.2) * 0.3; float n3 = snoise(vUv * uNoiseScale * 4.0 + uTime * 0.15) * 0.1; float noise = n1 + n2 + n3; // Map noise to 0–1 range noise = noise * 0.5 + 0.5; // Dissolve threshold based on progress float threshold = uProgress * 1.4 - 0.2; // Soft edge dissolve float dissolve = smoothstep(threshold - uEdgeWidth, threshold + uEdgeWidth, noise); // Edge glow — brightest right at the dissolve boundary float edgeDist = abs(noise - threshold); float edgeGlow = 1.0 - smoothstep(0.0, uEdgeWidth * 1.5, edgeDist); edgeGlow *= sin(uProgress * 3.14159); // Fade glow at start/end // Chromatic aberration at edges during morph float morphBell = sin(uProgress * 3.14159); float caStrength = morphBell * 0.003; vec2 caOffset = (vUv - 0.5) * caStrength; vec4 colorAca = colorA; vec4 colorBca = colorB; if (morphBell > 0.05) { colorAca.r = texture2D(uTextureA, vUv + caOffset).r; colorAca.b = texture2D(uTextureA, vUv - caOffset).b; colorBca.r = texture2D(uTextureB, vUv + caOffset).r; colorBca.b = texture2D(uTextureB, vUv - caOffset).b; } // Mix textures based on dissolve vec3 finalColor = mix(colorAca.rgb, colorBca.rgb, dissolve); // Add edge glow finalColor += uEdgeGlowColor * edgeGlow * uEdgeGlowIntensity; // Subtle displacement-based highlights finalColor += vec3(0.15, 0.2, 0.35) * abs(vDisplacement) * 0.15; // Vignette float vignette = 1.0 - smoothstep(0.4, 1.1, length((vUv - 0.5) * vec2(1.0, 1.0))); finalColor *= mix(0.7, 1.0, vignette); gl_FragColor = vec4(finalColor, 1.0); } `; // ============================================================================= // PARTICLE DATA TYPES // ============================================================================= interface ParticleData { x: number; y: number; z: number; size: number; speed: number; phase: number; driftX: number; driftY: number; brightness: number; } // ============================================================================= // THREE.JS SCENE COMPONENT // ============================================================================= const ThreeScene: React.FC<{ frame: number; fps: number; durationInFrames: number; textureA: THREE.Texture; textureB: THREE.Texture; }> = ({ frame, fps, durationInFrames, textureA, textureB }) => { const canvasRef = useRef(null); const time = frame / fps; const totalDuration = durationInFrames / fps; // Pre-generate particle data const sceneData = useMemo(() => { const particles: ParticleData[] = []; for (let i = 0; i < MORPH_CONFIG.particleCount; i++) { particles.push({ x: (seededRandom(i * 1.1) - 0.5) * 5, y: (seededRandom(i * 2.2) - 0.5) * 8, z: seededRandom(i * 3.3) * 2 - 1, size: 0.01 + seededRandom(i * 4.4) * 0.025, speed: 0.3 + seededRandom(i * 5.5) * 0.8, phase: seededRandom(i * 6.6) * Math.PI * 2, driftX: (seededRandom(i * 7.7) - 0.5) * 0.5, driftY: 0.1 + seededRandom(i * 8.8) * 0.3, brightness: 0.3 + seededRandom(i * 9.9) * 0.7, }); } return { particles }; }, []); useEffect(() => { if (!canvasRef.current) return; const canvas = canvasRef.current; const width = compositionConfig.width; const height = compositionConfig.height; // Renderer const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance', }); renderer.setSize(width, height); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; // Scene const scene = new THREE.Scene(); scene.background = new THREE.Color(MORPH_CONFIG.backgroundColor); // Camera — positioned to frame the vertical plane const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100); // ========================================================================= // MORPH PROGRESS // ========================================================================= const morphStart = MORPH_CONFIG.startTime; const morphEnd = morphStart + MORPH_CONFIG.duration; const rawProgress = interpolate(time, [morphStart, morphEnd], [0, 1], { easing: EASINGS.easeInOut, extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }); // ========================================================================= // MORPH PLANE // ========================================================================= // Aspect ratio for vertical video: plane should fill the frame const planeHeight = 6.0; const planeWidth = planeHeight * (width / height); const planeGeo = new THREE.PlaneGeometry(planeWidth, planeHeight, 128, 228); const morphMaterial = new THREE.ShaderMaterial({ uniforms: { uTextureA: { value: textureA }, uTextureB: { value: textureB }, uProgress: { value: rawProgress }, uTime: { value: time }, uNoiseScale: { value: MORPH_CONFIG.noiseScale }, uEdgeWidth: { value: MORPH_CONFIG.edgeWidth }, uEdgeGlowColor: { value: new THREE.Vector3(...MORPH_CONFIG.edgeGlowColor), }, uEdgeGlowIntensity: { value: MORPH_CONFIG.edgeGlowIntensity }, uDisplacementStrength: { value: MORPH_CONFIG.displacementStrength }, }, vertexShader: VERTEX_SHADER, fragmentShader: FRAGMENT_SHADER, }); const morphPlane = new THREE.Mesh(planeGeo, morphMaterial); scene.add(morphPlane); // ========================================================================= // CAMERA ANIMATION // ========================================================================= const morphBell = Math.sin(rawProgress * Math.PI); // Subtle dolly during morph const cameraZ = interpolate(morphBell, [0, 1], [5.2, 6.0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }); // Gentle breathing / sway const breathX = Math.sin(time * 0.8) * 0.04; const breathY = Math.cos(time * 0.6) * 0.03; // Slight tilt during morph const tiltZ = Math.sin(time * 0.5) * morphBell * 0.015; camera.position.set(breathX, breathY, cameraZ); camera.rotation.z = tiltZ; camera.lookAt(breathX * 0.5, breathY * 0.5, 0); // ========================================================================= // FLOATING PARTICLES // ========================================================================= const particleOpacityBase = interpolate( morphBell, [0, 0.3, 0.7, 1], [0.05, 0.6, 0.6, 1.0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } ); sceneData.particles.forEach((p) => { const floatX = p.x + Math.sin(time * p.speed + p.phase) * p.driftX; const floatY = p.y + Math.cos(time * p.speed * 0.7 + p.phase) * p.driftY; const floatZ = p.z + Math.sin(time * 0.4 + p.phase * 2) * 0.5; // Particles drift more during morph const morphDrift = morphBell * 0.5; const driftedX = floatX + Math.sin(time * 2 + p.phase) * morphDrift; const driftedY = floatY + Math.cos(time * 1.5 + p.phase) * morphDrift; const opacity = particleOpacityBase * p.brightness; if (opacity > 0.02) { const particleGeo = new THREE.SphereGeometry( p.size * (1 + morphBell * 0.5), 6, 6 ); const particleMat = new THREE.MeshBasicMaterial({ color: new THREE.Color( MORPH_CONFIG.edgeGlowColor[0] * 0.5 + 0.5, MORPH_CONFIG.edgeGlowColor[1] * 0.5 + 0.5, MORPH_CONFIG.edgeGlowColor[2] * 0.5 + 0.5 ), transparent: true, opacity, }); const particle = new THREE.Mesh(particleGeo, particleMat); particle.position.set(driftedX, driftedY, floatZ + 0.5); scene.add(particle); } }); // ========================================================================= // AMBIENT LIGHT FLARE DURING MORPH // ========================================================================= if (morphBell > 0.1) { // Soft background glow orb const flareSize = morphBell * 1.5; const flareGeo = new THREE.SphereGeometry(flareSize, 16, 16); const flareMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(...MORPH_CONFIG.edgeGlowColor), transparent: true, opacity: morphBell * 0.08, }); const flare = new THREE.Mesh(flareGeo, flareMat); flare.position.set(0, 0, -2); scene.add(flare); } // ========================================================================= // RENDER // ========================================================================= renderer.render(scene, camera); return () => { renderer.dispose(); planeGeo.dispose(); morphMaterial.dispose(); }; }, [frame, fps, durationInFrames, time, sceneData, textureA, textureB]); return ( ); }; // ============================================================================= // MAIN COMPONENT // ============================================================================= const ImageMorph3D: React.FC = () => { const frame = useCurrentFrame(); const { durationInFrames, fps } = useVideoConfig(); const [textures, setTextures] = useState<{ a: THREE.Texture; b: THREE.Texture; } | null>(null); const [handle] = useState(() => delayRender('Loading images...')); useEffect(() => { const loader = new THREE.TextureLoader(); loader.crossOrigin = 'anonymous'; let cancelled = false; Promise.all([ new Promise((resolve, reject) => { loader.load( IMAGE_A_URL, (tex) => { tex.colorSpace = THREE.SRGBColorSpace; resolve(tex); }, undefined, reject ); }), new Promise((resolve, reject) => { loader.load( IMAGE_B_URL, (tex) => { tex.colorSpace = THREE.SRGBColorSpace; resolve(tex); }, undefined, reject ); }), ]) .then(([a, b]) => { if (!cancelled) { setTextures({ a, b }); continueRender(handle); } }) .catch((err) => { console.error('Failed to load morph images:', err); continueRender(handle); }); return () => { cancelled = true; }; }, [handle]); if (!textures) { return ( ); } return ( ); }; export default ImageMorph3D;