import React, { useMemo } from 'react'; import { useCurrentFrame, useVideoConfig, interpolate, Easing, AbsoluteFill, } from 'remotion'; import * as THREE from 'three'; // ============================================================================= // COMPOSITION CONFIG // ============================================================================= export const compositionConfig = { id: 'CandleOnDesk', durationInSeconds: 5, fps: 60, width: 1920, height: 1080, }; // ============================================================================= // STYLE CONSTANTS // ============================================================================= const COLORS = { flame: 0xffa726, flameCore: 0xffeb3b, flameTip: 0xff5722, candle: 0xfff8e1, wick: 0x3e2723, desk: 0x5d4037, wall: 0x4a3728, paper: 0xfaf3e0, bookCover1: 0x8b4513, bookCover2: 0x2e4a3f, bookCover3: 0x4a1c40, bookPages: 0xf5f0e6, ambient: 0xff8a50, } as const; const EASINGS = { gentle: Easing.bezier(0.25, 0.1, 0.25, 1), easeOut: Easing.bezier(0.33, 1, 0.68, 1), easeInOut: Easing.bezier(0.37, 0, 0.63, 1), bouncy: Easing.bezier(0.34, 1.56, 0.64, 1), }; // ============================================================================= // SEEDED RANDOM (for deterministic renders) // ============================================================================= const seededRandom = (seed: number): number => { const x = Math.sin(seed * 9999) * 10000; return x - Math.floor(x); }; // ============================================================================= // TYPE DEFINITIONS // ============================================================================= interface PaperData { x: number; z: number; rotation: number; scale: number; tilt: number; } interface BookData { x: number; z: number; rotation: number; width: number; height: number; depth: number; color: number; isOpen: boolean; openAngle: number; } interface DustParticle { x: number; y: number; z: number; size: number; speed: number; phase: number; } // ============================================================================= // THREE.JS SCENE COMPONENT // ============================================================================= const ThreeScene: React.FC<{ frame: number; fps: number; durationInFrames: number }> = ({ frame, fps, durationInFrames, }) => { const canvasRef = React.useRef(null); const time = frame / fps; // Pre-generate static data ONCE with useMemo const sceneData = useMemo(() => { // Papers scattered on desk const papers: PaperData[] = []; for (let i = 0; i < 8; i++) { papers.push({ x: seededRandom(i * 1.1) * 3 - 1.5, z: seededRandom(i * 2.2) * 2.5 - 1, rotation: seededRandom(i * 3.3) * Math.PI * 0.3 - Math.PI * 0.15, scale: 0.8 + seededRandom(i * 4.4) * 0.4, tilt: seededRandom(i * 5.5) * 0.05, }); } // Books on desk const books: BookData[] = [ { x: -1.8, z: -0.5, rotation: 0.1, width: 0.8, height: 0.15, depth: 1.1, color: COLORS.bookCover1, isOpen: false, openAngle: 0 }, { x: -1.6, z: -0.4, rotation: -0.05, width: 0.75, height: 0.12, depth: 1.0, color: COLORS.bookCover2, isOpen: false, openAngle: 0 }, { x: 1.5, z: 0.3, rotation: 0.8, width: 0.9, height: 0.18, depth: 1.2, color: COLORS.bookCover3, isOpen: true, openAngle: 2.5 }, { x: 0.8, z: -0.8, rotation: -0.3, width: 0.7, height: 0.1, depth: 0.95, color: 0x6b4423, isOpen: true, openAngle: 2.8 }, ]; // Dust particles floating in candlelight const dustParticles: DustParticle[] = []; for (let i = 0; i < 40; i++) { dustParticles.push({ x: seededRandom(i * 10.1) * 4 - 2, y: seededRandom(i * 20.2) * 2 + 0.5, z: seededRandom(i * 30.3) * 3 - 1.5, size: 0.008 + seededRandom(i * 40.4) * 0.015, speed: 0.3 + seededRandom(i * 50.5) * 0.4, phase: seededRandom(i * 60.6) * Math.PI * 2, }); } // Flame flicker patterns (pre-computed noise seeds) const flickerSeeds = Array.from({ length: 10 }, (_, i) => ({ freq: 2 + seededRandom(i * 100) * 8, amp: 0.05 + seededRandom(i * 101) * 0.15, phase: seededRandom(i * 102) * Math.PI * 2, })); return { papers, books, dustParticles, flickerSeeds }; }, []); React.useEffect(() => { if (!canvasRef.current) return; const canvas = canvasRef.current; const width = 1920; const height = 1080; // RENDERER SETUP const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance', }); renderer.setSize(width, height); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 0.9; // SCENE const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x1a0f08, 0.08); // CAMERA const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100); // Gentle camera movement const cameraBreath = Math.sin(time * 0.5) * 0.05; const cameraSway = Math.sin(time * 0.3) * 0.03; camera.position.set(2.5 + cameraSway, 2.8 + cameraBreath, 3.5); camera.lookAt(0, 0.8, 0); // ========================================================================== // LIGHTING - Candlelight atmosphere // ========================================================================== // Very dim ambient (mostly darkness) const ambientLight = new THREE.AmbientLight(0x1a0a00, 0.15); scene.add(ambientLight); // Calculate flame flicker intensity let flickerIntensity = 0; sceneData.flickerSeeds.forEach((seed) => { flickerIntensity += Math.sin(time * seed.freq + seed.phase) * seed.amp; }); flickerIntensity = 1 + flickerIntensity * 0.5; // Main candle point light (the key light) const candleLight = new THREE.PointLight(0xff9944, 2.5 * flickerIntensity, 8, 1.5); const lightOffsetX = Math.sin(time * 7.3) * 0.02 + Math.sin(time * 11.7) * 0.01; const lightOffsetZ = Math.cos(time * 8.1) * 0.015 + Math.cos(time * 13.2) * 0.008; candleLight.position.set(lightOffsetX, 1.35, lightOffsetZ); candleLight.castShadow = true; candleLight.shadow.mapSize.width = 1024; candleLight.shadow.mapSize.height = 1024; candleLight.shadow.camera.near = 0.1; candleLight.shadow.camera.far = 10; candleLight.shadow.bias = -0.001; scene.add(candleLight); // Secondary warm fill from flame const warmFill = new THREE.PointLight(0xff6622, 0.8 * flickerIntensity, 5, 2); warmFill.position.set(0, 1.5, 0); scene.add(warmFill); // Subtle rim light from behind (moonlight through window) const rimLight = new THREE.DirectionalLight(0x334466, 0.1); rimLight.position.set(-3, 2, -2); scene.add(rimLight); // ========================================================================== // MATERIALS // ========================================================================== const deskMaterial = new THREE.MeshStandardMaterial({ color: COLORS.desk, roughness: 0.7, metalness: 0.05, }); const wallMaterial = new THREE.MeshStandardMaterial({ color: COLORS.wall, roughness: 0.9, metalness: 0, }); const candleMaterial = new THREE.MeshStandardMaterial({ color: COLORS.candle, roughness: 0.4, metalness: 0, }); const paperMaterial = new THREE.MeshStandardMaterial({ color: COLORS.paper, roughness: 0.95, metalness: 0, side: THREE.DoubleSide, }); // ========================================================================== // DESK // ========================================================================== const deskTop = new THREE.Mesh( new THREE.BoxGeometry(5, 0.12, 3), deskMaterial ); deskTop.position.set(0, 0, 0); deskTop.receiveShadow = true; deskTop.castShadow = true; scene.add(deskTop); // Desk edge trim const edgeMaterial = new THREE.MeshStandardMaterial({ color: 0x4a3228, roughness: 0.6, }); const frontEdge = new THREE.Mesh( new THREE.BoxGeometry(5.1, 0.08, 0.08), edgeMaterial ); frontEdge.position.set(0, -0.02, 1.54); frontEdge.castShadow = true; scene.add(frontEdge); // ========================================================================== // WALL BEHIND // ========================================================================== const wall = new THREE.Mesh( new THREE.PlaneGeometry(8, 6), wallMaterial ); wall.position.set(0, 2, -2); wall.receiveShadow = true; scene.add(wall); // ========================================================================== // CANDLE AND HOLDER // ========================================================================== // Candle holder/base const holderMaterial = new THREE.MeshStandardMaterial({ color: 0x8b7355, roughness: 0.3, metalness: 0.6, }); const holderBase = new THREE.Mesh( new THREE.CylinderGeometry(0.18, 0.22, 0.06, 16), holderMaterial ); holderBase.position.set(0, 0.09, 0); holderBase.castShadow = true; holderBase.receiveShadow = true; scene.add(holderBase); const holderStem = new THREE.Mesh( new THREE.CylinderGeometry(0.06, 0.08, 0.15, 12), holderMaterial ); holderStem.position.set(0, 0.19, 0); holderStem.castShadow = true; scene.add(holderStem); const holderCup = new THREE.Mesh( new THREE.CylinderGeometry(0.12, 0.08, 0.08, 16), holderMaterial ); holderCup.position.set(0, 0.30, 0); holderCup.castShadow = true; scene.add(holderCup); // Candle body const candleBody = new THREE.Mesh( new THREE.CylinderGeometry(0.08, 0.085, 0.7, 16), candleMaterial ); candleBody.position.set(0, 0.69, 0); candleBody.castShadow = true; scene.add(candleBody); // Melted wax drips const waxDripMaterial = new THREE.MeshStandardMaterial({ color: 0xfff5dc, roughness: 0.3, metalness: 0, }); for (let i = 0; i < 4; i++) { const dripAngle = seededRandom(i * 77) * Math.PI * 2; const dripLength = 0.15 + seededRandom(i * 88) * 0.2; const dripWidth = 0.02 + seededRandom(i * 99) * 0.02; const drip = new THREE.Mesh( new THREE.CapsuleGeometry(dripWidth, dripLength, 4, 8), waxDripMaterial ); drip.position.set( Math.cos(dripAngle) * 0.075, 0.85 - dripLength / 2, Math.sin(dripAngle) * 0.075 ); drip.rotation.z = Math.cos(dripAngle) * 0.2; drip.rotation.x = Math.sin(dripAngle) * 0.2; drip.castShadow = true; scene.add(drip); } // Wick const wickMaterial = new THREE.MeshStandardMaterial({ color: COLORS.wick, roughness: 0.9, }); const wick = new THREE.Mesh( new THREE.CylinderGeometry(0.008, 0.006, 0.08, 8), wickMaterial ); wick.position.set(0, 1.08, 0); scene.add(wick); // ========================================================================== // FLAME - Procedural flickering // ========================================================================== // Flame flicker calculations const flameSwayX = Math.sin(time * 6.5) * 0.03 + Math.sin(time * 11.2) * 0.015; const flameSwayZ = Math.cos(time * 7.8) * 0.02 + Math.cos(time * 9.4) * 0.01; const flameStretch = 1 + Math.sin(time * 8.3) * 0.1 + Math.sin(time * 15.7) * 0.05; const flameWidth = 1 + Math.sin(time * 12.1) * 0.08; // Inner flame (bright core) const innerFlameMaterial = new THREE.MeshBasicMaterial({ color: 0xffffcc, transparent: true, opacity: 0.95, }); const innerFlame = new THREE.Mesh( new THREE.SphereGeometry(0.025 * flameWidth, 8, 12), innerFlameMaterial ); innerFlame.scale.set(1, 2.2 * flameStretch, 1); innerFlame.position.set(flameSwayX * 0.3, 1.16, flameSwayZ * 0.3); scene.add(innerFlame); // Middle flame const midFlameMaterial = new THREE.MeshBasicMaterial({ color: COLORS.flameCore, transparent: true, opacity: 0.85, }); const midFlame = new THREE.Mesh( new THREE.SphereGeometry(0.04 * flameWidth, 8, 12), midFlameMaterial ); midFlame.scale.set(1, 2.8 * flameStretch, 1); midFlame.position.set(flameSwayX * 0.5, 1.18, flameSwayZ * 0.5); scene.add(midFlame); // Outer flame const outerFlameMaterial = new THREE.MeshBasicMaterial({ color: COLORS.flame, transparent: true, opacity: 0.7, }); const outerFlame = new THREE.Mesh( new THREE.SphereGeometry(0.055 * flameWidth, 8, 12), outerFlameMaterial ); outerFlame.scale.set(1, 3.2 * flameStretch, 1); outerFlame.position.set(flameSwayX * 0.7, 1.2, flameSwayZ * 0.7); scene.add(outerFlame); // Flame tip const tipMaterial = new THREE.MeshBasicMaterial({ color: COLORS.flameTip, transparent: true, opacity: 0.5, }); const flameTip = new THREE.Mesh( new THREE.ConeGeometry(0.03 * flameWidth, 0.12 * flameStretch, 8), tipMaterial ); flameTip.position.set(flameSwayX, 1.35, flameSwayZ); scene.add(flameTip); // Flame glow halo const glowMaterial = new THREE.MeshBasicMaterial({ color: 0xff6600, transparent: true, opacity: 0.15 * flickerIntensity, }); const flameGlow = new THREE.Mesh( new THREE.SphereGeometry(0.2, 16, 16), glowMaterial ); flameGlow.position.set(0, 1.25, 0); scene.add(flameGlow); // ========================================================================== // BOOKS // ========================================================================== sceneData.books.forEach((book) => { const bookGroup = new THREE.Group(); const coverMaterial = new THREE.MeshStandardMaterial({ color: book.color, roughness: 0.8, metalness: 0, }); const pagesMaterial = new THREE.MeshStandardMaterial({ color: COLORS.bookPages, roughness: 0.95, metalness: 0, }); if (book.isOpen) { // Open book - two halves angled const halfWidth = book.width * 0.48; // Left pages const leftPages = new THREE.Mesh( new THREE.BoxGeometry(halfWidth, book.height * 0.7, book.depth), pagesMaterial ); leftPages.position.set(-halfWidth / 2 - 0.02, book.height * 0.35, 0); leftPages.rotation.z = book.openAngle * 0.08; leftPages.castShadow = true; leftPages.receiveShadow = true; bookGroup.add(leftPages); // Right pages const rightPages = new THREE.Mesh( new THREE.BoxGeometry(halfWidth, book.height * 0.7, book.depth), pagesMaterial ); rightPages.position.set(halfWidth / 2 + 0.02, book.height * 0.35, 0); rightPages.rotation.z = -book.openAngle * 0.08; rightPages.castShadow = true; rightPages.receiveShadow = true; bookGroup.add(rightPages); // Spine const spine = new THREE.Mesh( new THREE.BoxGeometry(0.04, book.height, book.depth), coverMaterial ); spine.position.set(0, 0, 0); spine.castShadow = true; bookGroup.add(spine); } else { // Closed book // Cover const cover = new THREE.Mesh( new THREE.BoxGeometry(book.width, book.height, book.depth), coverMaterial ); cover.castShadow = true; cover.receiveShadow = true; bookGroup.add(cover); // Pages edge const pagesEdge = new THREE.Mesh( new THREE.BoxGeometry(book.width - 0.04, book.height - 0.02, book.depth - 0.04), pagesMaterial ); pagesEdge.position.set(0.02, 0, 0); bookGroup.add(pagesEdge); } bookGroup.position.set(book.x, book.height / 2 + 0.06, book.z); bookGroup.rotation.y = book.rotation; scene.add(bookGroup); }); // ========================================================================== // PAPERS // ========================================================================== sceneData.papers.forEach((paper, i) => { const paperGeo = new THREE.PlaneGeometry(0.6 * paper.scale, 0.8 * paper.scale); const paperMesh = new THREE.Mesh(paperGeo, paperMaterial); // Slight wave in papers const waveOffset = Math.sin(time * 0.5 + i) * 0.002; paperMesh.position.set(paper.x, 0.07 + i * 0.002 + waveOffset, paper.z); paperMesh.rotation.x = -Math.PI / 2 + paper.tilt; paperMesh.rotation.z = paper.rotation; paperMesh.receiveShadow = true; paperMesh.castShadow = true; scene.add(paperMesh); // Add some "writing" lines if (i % 2 === 0) { const lineMaterial = new THREE.MeshBasicMaterial({ color: 0x2a2520, transparent: true, opacity: 0.3, }); for (let j = 0; j < 6; j++) { const line = new THREE.Mesh( new THREE.PlaneGeometry(0.4 * paper.scale, 0.008), lineMaterial ); line.position.set( paper.x + seededRandom(i * j * 10) * 0.05, 0.072 + i * 0.002, paper.z - 0.25 * paper.scale + j * 0.08 * paper.scale ); line.rotation.x = -Math.PI / 2; line.rotation.z = paper.rotation + seededRandom(i * j) * 0.02; scene.add(line); } } }); // ========================================================================== // QUILL PEN // ========================================================================== const quillGroup = new THREE.Group(); const featherMaterial = new THREE.MeshStandardMaterial({ color: 0xf5f0e8, roughness: 0.6, side: THREE.DoubleSide, }); // Feather shaft const shaft = new THREE.Mesh( new THREE.CylinderGeometry(0.008, 0.003, 0.5, 8), featherMaterial ); shaft.rotation.z = Math.PI / 6; quillGroup.add(shaft); // Feather vanes (simplified) const vaneGeo = new THREE.PlaneGeometry(0.08, 0.3); const leftVane = new THREE.Mesh(vaneGeo, featherMaterial); leftVane.position.set(-0.04, 0.1, 0); leftVane.rotation.y = 0.2; quillGroup.add(leftVane); const rightVane = new THREE.Mesh(vaneGeo, featherMaterial); rightVane.position.set(0.04, 0.1, 0); rightVane.rotation.y = -0.2; quillGroup.add(rightVane); // Nib const nibMaterial = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.4, metalness: 0.3, }); const nib = new THREE.Mesh( new THREE.ConeGeometry(0.006, 0.04, 4), nibMaterial ); nib.position.set(-0.13, -0.22, 0); nib.rotation.z = Math.PI / 6; quillGroup.add(nib); quillGroup.position.set(0.5, 0.1, 0.6); quillGroup.rotation.y = -0.3; quillGroup.castShadow = true; scene.add(quillGroup); // ========================================================================== // INK BOTTLE // ========================================================================== const inkBottleMaterial = new THREE.MeshStandardMaterial({ color: 0x1a2030, roughness: 0.1, metalness: 0.1, transparent: true, opacity: 0.85, }); const inkBottle = new THREE.Mesh( new THREE.CylinderGeometry(0.06, 0.07, 0.12, 12), inkBottleMaterial ); inkBottle.position.set(0.8, 0.12, 0.4); inkBottle.castShadow = true; scene.add(inkBottle); // Ink inside const inkMaterial = new THREE.MeshStandardMaterial({ color: 0x0a0a15, roughness: 0.3, }); const ink = new THREE.Mesh( new THREE.CylinderGeometry(0.05, 0.05, 0.06, 12), inkMaterial ); ink.position.set(0.8, 0.09, 0.4); scene.add(ink); // Cork const corkMaterial = new THREE.MeshStandardMaterial({ color: 0xa0826d, roughness: 0.9, }); const cork = new THREE.Mesh( new THREE.CylinderGeometry(0.025, 0.03, 0.04, 8), corkMaterial ); cork.position.set(0.65, 0.08, 0.55); cork.rotation.z = Math.PI / 3; cork.castShadow = true; scene.add(cork); // ========================================================================== // DUST PARTICLES IN LIGHT // ========================================================================== sceneData.dustParticles.forEach((particle) => { const floatY = Math.sin(time * particle.speed + particle.phase) * 0.3; const driftX = Math.sin(time * 0.4 + particle.phase * 2) * 0.2; const driftZ = Math.cos(time * 0.3 + particle.phase * 1.5) * 0.15; // Only show particles in the light cone const distFromCandle = Math.sqrt( Math.pow(particle.x, 2) + Math.pow(particle.z, 2) ); if (distFromCandle < 2 && particle.y < 2.5) { const dustOpacity = interpolate( distFromCandle, [0, 1.5], [0.6, 0.1], { extrapolateRight: 'clamp' } ) * flickerIntensity; const dustMesh = new THREE.Mesh( new THREE.SphereGeometry(particle.size, 4, 4), new THREE.MeshBasicMaterial({ color: 0xffddaa, transparent: true, opacity: dustOpacity, }) ); dustMesh.position.set( particle.x + driftX, particle.y + floatY, particle.z + driftZ ); scene.add(dustMesh); } }); // ========================================================================== // SMOKE WISPS FROM CANDLE // ========================================================================== for (let i = 0; i < 8; i++) { const smokeTime = ((time * 0.8 + seededRandom(i * 500) * 3) % 3) / 3; if (smokeTime < 0.9) { const smokeY = smokeTime * 1.5; const smokeX = Math.sin(smokeTime * Math.PI * 2 + i) * 0.15 * smokeTime; const smokeZ = Math.cos(smokeTime * Math.PI * 1.5 + i * 2) * 0.1 * smokeTime; const smokeScale = 0.02 + smokeTime * 0.08; const smokeOpacity = Math.sin(smokeTime * Math.PI) * 0.15; if (smokeOpacity > 0.02) { const smokeMesh = new THREE.Mesh( new THREE.SphereGeometry(smokeScale, 6, 6), new THREE.MeshBasicMaterial({ color: 0x888888, transparent: true, opacity: smokeOpacity, }) ); smokeMesh.position.set( flameSwayX + smokeX, 1.45 + smokeY, flameSwayZ + smokeZ ); scene.add(smokeMesh); } } } // ========================================================================== // RENDER // ========================================================================== renderer.render(scene, camera); return () => { renderer.dispose(); }; }, [frame, fps, durationInFrames, time, sceneData]); return ( ); }; // ============================================================================= // MAIN COMPONENT // ============================================================================= const CandleOnDesk: React.FC = () => { const frame = useCurrentFrame(); const { durationInFrames, fps } = useVideoConfig(); return ( ); }; export default CandleOnDesk;