/** * UnderwaterScene.tsx * ------------------------------------------------------------------ * A Remotion composition depicting a cinematic, hyper-realistic * underwater scene. Pure code — no external image assets required. * * Drop this file into a Remotion project (npx create-video@latest) * and register it in your Root.tsx: * * import { UnderwaterScene, underwaterSchema } from './UnderwaterScene'; * * * Tested against remotion ^4.0. * ------------------------------------------------------------------ */ import React, {useMemo, useEffect, useRef} from 'react'; import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, random, Easing, } from 'remotion'; import Tone from 'tone'; // ----------------------------------------------------------------- // Palette // ----------------------------------------------------------------- type Palette = 'ocean' | 'abyss' | 'reef'; const palettes: Record< Palette, { surface: string; mid: string; deep: string; abyss: string; sunShaft: string; caustic: string; particle: string; } > = { ocean: { surface: '#7dd3c8', mid: '#1f6f8b', deep: '#0b3a57', abyss: '#031527', sunShaft: 'rgba(230, 245, 255, 0.55)', caustic: 'rgba(190, 235, 255, 0.35)', particle: 'rgba(220, 240, 255, 0.85)', }, abyss: { surface: '#2c6e7a', mid: '#0f3a4a', deep: '#061a2b', abyss: '#01060f', sunShaft: 'rgba(180, 220, 240, 0.35)', caustic: 'rgba(160, 210, 230, 0.25)', particle: 'rgba(200, 225, 240, 0.7)', }, reef: { surface: '#a7e7d4', mid: '#3aa1a0', deep: '#0f5c66', abyss: '#052028', sunShaft: 'rgba(255, 240, 210, 0.6)', caustic: 'rgba(220, 245, 220, 0.4)', particle: 'rgba(255, 250, 230, 0.85)', }, }; // ----------------------------------------------------------------- // Water column gradient + vignette // ----------------------------------------------------------------- const WaterColumn: React.FC<{p: Palette}> = ({p}) => { const c = palettes[p]; return ( ); }; const Vignette: React.FC = () => ( ); // ----------------------------------------------------------------- // Volumetric sun shafts (God rays) — SVG filtered rects // ----------------------------------------------------------------- const SunShafts: React.FC<{p: Palette}> = ({p}) => { const frame = useCurrentFrame(); const {width, height} = useVideoConfig(); const c = palettes[p]; const shafts = useMemo( () => new Array(9).fill(0).map((_, i) => ({ x: (i + 0.5) * (width / 9) + random(`shaft-x-${i}`) * 80 - 40, w: 60 + random(`shaft-w-${i}`) * 180, tilt: -8 + random(`shaft-t-${i}`) * 16, phase: random(`shaft-p-${i}`) * Math.PI * 2, speed: 0.5 + random(`shaft-s-${i}`) * 0.8, })), [width] ); return ( {shafts.map((s, i) => { const sway = Math.sin(frame / 30 / s.speed + s.phase) * 18 + Math.sin(frame / 90 + s.phase) * 6; const opacity = 0.35 + 0.35 * (0.5 + 0.5 * Math.sin(frame / 40 / s.speed + s.phase)); return ( ); })} ); }; // ----------------------------------------------------------------- // Surface ripple band — refraction at the top of the frame // ----------------------------------------------------------------- const SurfaceRipples: React.FC<{p: Palette}> = ({p}) => { const frame = useCurrentFrame(); const {width} = useVideoConfig(); const c = palettes[p]; const rows = 6; return ( {new Array(rows).fill(0).map((_, r) => { const amp = 6 + r * 2; const yBase = 40 + r * 28; const speed = 0.6 + r * 0.12; const phase = r * 1.3; const path = buildWave({ width, amp, yBase, frame, speed, phase, segments: 30, }); return ( ); })} ); }; const buildWave = ({ width, amp, yBase, frame, speed, phase, segments, }: { width: number; amp: number; yBase: number; frame: number; speed: number; phase: number; segments: number; }) => { const dx = width / segments; let d = ''; for (let i = 0; i <= segments; i++) { const x = i * dx; const y = yBase + Math.sin(i * 0.6 + frame / 12 * speed + phase) * amp + Math.sin(i * 0.25 - frame / 30 * speed + phase) * amp * 0.6; d += (i === 0 ? 'M' : 'L') + x.toFixed(1) + ' ' + y.toFixed(1) + ' '; } return d; }; // ----------------------------------------------------------------- // Caustic light patterns projected on the seabed // ----------------------------------------------------------------- const Caustics: React.FC<{p: Palette}> = ({p}) => { const frame = useCurrentFrame(); const {width, height} = useVideoConfig(); const c = palettes[p]; const cells = useMemo( () => new Array(18).fill(0).map((_, i) => ({ x: random(`cx-${i}`) * width, y: height * 0.72 + random(`cy-${i}`) * height * 0.25, r: 60 + random(`cr-${i}`) * 140, phase: random(`cp-${i}`) * Math.PI * 2, speed: 0.4 + random(`cs-${i}`) * 0.7, })), [width, height] ); return ( {cells.map((c2, i) => { const pulse = 0.6 + 0.4 * Math.sin(frame / 18 / c2.speed + c2.phase); const drift = Math.sin(frame / 40 + c2.phase) * 22; return ( ); })} ); }; // ----------------------------------------------------------------- // Seabed silhouette — layered dunes + scattered rocks // ----------------------------------------------------------------- const Seabed: React.FC<{p: Palette}> = ({p}) => { const {width, height} = useVideoConfig(); const c = palettes[p]; const dunePath = (yOffset: number, amp: number, seed: string) => { const segs = 12; const dx = width / segs; let d = `M 0 ${height} L 0 ${yOffset} `; for (let i = 0; i <= segs; i++) { const x = i * dx; const y = yOffset + Math.sin(i * 0.7 + random(seed + i) * 2) * amp + random(seed + 'b' + i) * amp * 0.5; d += `L ${x.toFixed(1)} ${y.toFixed(1)} `; } d += `L ${width} ${height} Z`; return d; }; const rocks = useMemo( () => new Array(12).fill(0).map((_, i) => ({ x: random(`rx-${i}`) * width, y: height * (0.82 + random(`ry-${i}`) * 0.12), rx: 40 + random(`rrx-${i}`) * 80, ry: 12 + random(`rry-${i}`) * 22, })), [width, height] ); return ( {rocks.map((r, i) => ( ))} ); }; // ----------------------------------------------------------------- // Drifting kelp / seagrass — layered SVG blades that sway // ----------------------------------------------------------------- const KelpBlade: React.FC<{ x: number; height: number; sway: number; frame: number; phase: number; width: number; color: string; }> = ({x, height: h, sway, frame, phase, width: w, color}) => { const tip = Math.sin(frame / 28 + phase) * sway; const mid = Math.sin(frame / 32 + phase + 0.8) * sway * 0.6; const d = ` M ${x - w / 2} 0 C ${x - w / 2 + mid} ${-h * 0.4}, ${x + tip - w / 2} ${-h * 0.75}, ${x + tip} ${-h} L ${x + tip + w / 2} ${-h + 4} C ${x + tip + w / 2} ${-h * 0.75}, ${x + mid + w / 2} ${-h * 0.4}, ${x + w / 2} 0 Z `; return ; }; const Kelp: React.FC<{p: Palette}> = ({p}) => { const frame = useCurrentFrame(); const {width, height} = useVideoConfig(); const c = palettes[p]; const blades = useMemo( () => new Array(14).fill(0).map((_, i) => ({ x: random(`kx-${i}`) * width, h: 220 + random(`kh-${i}`) * 340, w: 10 + random(`kw-${i}`) * 18, sway: 10 + random(`ks-${i}`) * 26, phase: random(`kp-${i}`) * Math.PI * 2, depth: random(`kd-${i}`), })), [width] ); return ( {blades .slice() .sort((a, b) => a.depth - b.depth) .map((b, i) => { const darken = 0.45 + b.depth * 0.55; const col = mixHex(c.deep, c.abyss, 1 - darken); return ( ); })} ); }; // ----------------------------------------------------------------- // Floating particles / marine snow // ----------------------------------------------------------------- const MarineSnow: React.FC<{p: Palette; count?: number}> = ({p, count = 80}) => { const frame = useCurrentFrame(); const {width, height} = useVideoConfig(); const c = palettes[p]; const dots = useMemo( () => new Array(count).fill(0).map((_, i) => ({ x: random(`px-${i}`) * width, y: random(`py-${i}`) * height, r: 0.6 + random(`pr-${i}`) * 2.4, driftX: -8 + random(`pdx-${i}`) * 16, driftY: 6 + random(`pdy-${i}`) * 22, phase: random(`pp-${i}`) * Math.PI * 2, speed: 0.4 + random(`ps-${i}`) * 1.2, })), [width, height, count] ); return ( {dots.map((d, i) => { const t = frame / 30; const x = d.x + Math.sin(t * d.speed + d.phase) * 14 + d.driftX * t * 0.3; const y = (d.y + d.driftY * t * 6) % height; const a = 0.2 + 0.8 * (0.5 + 0.5 * Math.sin(t * d.speed * 2 + d.phase)); return ( ); })} ); }; // ----------------------------------------------------------------- // Bubble streams rising from the seabed // ----------------------------------------------------------------- const Bubbles: React.FC<{p: Palette}> = ({p}) => { const frame = useCurrentFrame(); const {width, height, durationInFrames} = useVideoConfig(); const streams = useMemo( () => new Array(5).fill(0).map((_, i) => ({ x: 150 + random(`bsx-${i}`) * (width - 300), count: 10 + Math.floor(random(`bsc-${i}`) * 8), seed: i, })), [width] ); return ( {streams.map((s) => new Array(s.count).fill(0).map((_, j) => { const seedKey = `b-${s.seed}-${j}`; const lifeLen = 90 + random(seedKey + 'l') * 120; const offset = j * 14 + random(seedKey + 'o') * 40; const localFrame = ((frame + offset) % lifeLen) / lifeLen; const y = interpolate(localFrame, [0, 1], [height - 40, 80]); const r = 2 + random(seedKey + 'r') * 5; const sway = Math.sin(localFrame * Math.PI * 3 + s.seed) * 18; const op = interpolate( localFrame, [0, 0.1, 0.9, 1], [0, 0.9, 0.7, 0], {extrapolateRight: 'clamp'} ); return ( ); }) )} ); }; // ----------------------------------------------------------------- // Fish — a small silhouette school that crosses the frame // ----------------------------------------------------------------- const Fish: React.FC<{ yBase: number; delay: number; scale: number; flip: boolean; color: string; }> = ({yBase, delay, scale, flip, color}) => { const frame = useCurrentFrame(); const {width, durationInFrames} = useVideoConfig(); const t = (frame + delay) / durationInFrames; const x = interpolate(t, [0, 1], flip ? [width + 120, -240] : [-240, width + 120]); const y = yBase + Math.sin((frame + delay) / 22) * 10; const tail = Math.sin((frame + delay) / 3) * 8; return ( ); }; const School: React.FC<{p: Palette}> = ({p}) => { const {width, height} = useVideoConfig(); const c = palettes[p]; const fish = useMemo( () => new Array(9).fill(0).map((_, i) => ({ yBase: height * (0.3 + random(`fy-${i}`) * 0.4), delay: -random(`fd-${i}`) * 300, scale: 0.4 + random(`fs-${i}`) * 0.7, flip: random(`ff-${i}`) > 0.6, col: mixHex(c.deep, '#000', 0.3 + random(`fc-${i}`) * 0.4), })), [height] ); return ( {fish.map((f, i) => ( ))} ); }; // ----------------------------------------------------------------- // Slow camera drift — subtle parallax/breathing to feel cinematic // ----------------------------------------------------------------- const useCameraDrift = () => { const frame = useCurrentFrame(); const {durationInFrames} = useVideoConfig(); const t = frame / durationInFrames; const zoom = interpolate(t, [0, 1], [1.0, 1.08], { easing: Easing.inOut(Easing.quad), }); const panX = Math.sin(frame / 180) * 14; const panY = Math.cos(frame / 240) * 10; return {zoom, panX, panY}; }; // ----------------------------------------------------------------- // Tone.js underwater soundscape // Ambient low-pass filtered pink noise + random bubble pings. // Plays in the Remotion Studio preview (requires a user gesture // to start the AudioContext). // ----------------------------------------------------------------- const UnderwaterAudio: React.FC = () => { const frame = useCurrentFrame(); const {fps} = useVideoConfig(); const startedRef = useRef(false); const nodesRef = useRef<{ noise?: any; filter?: any; lfo?: any; reverb?: any; bubbleSynth?: any; rumble?: any; rumbleFilter?: any; }>({}); const lastBubbleFrameRef = useRef(-9999); // Set up the audio graph once. useEffect(() => { const start = async () => { if (startedRef.current) return; try { await Tone.start(); } catch (e) { // ignore — user gesture required } const reverb = new Tone.Reverb({decay: 6, wet: 0.6}).toDestination(); // Ambient pink noise through a slow-moving low-pass filter. const filter = new Tone.Filter({ frequency: 420, type: 'lowpass', Q: 0.8, }).connect(reverb); const lfo = new Tone.LFO({ frequency: 0.08, min: 240, max: 650, }).connect(filter.frequency); const noise = new Tone.Noise('pink'); noise.volume.value = -18; noise.connect(filter); // Deep rumble — slow sine sweep. const rumbleFilter = new Tone.Filter({ frequency: 120, type: 'lowpass', }).connect(reverb); const rumble = new Tone.Oscillator({ frequency: 42, type: 'sine', }).connect(rumbleFilter); rumble.volume.value = -22; // Bubble pings — short pluck-like blips. const bubbleSynth = new Tone.MembraneSynth({ pitchDecay: 0.08, octaves: 4, envelope: {attack: 0.001, decay: 0.2, sustain: 0, release: 0.2}, }).connect(reverb); bubbleSynth.volume.value = -12; noise.start(); rumble.start(); lfo.start(); nodesRef.current = { noise, filter, lfo, reverb, bubbleSynth, rumble, rumbleFilter, }; startedRef.current = true; }; start(); const gestureHandler = () => start(); window.addEventListener('click', gestureHandler, {once: true}); window.addEventListener('keydown', gestureHandler, {once: true}); return () => { window.removeEventListener('click', gestureHandler); window.removeEventListener('keydown', gestureHandler); const n = nodesRef.current; try { n.noise?.stop(); n.rumble?.stop(); n.lfo?.stop(); n.noise?.dispose(); n.rumble?.dispose(); n.lfo?.dispose(); n.filter?.dispose(); n.rumbleFilter?.dispose(); n.bubbleSynth?.dispose(); n.reverb?.dispose(); } catch {} startedRef.current = false; }; }, []); // Trigger bubble pings deterministically from the frame clock. useEffect(() => { if (!startedRef.current) return; // One bubble every ~0.9s, jittered per frame via remotion's `random`. const bubbleEvery = Math.floor(fps * 0.9); if (frame - lastBubbleFrameRef.current < bubbleEvery) return; if (random(`bubble-gate-${frame}`) > 0.55) return; lastBubbleFrameRef.current = frame; const notes = ['C5', 'D5', 'E5', 'G5', 'A5', 'C6']; const note = notes[Math.floor(random(`bubble-note-${frame}`) * notes.length)]; const vel = 0.3 + random(`bubble-vel-${frame}`) * 0.5; try { nodesRef.current.bubbleSynth?.triggerAttackRelease(note, '16n', undefined, vel); } catch {} }, [frame, fps]); return null; }; // ----------------------------------------------------------------- // Helpers // ----------------------------------------------------------------- const mixHex = (a: string, b: string, t: number) => { const pa = parseHex(a); const pb = parseHex(b); const r = Math.round(pa.r + (pb.r - pa.r) * t); const g = Math.round(pa.g + (pb.g - pa.g) * t); const bb = Math.round(pa.b + (pb.b - pa.b) * t); return `rgb(${r}, ${g}, ${bb})`; }; const parseHex = (h: string) => { const m = h.replace('#', ''); return { r: parseInt(m.substring(0, 2), 16), g: parseInt(m.substring(2, 4), 16), b: parseInt(m.substring(4, 6), 16), }; }; // ----------------------------------------------------------------- // Root composition // ----------------------------------------------------------------- export type UnderwaterSceneProps = { palette?: Palette; }; export const UnderwaterScene: React.FC = ({ palette = 'ocean', }) => { const {zoom, panX, panY} = useCameraDrift(); return ( {/* Tone.js generative soundscape */} {/* Camera rig */} {/* Lens chromatic tint — stays put, outside camera rig */} ); }; export default UnderwaterScene;