/**
* 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 (
);
};
// -----------------------------------------------------------------
// 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 (
);
};
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 (
);
};
// -----------------------------------------------------------------
// 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 (
);
};
// -----------------------------------------------------------------
// 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 (
);
};
// -----------------------------------------------------------------
// 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 (
);
};
// -----------------------------------------------------------------
// 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 (
);
};
// -----------------------------------------------------------------
// 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 (
);
};
// -----------------------------------------------------------------
// 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;