cutship.ai
Component · Transitions · 9:16
Light Leak Transition
by CutshipVerified
A warm film light leak drifts across the frame, blooming the exposure as the outgoing clip burns out and the next one fades in under the glow. Feels like real film fogging, not a white flash.
New
Free
Sign up free to Make this
Sign up free to unlock the spec, agent prompt and Make this.
components/remotion/light-leak-transition/component.tsx
import React from "react";
import { Video } from "@remotion/media";
import {
AbsoluteFill,
Easing,
Sequence,
interpolate,
random,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import type { LightLeakTransitionProps } from "./schema";
const resolveSrc = (src: string) => (/^(https?:|data:|blob:|\/)/.test(src) ? src : staticFile(src));
const Clip: React.FC<{ src: string; node?: React.ReactNode }> = ({ src, node }) =>
node ? <>{node}</> : src ? <Video src={resolveSrc(src)} objectFit="cover" style={{ width: "100%", height: "100%" }} /> : null;
type RGB = [number, number, number];
// main wash, secondary glow, hot core, deep fringe
const PALETTES: Record<LightLeakTransitionProps["palette"], [RGB, RGB, RGB, RGB]> = {
golden: [[255, 170, 60], [255, 212, 128], [255, 246, 222], [255, 110, 30]],
ember: [[255, 104, 28], [255, 180, 72], [255, 241, 214], [226, 30, 36]],
rose: [[255, 108, 120], [255, 180, 138], [255, 240, 230], [214, 46, 107]],
dusk: [[220, 72, 150], [255, 140, 92], [255, 234, 224], [110, 60, 255]],
};
const hexToRgb = (hex: string): RGB | null => {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return null;
const h = m[1].length === 3 ? m[1].split("").map((c) => c + c).join("") : m[1];
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
};
const rgba = ([r, g, b]: RGB, a: number) => `rgba(${r},${g},${b},${Math.max(0, Math.min(1, a)).toFixed(3)})`;
/** A warm film light leak sweeps across the frame; clip B fades in under the brightest moment. */
export const LightLeakTransition: React.FC<
LightLeakTransitionProps & { clipA?: React.ReactNode; clipB?: React.ReactNode }
> = ({
srcA,
srcB,
transitionFrame,
durationInFrames,
palette,
tint,
intensity,
exposure,
blur,
direction,
seed,
brandKit,
clipA,
clipB,
}) => {
const frame = useCurrentFrame();
const { width: W, height: H } = useVideoConfig();
const clamp = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
const half = durationInFrames / 2;
const start = transitionFrame - half;
const end = transitionFrame + half;
const base = PALETTES[palette];
const custom = hexToRgb(brandKit?.colors?.[0] ?? "") ?? hexToRgb(tint);
const [main, glow, hot, fringe] = custom ? [custom, base[1], base[2], base[3]] : base;
// Slow swell in, peak on the cut, lingering burn-off: film fogs faster than it clears.
const rise = interpolate(frame, [start, transitionFrame], [0, 1], { ...clamp, easing: Easing.bezier(0.35, 0, 0.45, 1) });
const fall = interpolate(frame, [transitionFrame, end], [1, 0], { ...clamp, easing: Easing.bezier(0.45, 0, 0.6, 1) });
const env = frame <= transitionFrame ? rise : fall;
// Organic flicker, like a real gate exposing unevenly.
const flicker = 1 + (random(`leak-${seed}-${Math.floor(frame)}`) - 0.5) * 0.12 * env;
const k = env * intensity * flicker;
const p = interpolate(frame, [start, end], [0, 1], { ...clamp, easing: Easing.bezier(0.3, 0.1, 0.5, 1) });
const dir = direction === "right" ? 1 : -1;
const sx = (t: number) => (dir === 1 ? t : 1 - t) * W;
const r1 = random(`a-${seed}`);
const r2 = random(`b-${seed}`);
const r3 = random(`c-${seed}`);
// Main wash: big, soft, sweeps across.
const washX = sx(interpolate(p, [0, 1], [-0.25, 1.2]));
const washY = H * (0.3 + 0.35 * r1) + Math.sin(p * Math.PI) * H * 0.06;
// Hot core: tighter, trails the wash and travels further (parallax).
const coreX = sx(interpolate(p, [0, 1], [-0.45, 1.35]));
const coreY = H * (0.42 + 0.2 * (r2 - 0.5)) - Math.sin(p * Math.PI) * H * 0.08;
// Secondary bloom from the opposite corner.
const glowX = sx(interpolate(p, [0, 1], [0.15, 0.85]));
const glowY = H * (0.75 + 0.15 * r3);
const showA = frame < transitionFrame + 3;
const bOpacity = interpolate(frame, [transitionFrame - 3, transitionFrame + 2], [0, 1], {
...clamp,
easing: Easing.inOut(Easing.quad),
});
// Exposure spikes only right around the cut so the leak's shape stays legible on the way in.
const spike = Math.pow(env, 3);
const footageFilter =
env > 0.01
? `brightness(${1 + exposure * spike}) contrast(${1 + 0.06 * env}) saturate(${1 + 0.18 * env}) sepia(${0.1 * env}) blur(${blur * spike}px)`
: undefined;
const layer = (background: string, extra?: React.CSSProperties): React.CSSProperties => ({
background,
mixBlendMode: "screen",
pointerEvents: "none",
...extra,
});
const edge = dir === 1 ? "to right" : "to left";
return (
<AbsoluteFill style={{ backgroundColor: "black", overflow: "hidden" }}>
<AbsoluteFill style={{ filter: footageFilter, transform: `scale(${1 + 0.03 * env})` }}>
{showA ? (
<AbsoluteFill>
<Clip src={srcA} node={clipA} />
</AbsoluteFill>
) : null}
<Sequence from={Math.floor(start)} layout="none">
<AbsoluteFill style={{ opacity: bOpacity }}>
<Clip src={srcB} node={clipB} />
</AbsoluteFill>
</Sequence>
</AbsoluteFill>
{k > 0.003 ? (
<>
{/* Edge fog: film exposed at the gate edge first. */}
<AbsoluteFill
style={layer(
`linear-gradient(${edge}, ${rgba(fringe, 0.75 * k)} 0%, ${rgba(main, 0.45 * k)} ${18 + 30 * p}%, ${rgba(main, 0)} ${45 + 40 * p}%)`,
)}
/>
{/* Main warm wash. */}
<AbsoluteFill
style={layer(
`radial-gradient(ellipse ${W * 0.8}px ${H * 0.5}px at ${washX}px ${washY}px, ${rgba(main, 0.95 * k)} 0%, ${rgba(main, 0.55 * k)} 32%, ${rgba(fringe, 0.18 * k)} 58%, ${rgba(fringe, 0)} 75%)`,
)}
/>
{/* Secondary low bloom. */}
<AbsoluteFill
style={layer(
`radial-gradient(ellipse ${W * 0.8}px ${H * 0.35}px at ${glowX}px ${glowY}px, ${rgba(glow, 0.6 * k)} 0%, ${rgba(main, 0.25 * k)} 40%, ${rgba(main, 0)} 72%)`,
)}
/>
{/* Hot, near-white core: the overexposed heart of the leak. */}
<AbsoluteFill
style={layer(
`radial-gradient(ellipse ${W * 0.5}px ${H * 0.36}px at ${coreX}px ${coreY}px, ${rgba(hot, 0.95 * k)} 0%, ${rgba(glow, 0.7 * k)} 28%, ${rgba(main, 0.25 * k)} 55%, ${rgba(main, 0)} 78%)`,
)}
/>
{/* Soft vertical burn band drifting through, like a sprocket-side streak. */}
<AbsoluteFill
style={layer(
`linear-gradient(${edge}, ${rgba(glow, 0)} ${Math.max(0, p * 100 - 22)}%, ${rgba(glow, 0.42 * k)} ${p * 100}%, ${rgba(glow, 0)} ${Math.min(100, p * 100 + 22)}%)`,
{ filter: "blur(30px)" },
)}
/>
{/* Overall lift so the whole frame breathes with the leak. */}
<AbsoluteFill style={layer(rgba(glow, 0.06 * k * k))} />
</>
) : null}
</AbsoluteFill>
);
};
export default LightLeakTransition;
Remotion code you can paste anywhere, or install it: npx cutship add light-leak-transition
MIT License: use it in any video, personal or commercial. License
More from Cutship
Creator page →