Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| }; | |