remotion-render / src /ReelsComposition.jsx
Foxik163's picture
Add dark template preset
52b5157
import React from 'react';
import {
AbsoluteFill,
Sequence,
Video,
Img,
interpolate,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
// CapCut-style template presets.
// Each preset defines pacing, visual behavior, typography, and motion — not just colors.
export const TEMPLATE_PRESETS = {
viral: {
clipDuration: 5,
transitionDuration: 0.3,
zoom: { start: 1.05, end: 1.15 },
overlay: { color: '#000000', opacity: 0.12 },
subtitle: {
fontFamily: '"Impact", "Arial Black", sans-serif',
fontSize: 52,
fontWeight: '900',
color: '#FFE600',
stroke: '#000000',
strokeWidth: 5,
position: 'bottom',
animation: 'pop',
letterSpacing: '1px',
textTransform: 'uppercase',
},
cinematicBars: false,
vignette: false,
grain: false,
},
luxury: {
clipDuration: 7,
transitionDuration: 1.0,
zoom: { start: 1.0, end: 1.08 },
overlay: { color: '#0d0800', opacity: 0.38 },
subtitle: {
fontFamily: '"Georgia", "Palatino Linotype", serif',
fontSize: 38,
fontWeight: 'normal',
color: '#D4AF37',
stroke: '#1a1000',
strokeWidth: 2,
position: 'bottom',
animation: 'fade',
letterSpacing: '3px',
textTransform: 'uppercase',
},
cinematicBars: true,
vignette: true,
grain: false,
},
business: {
clipDuration: 6,
transitionDuration: 0.5,
zoom: { start: 1.0, end: 1.05 },
overlay: { color: '#001020', opacity: 0.22 },
subtitle: {
fontFamily: '"Helvetica Neue", "Arial", sans-serif',
fontSize: 42,
fontWeight: '700',
color: '#FFFFFF',
stroke: '#003399',
strokeWidth: 3,
position: 'bottom',
animation: 'slide',
letterSpacing: '0.5px',
textTransform: 'none',
},
cinematicBars: false,
vignette: false,
grain: false,
},
gym: {
clipDuration: 3,
transitionDuration: 0.12,
zoom: { start: 1.0, end: 1.22 },
overlay: { color: '#200000', opacity: 0.28 },
subtitle: {
fontFamily: '"Impact", "Arial Black", sans-serif',
fontSize: 56,
fontWeight: '900',
color: '#FF2020',
stroke: '#000000',
strokeWidth: 6,
position: 'middle',
animation: 'slam',
letterSpacing: '2px',
textTransform: 'uppercase',
},
cinematicBars: false,
vignette: false,
grain: false,
},
cinematic: {
clipDuration: 8,
transitionDuration: 1.2,
zoom: { start: 1.0, end: 1.06 },
overlay: { color: '#050510', opacity: 0.45 },
subtitle: {
fontFamily: '"Georgia", "Times New Roman", serif',
fontSize: 34,
fontWeight: 'normal',
color: '#EEE0C0',
stroke: '#000000',
strokeWidth: 1,
position: 'bottom',
animation: 'fade',
letterSpacing: '2px',
textTransform: 'none',
},
cinematicBars: true,
vignette: true,
grain: true,
},
motivation: {
clipDuration: 5,
transitionDuration: 0.4,
zoom: { start: 1.02, end: 1.12 },
overlay: { color: '#0a0020', opacity: 0.2 },
subtitle: {
fontFamily: '"Impact", "Arial Black", sans-serif',
fontSize: 50,
fontWeight: '900',
color: '#FFFFFF',
stroke: '#6600CC',
strokeWidth: 4,
position: 'bottom',
animation: 'pop',
letterSpacing: '1px',
textTransform: 'uppercase',
},
cinematicBars: false,
vignette: false,
grain: false,
},
dark: {
clipDuration: 4,
transitionDuration: 0.25,
zoom: { start: 1.0, end: 1.10 },
overlay: { color: '#000000', opacity: 0.55 },
subtitle: {
fontFamily: '"Impact", "Arial Black", sans-serif',
fontSize: 48,
fontWeight: '900',
color: '#FFFFFF',
stroke: '#000000',
strokeWidth: 4,
position: 'bottom',
animation: 'fade',
letterSpacing: '2px',
textTransform: 'uppercase',
},
cinematicBars: true,
vignette: true,
grain: false,
},
};
// Calculate composition duration based on template + clip count.
export const calculateMetadata = ({ props }) => {
const fps = 30;
const preset = TEMPLATE_PRESETS[props.template] || TEMPLATE_PRESETS.viral;
const clips = props.clips || [];
const clipCount = Math.max(clips.length, 1);
const transSec = preset.transitionDuration;
const totalSec = Math.min(
clipCount * preset.clipDuration - Math.max(0, clipCount - 1) * transSec,
30
);
return {
fps,
durationInFrames: Math.max(Math.round(totalSec * fps), fps),
width: 540,
height: 960,
};
};
// ─── Clip with zoom + fade transitions ───────────────────────────────────────
const ClipLayer = ({ src, preset, clipDurFrames }) => {
const frame = useCurrentFrame();
const fps = 30;
const transFrames = Math.round(preset.transitionDuration * fps);
const fadeIn = interpolate(frame, [0, Math.max(transFrames, 1)], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const fadeOut = interpolate(
frame,
[clipDurFrames - Math.max(transFrames, 1), clipDurFrames],
[1, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
);
const opacity = Math.min(fadeIn, fadeOut);
const scale = interpolate(frame, [0, clipDurFrames], [preset.zoom.start, preset.zoom.end], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<AbsoluteFill
style={{
opacity,
transform: `scale(${scale})`,
transformOrigin: 'center center',
overflow: 'hidden',
}}
>
<Video
src={src}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
muted
volume={0}
/>
</AbsoluteFill>
);
};
// ─── Subtitle overlay with template-specific animation ───────────────────────
const SubtitleLayer = ({ subtitles, preset }) => {
const frame = useCurrentFrame();
const fps = 30;
const currentTime = frame / fps;
const activeSub = subtitles.find(
(s) => currentTime >= s.start && currentTime < s.end
);
if (!activeSub) return null;
const style = preset.subtitle;
const subStartFrame = Math.round(activeSub.start * fps);
const subFrame = frame - subStartFrame;
const IN_FRAMES = 7;
let opacity = 1;
let scale = 1;
let translateY = 0;
if (style.animation === 'pop') {
opacity = interpolate(subFrame, [0, 3], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
scale = interpolate(
subFrame,
[0, Math.round(IN_FRAMES * 0.6), IN_FRAMES],
[0.5, 1.12, 1.0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
);
} else if (style.animation === 'fade') {
opacity = interpolate(subFrame, [0, IN_FRAMES], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
} else if (style.animation === 'slide') {
opacity = interpolate(subFrame, [0, IN_FRAMES], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
translateY = interpolate(subFrame, [0, IN_FRAMES], [24, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
} else if (style.animation === 'slam') {
opacity = interpolate(subFrame, [0, 2], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
scale = interpolate(
subFrame,
[0, Math.round(IN_FRAMES * 0.4), IN_FRAMES],
[2.2, 0.88, 1.0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
);
}
const topPct =
style.position === 'top'
? '18%'
: style.position === 'middle'
? '50%'
: '82%';
return (
<AbsoluteFill style={{ pointerEvents: 'none' }}>
<div
style={{
position: 'absolute',
left: '50%',
top: topPct,
width: '88%',
transform: `translate(-50%, -50%) scale(${scale}) translateY(${translateY}px)`,
opacity,
textAlign: 'center',
fontFamily: style.fontFamily,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
color: style.color,
letterSpacing: style.letterSpacing,
textTransform: style.textTransform,
WebkitTextStroke: `${style.strokeWidth}px ${style.stroke}`,
paintOrder: 'stroke fill',
lineHeight: 1.25,
wordBreak: 'break-word',
textShadow: '0 2px 12px rgba(0,0,0,0.9)',
padding: '6px 10px',
}}
>
{activeSub.text}
</div>
</AbsoluteFill>
);
};
// ─── Cinematic letterbox bars ────────────────────────────────────────────────
const CinematicBars = () => (
<>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 70,
backgroundColor: '#000',
zIndex: 10,
}}
/>
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 70,
backgroundColor: '#000',
zIndex: 10,
}}
/>
</>
);
// ─── Film grain (CSS noise simulation) ───────────────────────────────────────
const FilmGrain = () => {
const frame = useCurrentFrame();
// Alternate between two grain seeds per frame for animation feel
const opacity = 0.04 + (frame % 2) * 0.02;
return (
<AbsoluteFill
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E")`,
opacity,
pointerEvents: 'none',
mixBlendMode: 'overlay',
}}
/>
);
};
// ─── Main composition ─────────────────────────────────────────────────────────
export const ReelsComposition = ({
clips = [],
subtitles = [],
template = 'viral',
backgroundImage = null,
backgroundVideo = null,
}) => {
const { fps, durationInFrames } = useVideoConfig();
const preset = TEMPLATE_PRESETS[template] || TEMPLATE_PRESETS.viral;
const clipDurFrames = Math.round(preset.clipDuration * fps);
const transFrames = Math.round(preset.transitionDuration * fps);
return (
<AbsoluteFill style={{ backgroundColor: '#000000', overflow: 'hidden' }}>
{/* ── LAYER 1: Background content ── */}
{backgroundVideo ? (
<AbsoluteFill>
<Video
src={backgroundVideo}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
muted
volume={0}
loop
/>
</AbsoluteFill>
) : backgroundImage ? (
<AbsoluteFill>
<Img
src={backgroundImage}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</AbsoluteFill>
) : (
// Stock clips with template-driven transitions + zoom
clips.map((clip, i) => {
const from = i * (clipDurFrames - transFrames);
return (
<Sequence
key={i}
from={from}
durationInFrames={clipDurFrames + transFrames}
>
<ClipLayer src={clip} preset={preset} clipDurFrames={clipDurFrames} />
</Sequence>
);
})
)}
{/* ── LAYER 2: Template color grade overlay ── */}
<AbsoluteFill
style={{
backgroundColor: preset.overlay.color,
opacity: preset.overlay.opacity,
pointerEvents: 'none',
}}
/>
{/* ── LAYER 3: Vignette ── */}
{preset.vignette && (
<AbsoluteFill
style={{
background:
'radial-gradient(ellipse at 50% 50%, transparent 35%, rgba(0,0,0,0.75) 100%)',
pointerEvents: 'none',
}}
/>
)}
{/* ── LAYER 4: Film grain ── */}
{preset.grain && <FilmGrain />}
{/* ── LAYER 5: Subtitles ── */}
<SubtitleLayer subtitles={subtitles} preset={preset} />
{/* ── LAYER 6: Cinematic bars (on top of subtitles for consistency) ── */}
{preset.cinematicBars && <CinematicBars />}
</AbsoluteFill>
);
};