
Lyrics Overlay
- 3 installs
- 4 repo stars
- Updated March 11, 2026
- aviz85/ai-music-video-maker
lyrics-overlay is a Claude Code skill that adds animated lyrics and caption overlays to videos using Remotion React components.
About
A Claude Code skill that overlays animated lyrics and captions onto videos using Remotion React components. A developer picks one of seven template styles based on song genre and mood, feeds it word-level timing from a transcript, and renders the result. It supports RTL text and per-style props like colors, position, and glow.
- Adds animated lyrics/captions to videos using Remotion React components
- Seven template styles (karaoke, minimal, fade, neon, cinematic, bounce, typewriter)
- Parses ElevenLabs word-level transcript JSON for word-timed karaoke highlighting
Lyrics Overlay by the numbers
- 3 all-time installs (skills.sh)
- Ranked #1,155 of 1,337 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
lyrics-overlay capabilities & compatibility
No API keys required for the overlay itself; needs a Remotion project.
- Capabilities
- video generation · transcription
- Use cases
- video generation · transcription
- Pricing
- Free
What lyrics-overlay says it does
Add beautiful animated lyrics/captions to videos using Remotion with multiple template styles.
All styles have **slower decay** - words linger ~1.5 seconds after their timing ends for better readability.
npx skills add https://github.com/aviz85/ai-music-video-maker --skill lyrics-overlayAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 4 |
| Last updated | March 11, 2026 |
| Repository | aviz85/ai-music-video-maker ↗ |
What it does
Add word-timed karaoke or caption overlays to a video by choosing a Remotion style and feeding ElevenLabs transcript timing.
Who is it for?
Rendering karaoke-style, word-timed lyric overlays on music videos with a style matched to the song's genre.
Skip if: Videos without word-level timing data, since the parser depends on transcript timestamps.
When should I use this skill?
You need lyrics, captions, subtitles, karaoke, or song-text overlaid on a video.
What you get
A rendered video with animated lyric captions timed to the audio.
- Video with animated lyric/caption overlay
By the numbers
- 7 template styles
- words linger ~1.5 seconds after timing ends
Files
Lyrics Overlay Skill
Add beautiful animated lyrics/captions to videos using Remotion with multiple template styles.
Style Selection Guide (For Agent)
Choose style based on song mood, genre, and visual aesthetic:
| Style | Component | Best For | Mood/Vibe |
|---|---|---|---|
| karaoke | LyricsOverlay | Pop, dance, singalong | Energetic, fun, accessible |
| minimal | LyricsOverlay | Ballads, acoustic | Clean, elegant, subtle |
| fade | LyricsOverlay | Narration, spoken word | Gentle, smooth, understated |
| neon | LyricsOverlayNeon | Electronic, synthwave, EDM | Cyberpunk, futuristic, high-energy |
| cinematic | LyricsOverlayCinematic | Epic, orchestral, trailers | Dramatic, powerful, movie-like |
| bounce | LyricsOverlayBounce | Kids, fun, upbeat | Playful, colorful, joyful |
| typewriter | LyricsOverlayTypewriter | Storytelling, retro, indie | Nostalgic, intimate, artistic |
Style Details
1. Karaoke (Default)
- Position: Bottom
- Animation: Spring bounce word-by-word, current word glows
- Colors: White text, gold/orange highlight
- Use when: Standard music videos, pop songs, singalong content
2. Minimal
- Position: Bottom
- Animation: Full line shows, current word scales up slightly
- Colors: Subtle white/gray
- Use when: Clean look needed, don't distract from visuals
3. Fade
- Position: Bottom
- Animation: Words fade in smoothly as spoken
- Colors: Pure white
- Use when: Speeches, narration, gentle songs
4. Neon (Cyberpunk)
- Position: Bottom or center
- Animation: Chromatic aberration, glitch on entrance, flicker
- Colors: Magenta glow (#FF00FF), cyan split (#00FFFF)
- Effects: Scanlines, RGB split, multi-layer glow
- Use when: Electronic music, synthwave, futuristic aesthetic
5. Cinematic (Movie Trailer)
- Position: CENTER (large, dramatic)
- Animation: Words scale from big to normal with blur, uppercase
- Colors: Gold accent (#FFD700) on white
- Effects: Vignette, letterbox bars, dramatic glow
- Use when: Epic songs, trailers, powerful moments, orchestral
6. Bounce (Playful)
- Position: Center
- Animation: Words bounce in from random positions, rotation
- Colors: Rainbow palette, each word different color
- Effects: Continuous subtle bounce on active word
- Use when: Fun/upbeat songs, kids content, party vibes
7. Typewriter (Retro)
- Position: Center
- Animation: Character-by-character reveal with cursor
- Themes:
classic(white),terminal(green),vintage(sepia) - Effects: Blinking cursor, themed backgrounds
- Use when: Storytelling, indie, nostalgic feel, artistic
Quick Start
1. Prepare Lyrics Data
cd skills/transcribe/scripts
npx tsx transcribe.ts -i <audio.mp3> -o <project>/subtitles --json2. Create Composition
// Pick the right component for your style:
import { LyricsOverlay } from './LyricsOverlay'; // karaoke, minimal, fade
import { LyricsOverlayNeon } from './LyricsOverlayNeon'; // neon
import { LyricsOverlayCinematic } from './LyricsOverlayCinematic'; // cinematic
import { LyricsOverlayBounce } from './LyricsOverlayBounce'; // bounce
import { LyricsOverlayTypewriter } from './LyricsOverlayTypewriter'; // typewriter
import { parseElevenLabsTranscript } from './LyricsOverlay';
import { staticFile } from 'remotion';
const transcript = require('../../public/lyrics/subtitles.json');
export const MyVideo: React.FC = () => {
const lyrics = parseElevenLabsTranscript(transcript, {
maxWordsPerLine: 6,
lineGapThreshold: 0.8,
});
// Example: Cinematic style
return (
<LyricsOverlayCinematic
videoSrc={staticFile('videos/input.mp4')}
lyrics={lyrics}
fontSize={90}
accentColor="#FFD700"
isRTL={true}
useOffthreadVideo={true}
/>
);
};3. Render
cd ~/remotion-assistant
npx remotion render MyVideo out/final.mp4Props Reference
Common Props (All Styles)
| Prop | Type | Default | Description |
|---|---|---|---|
videoSrc | string | required | Video file path |
lyrics | LyricsData | required | Parsed lyrics data |
fontSize | number | varies | Base font size |
isRTL | boolean | auto | Right-to-left mode |
useOffthreadVideo | boolean | false | Use OffthreadVideo for better memory |
LyricsOverlay (karaoke/minimal/fade)
| Prop | Type | Default |
|---|---|---|
style | 'karaoke' \ | 'minimal' \ |
position | 'bottom' \ | 'center' \ |
primaryColor | string | '#FFFFFF' |
highlightColor | string | '#FF6B35' |
LyricsOverlayNeon
| Prop | Type | Default |
|---|---|---|
glowColor | string | '#FF00FF' |
secondaryGlow | string | '#00FFFF' |
glitchIntensity | number | 0.5 |
LyricsOverlayCinematic
| Prop | Type | Default |
|---|---|---|
accentColor | string | '#FFD700' |
letterSpacing | number | 0.15 |
| Position | - | CENTER (fixed) |
LyricsOverlayBounce
| Prop | Type | Default |
|---|---|---|
highlightColor | string | '#FFFFFF' |
bounceIntensity | number | 0.7 |
position | 'bottom' \ | 'center' \ |
LyricsOverlayTypewriter
| Prop | Type | Default |
|---|---|---|
theme | 'classic' \ | 'terminal' \ |
showCursor | boolean | true |
typeSpeed | number | 20 |
position | 'bottom' \ | 'center' \ |
Color Presets
By Style
| Style | Recommended Colors |
|---|---|
| karaoke | Gold #FFD700, Orange #FF6B35 |
| neon | Magenta #FF00FF + Cyan #00FFFF |
| cinematic | Gold #FFD700, Silver #C0C0C0 |
| bounce | Rainbow (automatic) |
| typewriter | Theme-based (green/sepia/white) |
By Song Genre
| Genre | Style | Colors |
|---|---|---|
| Pop | karaoke | #FF6B35 highlight |
| Electronic | neon | #FF00FF / #00FFFF |
| Rock/Metal | cinematic | #FF0000 accent |
| Ballad | minimal | #FFFFFF highlight |
| Kids | bounce | Rainbow |
| Indie | typewriter vintage | Sepia |
Integration with music-video
This skill is called in Step 7 of the music-video pipeline.
The agent should select style based on: 1. Song genre (electronic → neon, epic → cinematic) 2. Energy level (high → bounce/neon, low → minimal/fade) 3. Visual aesthetic (modern → neon, retro → typewriter, dramatic → cinematic)
Text Decay Behavior
All styles have slower decay - words linger ~1.5 seconds after their timing ends for better readability. The fade starts AFTER the line ends, not before.
Remotion Templates (Included)
All Remotion components are included in this skill:
.claude/skills/lyrics-overlay/remotion-templates/
├── compositions/
│ ├── LyricsOverlay.tsx # karaoke, minimal, fade
│ ├── LyricsOverlayNeon.tsx # neon/cyberpunk
│ ├── LyricsOverlayCinematic.tsx # cinematic/epic
│ ├── LyricsOverlayBounce.tsx # bounce/playful
│ └── LyricsOverlayTypewriter.tsx # typewriter/retro
└── utils/
└── lyricsParser.ts # ElevenLabs JSON parserSetup Remotion Project
Copy templates to your Remotion project:
# One-time setup
cp -r .claude/skills/lyrics-overlay/remotion-templates/* ~/remotion-assistant/src/Dependencies
- Remotion project (use templates above)
transcribeskill - For word-level timing (included in project)
import {
AbsoluteFill,
Video,
OffthreadVideo,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
Easing,
} from 'remotion';
// Types for lyrics data
export interface LyricWord {
word: string;
start: number; // seconds
end: number; // seconds
emphasis?: 'hero' | 'strong' | 'normal';
}
export interface LyricLine {
lineStart: number;
lineEnd: number;
words: LyricWord[];
}
export interface LyricsData {
lines: LyricLine[];
duration: number;
}
// Style types
export type LyricsStyle = 'fade' | 'karaoke' | 'minimal';
export type LyricsPosition = 'bottom' | 'center' | 'top';
export interface LyricsOverlayProps {
videoSrc: string;
lyrics: LyricsData;
style?: LyricsStyle;
fontSize?: number;
position?: LyricsPosition;
primaryColor?: string;
secondaryColor?: string;
highlightColor?: string;
fontFamily?: string;
isRTL?: boolean;
showGradientOverlay?: boolean;
useOffthreadVideo?: boolean;
}
// Helper to detect Hebrew/Arabic text for RTL
const isRTLText = (text: string): boolean => {
const rtlRegex = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F]/;
return rtlRegex.test(text);
};
// Animated word component for karaoke style
interface KaraokeWordProps {
word: LyricWord;
lineEnd: number;
frame: number;
fps: number;
index: number;
primaryColor: string;
highlightColor: string;
fontSize: number;
isRTL: boolean;
}
const KaraokeWord: React.FC<KaraokeWordProps> = ({
word,
lineEnd,
frame,
fps,
index,
primaryColor,
highlightColor,
fontSize,
isRTL,
}) => {
const wordStartFrame = word.start * fps;
const wordEndFrame = word.end * fps;
const lineEndFrame = lineEnd * fps;
const localFrame = frame - wordStartFrame;
// Word hasn't appeared yet
if (frame < wordStartFrame) {
return (
<span
style={{
opacity: 0.4,
display: 'inline-block',
color: primaryColor,
fontSize,
}}
>
{word.word}
{!isRTL && '\u00A0'}
</span>
);
}
// Spring animation for entrance
const getSpringConfig = () => {
switch (word.emphasis) {
case 'hero':
return { damping: 8, stiffness: 200, mass: 0.3 };
case 'strong':
return { damping: 10, stiffness: 160, mass: 0.4 };
default:
return { damping: 12, stiffness: 140, mass: 0.5 };
}
};
const scale = spring({
frame: localFrame,
fps,
config: getSpringConfig(),
});
// Calculate if word is currently being spoken
const isActive = frame >= wordStartFrame && frame <= wordEndFrame;
const isPast = frame > wordEndFrame;
// Entrance animation
const translateY = interpolate(localFrame, [0, 5], [15, 0], {
extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
});
const opacity = interpolate(localFrame, [0, 3], [0.4, 1], { extrapolateRight: 'clamp' });
// Line exit animation - slower decay, words linger after timing ends
const exitDuration = 35; // ~1.4 sec at 25fps - slower decay
const exitStart = lineEndFrame; // Start fading AFTER line ends
const exitProgress = frame > exitStart
? interpolate(frame, [exitStart, exitStart + exitDuration], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
})
: 0;
const exitOpacity = 1 - exitProgress;
const exitTranslateY = exitProgress * 15;
// Styling based on emphasis
const getFontSize = () => {
switch (word.emphasis) {
case 'hero': return fontSize * 1.3;
case 'strong': return fontSize * 1.1;
default: return fontSize;
}
};
const getColor = () => {
if (isActive) return highlightColor;
if (isPast) return primaryColor;
return primaryColor;
};
const getFontWeight = () => {
switch (word.emphasis) {
case 'hero': return 900;
case 'strong': return 700;
default: return 600;
}
};
const textShadow = isActive
? `0 0 30px ${highlightColor}, 0 0 60px ${highlightColor}80, 0 4px 15px rgba(0,0,0,0.9)`
: '0 2px 15px rgba(0,0,0,0.9), 0 0 30px rgba(0,0,0,0.5)';
return (
<span
style={{
display: 'inline-block',
opacity: opacity * exitOpacity,
transform: `translateY(${translateY + exitTranslateY}px) scale(${scale})`,
fontSize: getFontSize(),
fontWeight: getFontWeight(),
color: getColor(),
textShadow,
marginLeft: isRTL ? 12 : 0,
marginRight: isRTL ? 0 : 12,
transition: 'color 0.15s ease',
}}
>
{word.word}
</span>
);
};
// Fade style word component
interface FadeWordProps {
word: LyricWord;
lineEnd: number;
frame: number;
fps: number;
primaryColor: string;
fontSize: number;
isRTL: boolean;
}
const FadeWord: React.FC<FadeWordProps> = ({
word,
lineEnd,
frame,
fps,
primaryColor,
fontSize,
isRTL,
}) => {
const wordStartFrame = word.start * fps;
const lineEndFrame = lineEnd * fps;
const localFrame = frame - wordStartFrame;
// Word hasn't appeared yet
if (frame < wordStartFrame) {
return <span style={{ opacity: 0 }}>{word.word}{!isRTL && '\u00A0'}</span>;
}
// Fade in
const opacity = interpolate(localFrame, [0, 8], [0, 1], { extrapolateRight: 'clamp' });
// Line exit - slower decay
const exitDuration = 35;
const exitStart = lineEndFrame;
const exitOpacity = frame > exitStart
? interpolate(frame, [exitStart, exitStart + exitDuration], [1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
})
: 1;
return (
<span
style={{
display: 'inline-block',
opacity: opacity * exitOpacity,
fontSize,
fontWeight: 600,
color: primaryColor,
textShadow: '0 2px 15px rgba(0,0,0,0.9), 0 0 30px rgba(0,0,0,0.5)',
marginLeft: isRTL ? 8 : 0,
marginRight: isRTL ? 0 : 8,
}}
>
{word.word}
</span>
);
};
// Minimal style - shows whole line, highlights current word
interface MinimalLineProps {
line: LyricLine;
frame: number;
fps: number;
primaryColor: string;
highlightColor: string;
fontSize: number;
isRTL: boolean;
}
const MinimalLine: React.FC<MinimalLineProps> = ({
line,
frame,
fps,
primaryColor,
highlightColor,
fontSize,
isRTL,
}) => {
const lineStartFrame = line.lineStart * fps;
const lineEndFrame = line.lineEnd * fps;
// Extended visibility for slower decay
if (frame < lineStartFrame - 5 || frame > lineEndFrame + 40) return null;
// Line entrance/exit - slower decay after line ends
const entranceProgress = interpolate(frame, [lineStartFrame - 5, lineStartFrame + 5], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const exitProgress = interpolate(frame, [lineEndFrame, lineEndFrame + 35], [1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
});
const opacity = Math.min(entranceProgress, exitProgress);
return (
<div
style={{
opacity,
direction: isRTL ? 'rtl' : 'ltr',
textAlign: 'center',
}}
>
{line.words.map((word, idx) => {
const isActive = frame >= word.start * fps && frame <= word.end * fps;
return (
<span
key={idx}
style={{
display: 'inline-block',
fontSize,
fontWeight: isActive ? 700 : 500,
color: isActive ? highlightColor : primaryColor,
textShadow: isActive
? `0 0 20px ${highlightColor}, 0 2px 10px rgba(0,0,0,0.9)`
: '0 2px 10px rgba(0,0,0,0.8)',
marginLeft: isRTL ? 8 : 0,
marginRight: isRTL ? 0 : 8,
transition: 'all 0.15s ease',
transform: isActive ? 'scale(1.05)' : 'scale(1)',
}}
>
{word.word}
</span>
);
})}
</div>
);
};
// Lyric line component
interface LyricLineComponentProps {
line: LyricLine;
frame: number;
fps: number;
style: LyricsStyle;
primaryColor: string;
highlightColor: string;
fontSize: number;
isRTL: boolean;
}
const LyricLineComponent: React.FC<LyricLineComponentProps> = ({
line,
frame,
fps,
style,
primaryColor,
highlightColor,
fontSize,
isRTL,
}) => {
const lineStartFrame = line.lineStart * fps;
const lineEndFrame = line.lineEnd * fps;
// Line not visible yet or already gone (extended for slower decay)
if (frame < lineStartFrame - 5 || frame > lineEndFrame + 40) return null;
if (style === 'minimal') {
return (
<MinimalLine
line={line}
frame={frame}
fps={fps}
primaryColor={primaryColor}
highlightColor={highlightColor}
fontSize={fontSize}
isRTL={isRTL}
/>
);
}
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'baseline',
flexWrap: 'wrap',
gap: '4px 0',
direction: isRTL ? 'rtl' : 'ltr',
}}
>
{line.words.map((word, index) =>
style === 'karaoke' ? (
<KaraokeWord
key={`${word.word}-${index}`}
word={word}
lineEnd={line.lineEnd}
frame={frame}
fps={fps}
index={index}
primaryColor={primaryColor}
highlightColor={highlightColor}
fontSize={fontSize}
isRTL={isRTL}
/>
) : (
<FadeWord
key={`${word.word}-${index}`}
word={word}
lineEnd={line.lineEnd}
frame={frame}
fps={fps}
primaryColor={primaryColor}
fontSize={fontSize}
isRTL={isRTL}
/>
)
)}
</div>
);
};
// Main LyricsOverlay component
export const LyricsOverlay: React.FC<LyricsOverlayProps> = ({
videoSrc,
lyrics,
style = 'karaoke',
fontSize = 56,
position = 'bottom',
primaryColor = '#FFFFFF',
secondaryColor = '#E0E0E0',
highlightColor = '#FF6B35',
fontFamily = 'system-ui, -apple-system, sans-serif',
isRTL,
showGradientOverlay = true,
useOffthreadVideo = false,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Auto-detect RTL if not specified
const effectiveRTL = isRTL ?? (lyrics.lines.length > 0 && lyrics.lines[0].words.length > 0
? isRTLText(lyrics.lines[0].words[0].word)
: false);
// Position styling
const getPositionStyle = (): React.CSSProperties => {
switch (position) {
case 'top':
return { top: 80, left: 60, right: 60 };
case 'center':
return { top: '50%', left: 60, right: 60, transform: 'translateY(-50%)' };
case 'bottom':
default:
return { bottom: 100, left: 60, right: 60 };
}
};
// Gradient position
const getGradientStyle = (): React.CSSProperties => {
switch (position) {
case 'top':
return {
top: 0,
left: 0,
right: 0,
height: '35%',
background: 'linear-gradient(to bottom, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)',
};
case 'center':
return {
top: '30%',
left: 0,
right: 0,
height: '40%',
background: 'linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.5) 30%, rgba(0,0,0,0.5) 70%, transparent 100%)',
};
case 'bottom':
default:
return {
bottom: 0,
left: 0,
right: 0,
height: '35%',
background: 'linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)',
};
}
};
const VideoComponent = useOffthreadVideo ? OffthreadVideo : Video;
return (
<AbsoluteFill style={{ fontFamily }}>
{/* Video background */}
<VideoComponent
src={videoSrc}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{/* Gradient overlay for text readability */}
{showGradientOverlay && (
<div
style={{
position: 'absolute',
...getGradientStyle(),
pointerEvents: 'none',
}}
/>
)}
{/* Lyrics container */}
<div
style={{
position: 'absolute',
...getPositionStyle(),
}}
>
{lyrics.lines.map((line, index) => (
<LyricLineComponent
key={index}
line={line}
frame={frame}
fps={fps}
style={style}
primaryColor={primaryColor}
highlightColor={highlightColor}
fontSize={fontSize}
isRTL={effectiveRTL}
/>
))}
</div>
</AbsoluteFill>
);
};
// Re-export utility functions for parsing lyrics
export { parseSRT, parseElevenLabsTranscript } from '../utils/lyricsParser';
// Export default demo lyrics for testing
export const demoLyrics: LyricsData = {
lines: [
{
lineStart: 0,
lineEnd: 3,
words: [
{ word: 'Welcome', start: 0, end: 0.8, emphasis: 'hero' },
{ word: 'to', start: 0.9, end: 1.2 },
{ word: 'the', start: 1.3, end: 1.5 },
{ word: 'lyrics', start: 1.6, end: 2.2, emphasis: 'strong' },
{ word: 'overlay!', start: 2.3, end: 3, emphasis: 'hero' },
],
},
{
lineStart: 4,
lineEnd: 7,
words: [
{ word: 'This', start: 4, end: 4.3 },
{ word: 'is', start: 4.4, end: 4.6 },
{ word: 'a', start: 4.7, end: 4.8 },
{ word: 'demo', start: 4.9, end: 5.5, emphasis: 'strong' },
{ word: 'composition.', start: 5.6, end: 7 },
],
},
],
duration: 10,
};
import {
AbsoluteFill,
Video,
OffthreadVideo,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
Easing,
random,
} from 'remotion';
import { LyricsData, LyricLine, LyricWord } from './LyricsOverlay';
export type { LyricsData, LyricLine, LyricWord };
export type BouncePosition = 'bottom' | 'center' | 'top';
export interface LyricsOverlayBounceProps {
videoSrc: string;
lyrics: LyricsData;
fontSize?: number;
position?: BouncePosition;
primaryColor?: string;
highlightColor?: string;
fontFamily?: string;
isRTL?: boolean;
useOffthreadVideo?: boolean;
bounceIntensity?: number; // 0-1
}
const isRTLText = (text: string): boolean => {
const rtlRegex = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F]/;
return rtlRegex.test(text);
};
// Color palette for variety
const colors = [
'#FF6B6B', // Coral
'#4ECDC4', // Teal
'#FFE66D', // Yellow
'#95E1D3', // Mint
'#F38181', // Salmon
'#AA96DA', // Lavender
'#FCBAD3', // Pink
'#A8D8EA', // Sky blue
];
// Bouncy word with playful physics
interface BounceWordProps {
word: LyricWord;
lineEnd: number;
frame: number;
fps: number;
index: number;
primaryColor: string;
highlightColor: string;
fontSize: number;
isRTL: boolean;
bounceIntensity: number;
seed: string;
}
const BounceWord: React.FC<BounceWordProps> = ({
word,
lineEnd,
frame,
fps,
index,
primaryColor,
highlightColor,
fontSize,
isRTL,
bounceIntensity,
seed,
}) => {
const wordStartFrame = word.start * fps;
const wordEndFrame = word.end * fps;
const lineEndFrame = lineEnd * fps;
const localFrame = frame - wordStartFrame;
// Not visible yet
if (frame < wordStartFrame) {
return (
<span style={{ opacity: 0, display: 'inline-block' }}>
{word.word}
</span>
);
}
const isActive = frame >= wordStartFrame && frame <= wordEndFrame;
// Random starting position for each word
const startY = (random(`${seed}-y`) - 0.5) * 100 * bounceIntensity;
const startX = (random(`${seed}-x`) - 0.5) * 40 * bounceIntensity;
const startRotation = (random(`${seed}-rot`) - 0.5) * 30 * bounceIntensity;
// Extra bouncy spring for entrance
const springProgress = spring({
frame: localFrame,
fps,
config: {
damping: 8,
stiffness: 180,
mass: 0.4,
},
});
// Position animation
const translateY = interpolate(springProgress, [0, 1], [startY + 60, 0]);
const translateX = interpolate(springProgress, [0, 1], [startX, 0]);
const rotation = interpolate(springProgress, [0, 1], [startRotation, 0]);
const scale = spring({
frame: localFrame,
fps,
config: { damping: 6, stiffness: 200, mass: 0.3 },
});
// Continuous subtle bounce for active word
const activeBounce = isActive
? Math.sin(localFrame * 0.5) * 3
: 0;
// Exit animation - slower decay
const exitDuration = 35;
const exitStart = lineEndFrame; // Start AFTER line ends
const exitProgress = frame > exitStart
? interpolate(frame, [exitStart, exitStart + exitDuration], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
: 0;
const exitY = exitProgress * -50;
const exitOpacity = 1 - exitProgress;
const exitScale = 1 - exitProgress * 0.3;
// Opacity entrance
const opacity = interpolate(localFrame, [0, 5], [0, 1], { extrapolateRight: 'clamp' });
// Pick a fun color for each word
const wordColor = isActive
? highlightColor
: colors[index % colors.length];
// Size based on emphasis
const getFontSize = () => {
switch (word.emphasis) {
case 'hero': return fontSize * 1.4;
case 'strong': return fontSize * 1.15;
default: return fontSize;
}
};
const textShadow = isActive
? `0 0 20px ${highlightColor}, 0 0 40px ${highlightColor}66, 0 4px 15px rgba(0,0,0,0.8)`
: `0 4px 15px rgba(0,0,0,0.6), 0 0 10px ${wordColor}44`;
return (
<span
style={{
display: 'inline-block',
opacity: opacity * exitOpacity,
transform: `
translateY(${translateY + activeBounce + exitY}px)
translateX(${translateX}px)
rotate(${rotation}deg)
scale(${scale * exitScale})
`,
fontSize: getFontSize(),
fontWeight: 700,
color: wordColor,
textShadow,
marginLeft: isRTL ? 12 : 0,
marginRight: isRTL ? 0 : 12,
transition: 'color 0.15s ease',
}}
>
{word.word}
</span>
);
};
// Bounce line
interface BounceLineProps {
line: LyricLine;
frame: number;
fps: number;
primaryColor: string;
highlightColor: string;
fontSize: number;
isRTL: boolean;
bounceIntensity: number;
lineIndex: number;
}
const BounceLine: React.FC<BounceLineProps> = ({
line,
frame,
fps,
primaryColor,
highlightColor,
fontSize,
isRTL,
bounceIntensity,
lineIndex,
}) => {
const lineStartFrame = line.lineStart * fps;
const lineEndFrame = line.lineEnd * fps;
// Extended visibility for slower decay
if (frame < lineStartFrame - 5 || frame > lineEndFrame + 40) return null;
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexWrap: 'wrap',
direction: isRTL ? 'rtl' : 'ltr',
gap: '8px 0',
}}
>
{line.words.map((word, index) => (
<BounceWord
key={`${word.word}-${index}`}
word={word}
lineEnd={line.lineEnd}
frame={frame}
fps={fps}
index={index}
primaryColor={primaryColor}
highlightColor={highlightColor}
fontSize={fontSize}
isRTL={isRTL}
bounceIntensity={bounceIntensity}
seed={`line-${lineIndex}-word-${index}`}
/>
))}
</div>
);
};
export const LyricsOverlayBounce: React.FC<LyricsOverlayBounceProps> = ({
videoSrc,
lyrics,
fontSize = 64,
position = 'center',
primaryColor = '#FFFFFF',
highlightColor = '#FFFFFF',
fontFamily = '"Fredoka One", "Bubblegum Sans", "Comic Sans MS", cursive',
isRTL,
useOffthreadVideo = false,
bounceIntensity = 0.7,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const effectiveRTL = isRTL ?? (lyrics.lines.length > 0 && lyrics.lines[0].words.length > 0
? isRTLText(lyrics.lines[0].words[0].word)
: false);
const getPositionStyle = (): React.CSSProperties => {
switch (position) {
case 'top':
return { top: 100, left: 60, right: 60 };
case 'center':
return { top: '50%', left: 60, right: 60, transform: 'translateY(-50%)' };
case 'bottom':
default:
return { bottom: 120, left: 60, right: 60 };
}
};
const VideoComponent = useOffthreadVideo ? OffthreadVideo : Video;
return (
<AbsoluteFill style={{ fontFamily }}>
<VideoComponent
src={videoSrc}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{/* Colorful gradient overlay */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.5) 100%)',
pointerEvents: 'none',
}}
/>
{/* Lyrics container */}
<div
style={{
position: 'absolute',
...getPositionStyle(),
textAlign: 'center',
}}
>
{lyrics.lines.map((line, index) => (
<BounceLine
key={index}
line={line}
lineIndex={index}
frame={frame}
fps={fps}
primaryColor={primaryColor}
highlightColor={highlightColor}
fontSize={fontSize}
isRTL={effectiveRTL}
bounceIntensity={bounceIntensity}
/>
))}
</div>
</AbsoluteFill>
);
};
export { parseSRT, parseElevenLabsTranscript } from '../utils/lyricsParser';
import {
AbsoluteFill,
Video,
OffthreadVideo,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
Easing,
} from 'remotion';
import { LyricsData, LyricLine, LyricWord } from './LyricsOverlay';
export type { LyricsData, LyricLine, LyricWord };
export interface LyricsOverlayCinematicProps {
videoSrc: string;
lyrics: LyricsData;
fontSize?: number;
primaryColor?: string;
accentColor?: string;
fontFamily?: string;
isRTL?: boolean;
useOffthreadVideo?: boolean;
letterSpacing?: number;
}
const isRTLText = (text: string): boolean => {
const rtlRegex = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F]/;
return rtlRegex.test(text);
};
// Cinematic word - big, dramatic, center-screen
interface CinematicWordProps {
word: LyricWord;
lineStart: number;
lineEnd: number;
frame: number;
fps: number;
index: number;
totalWords: number;
primaryColor: string;
accentColor: string;
fontSize: number;
isRTL: boolean;
letterSpacing: number;
}
const CinematicWord: React.FC<CinematicWordProps> = ({
word,
lineStart,
lineEnd,
frame,
fps,
index,
totalWords,
primaryColor,
accentColor,
fontSize,
isRTL,
letterSpacing,
}) => {
const wordStartFrame = word.start * fps;
const wordEndFrame = word.end * fps;
const lineEndFrame = lineEnd * fps;
const localFrame = frame - wordStartFrame;
// Not visible yet
if (frame < wordStartFrame - 10) return null;
const isActive = frame >= wordStartFrame && frame <= wordEndFrame;
const isPast = frame > wordEndFrame;
// Dramatic scale entrance - starts big, settles to normal
const entranceScale = spring({
frame: Math.max(0, localFrame),
fps,
config: { damping: 15, stiffness: 100, mass: 0.8 },
});
// Pre-entrance: word is very large and faded
const preEntranceScale = frame < wordStartFrame
? interpolate(frame, [wordStartFrame - 10, wordStartFrame], [2.5, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
: 1;
const preEntranceOpacity = frame < wordStartFrame
? interpolate(frame, [wordStartFrame - 10, wordStartFrame], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
: 1;
// Exit animation - slower decay, words linger after timing ends
const exitDuration = 45; // 1.8 sec at 25fps - much slower decay
const exitStart = lineEndFrame; // Start fading AFTER line ends, not before
const exitProgress = frame > exitStart
? interpolate(frame, [exitStart, exitStart + exitDuration], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic), // Smooth ease-out for natural decay
})
: 0;
const exitScale = 1 + exitProgress * 0.15; // Subtler scale
const exitOpacity = 1 - exitProgress;
// Blur effect for dramatic entrance
const blur = frame < wordStartFrame
? interpolate(frame, [wordStartFrame - 10, wordStartFrame], [8, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
: exitProgress * 4;
// Glow intensity
const glowIntensity = isActive ? 40 : isPast ? 15 : 25;
// Size based on emphasis
const getFontSize = () => {
switch (word.emphasis) {
case 'hero': return fontSize * 1.5;
case 'strong': return fontSize * 1.2;
default: return fontSize;
}
};
const textShadow = `
0 0 ${glowIntensity}px ${accentColor},
0 0 ${glowIntensity * 2}px ${accentColor}88,
0 0 ${glowIntensity * 3}px ${accentColor}44,
0 8px 30px rgba(0,0,0,0.9)
`;
const finalScale = preEntranceScale * entranceScale * exitScale;
const finalOpacity = preEntranceOpacity * exitOpacity;
return (
<span
style={{
display: 'inline-block',
opacity: finalOpacity,
transform: `scale(${finalScale})`,
fontSize: getFontSize(),
fontWeight: 800,
color: isActive ? '#FFFFFF' : primaryColor,
textShadow,
filter: `blur(${blur}px)`,
letterSpacing: `${letterSpacing}em`,
textTransform: 'uppercase',
marginLeft: isRTL ? 20 : 0,
marginRight: isRTL ? 0 : 20,
transition: 'color 0.2s ease',
}}
>
{word.word}
</span>
);
};
// Cinematic line - centered, large
interface CinematicLineProps {
line: LyricLine;
frame: number;
fps: number;
primaryColor: string;
accentColor: string;
fontSize: number;
isRTL: boolean;
letterSpacing: number;
lineIndex: number;
}
const CinematicLine: React.FC<CinematicLineProps> = ({
line,
frame,
fps,
primaryColor,
accentColor,
fontSize,
isRTL,
letterSpacing,
lineIndex,
}) => {
const lineStartFrame = line.lineStart * fps;
const lineEndFrame = line.lineEnd * fps;
// Extended visibility: allow words to linger 45 frames (1.8 sec) after line ends
if (frame < lineStartFrame - 15 || frame > lineEndFrame + 50) return null;
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexWrap: 'wrap',
direction: isRTL ? 'rtl' : 'ltr',
}}
>
{line.words.map((word, index) => (
<CinematicWord
key={`${word.word}-${index}`}
word={word}
lineStart={line.lineStart}
lineEnd={line.lineEnd}
frame={frame}
fps={fps}
index={index}
totalWords={line.words.length}
primaryColor={primaryColor}
accentColor={accentColor}
fontSize={fontSize}
isRTL={isRTL}
letterSpacing={letterSpacing}
/>
))}
</div>
);
};
export const LyricsOverlayCinematic: React.FC<LyricsOverlayCinematicProps> = ({
videoSrc,
lyrics,
fontSize = 90,
primaryColor = '#E8E8E8',
accentColor = '#FFD700', // Gold
fontFamily = '"Bebas Neue", "Anton", Impact, sans-serif',
isRTL,
useOffthreadVideo = false,
letterSpacing = 0.15,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const effectiveRTL = isRTL ?? (lyrics.lines.length > 0 && lyrics.lines[0].words.length > 0
? isRTLText(lyrics.lines[0].words[0].word)
: false);
const VideoComponent = useOffthreadVideo ? OffthreadVideo : Video;
return (
<AbsoluteFill style={{ fontFamily }}>
<VideoComponent
src={videoSrc}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{/* Dark cinematic bars */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '15%',
background: 'linear-gradient(to bottom, rgba(0,0,0,0.9) 0%, transparent 100%)',
}}
/>
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '15%',
background: 'linear-gradient(to top, rgba(0,0,0,0.9) 0%, transparent 100%)',
}}
/>
{/* Vignette */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.6) 100%)',
pointerEvents: 'none',
}}
/>
{/* Center lyrics */}
<div
style={{
position: 'absolute',
top: '50%',
left: 80,
right: 80,
transform: 'translateY(-50%)',
textAlign: 'center',
}}
>
{lyrics.lines.map((line, index) => (
<CinematicLine
key={index}
line={line}
lineIndex={index}
frame={frame}
fps={fps}
primaryColor={primaryColor}
accentColor={accentColor}
fontSize={fontSize}
isRTL={effectiveRTL}
letterSpacing={letterSpacing}
/>
))}
</div>
</AbsoluteFill>
);
};
export { parseSRT, parseElevenLabsTranscript } from '../utils/lyricsParser';
import {
AbsoluteFill,
Video,
OffthreadVideo,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
Easing,
random,
} from 'remotion';
import { LyricsData, LyricLine, LyricWord } from './LyricsOverlay';
// Re-export types
export type { LyricsData, LyricLine, LyricWord };
export type NeonLyricsPosition = 'bottom' | 'center' | 'top';
export interface LyricsOverlayNeonProps {
videoSrc: string;
lyrics: LyricsData;
fontSize?: number;
position?: NeonLyricsPosition;
primaryColor?: string;
glowColor?: string;
secondaryGlow?: string;
fontFamily?: string;
isRTL?: boolean;
showGradientOverlay?: boolean;
useOffthreadVideo?: boolean;
glitchIntensity?: number; // 0-1
}
// Helper to detect Hebrew/Arabic text for RTL
const isRTLText = (text: string): boolean => {
const rtlRegex = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F]/;
return rtlRegex.test(text);
};
// Neon word with glitch and chromatic aberration
interface NeonWordProps {
word: LyricWord;
lineEnd: number;
frame: number;
fps: number;
index: number;
primaryColor: string;
glowColor: string;
secondaryGlow: string;
fontSize: number;
isRTL: boolean;
glitchIntensity: number;
seed: string;
}
const NeonWord: React.FC<NeonWordProps> = ({
word,
lineEnd,
frame,
fps,
index,
primaryColor,
glowColor,
secondaryGlow,
fontSize,
isRTL,
glitchIntensity,
seed,
}) => {
const wordStartFrame = word.start * fps;
const wordEndFrame = word.end * fps;
const lineEndFrame = lineEnd * fps;
const localFrame = frame - wordStartFrame;
// Word hasn't appeared yet
if (frame < wordStartFrame) {
return (
<span
style={{
opacity: 0.15,
display: 'inline-block',
color: primaryColor,
fontSize,
filter: 'blur(2px)',
}}
>
{word.word}
</span>
);
}
// Calculate states
const isActive = frame >= wordStartFrame && frame <= wordEndFrame;
const isPast = frame > wordEndFrame;
const justAppeared = localFrame < 8;
// Entrance spring
const scale = spring({
frame: localFrame,
fps,
config: {
damping: 10,
stiffness: 200,
mass: 0.3,
},
});
// Glitch effect on entrance
const glitchOffset = justAppeared
? random(`${seed}-glitch-${index}`) * 6 * glitchIntensity * (1 - localFrame / 8)
: 0;
// Chromatic aberration offset
const chromaOffset = isActive ? 2 : 0;
// Entrance animation
const translateY = interpolate(localFrame, [0, 6], [25, 0], {
extrapolateRight: 'clamp',
easing: Easing.out(Easing.back(1.5)),
});
const opacity = interpolate(localFrame, [0, 4], [0, 1], { extrapolateRight: 'clamp' });
// Line exit animation - slower decay
const exitDuration = 35;
const exitStart = lineEndFrame; // Start AFTER line ends
const exitProgress = frame > exitStart
? interpolate(frame, [exitStart, exitStart + exitDuration], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' })
: 0;
const exitOpacity = 1 - exitProgress;
const exitScale = 1 - exitProgress * 0.2;
const exitBlur = exitProgress * 4;
// Neon flicker for active word
const flicker = isActive
? 0.9 + random(`${seed}-flicker-${frame % 5}`) * 0.1
: 1;
// Sizing based on emphasis
const getFontSize = () => {
switch (word.emphasis) {
case 'hero': return fontSize * 1.4;
case 'strong': return fontSize * 1.15;
default: return fontSize;
}
};
// Glow intensity
const glowIntensity = isActive ? 1 : isPast ? 0.4 : 0.2;
const textShadow = `
0 0 ${10 * glowIntensity}px ${glowColor},
0 0 ${20 * glowIntensity}px ${glowColor},
0 0 ${40 * glowIntensity}px ${glowColor},
0 0 ${80 * glowIntensity}px ${glowColor}88,
0 4px 20px rgba(0,0,0,0.9)
`;
return (
<span
style={{
position: 'relative',
display: 'inline-block',
marginLeft: isRTL ? 14 : 0,
marginRight: isRTL ? 0 : 14,
}}
>
{/* Chromatic aberration layers */}
{isActive && (
<>
<span
style={{
position: 'absolute',
left: -chromaOffset,
top: 0,
opacity: 0.7 * flicker,
fontSize: getFontSize(),
fontWeight: 700,
color: '#FF0066',
filter: 'blur(1px)',
transform: `translateY(${translateY + glitchOffset}px) scale(${scale * exitScale})`,
}}
>
{word.word}
</span>
<span
style={{
position: 'absolute',
left: chromaOffset,
top: 0,
opacity: 0.7 * flicker,
fontSize: getFontSize(),
fontWeight: 700,
color: '#00FFFF',
filter: 'blur(1px)',
transform: `translateY(${translateY + glitchOffset}px) scale(${scale * exitScale})`,
}}
>
{word.word}
</span>
</>
)}
{/* Main text */}
<span
style={{
position: 'relative',
display: 'inline-block',
opacity: opacity * exitOpacity * flicker,
transform: `translateY(${translateY + glitchOffset}px) scale(${scale * exitScale})`,
fontSize: getFontSize(),
fontWeight: 700,
color: isActive ? '#FFFFFF' : primaryColor,
textShadow,
filter: `blur(${exitBlur}px)`,
letterSpacing: isActive ? '0.05em' : '0',
transition: 'letter-spacing 0.2s ease',
}}
>
{word.word}
</span>
</span>
);
};
// Neon line component
interface NeonLineProps {
line: LyricLine;
frame: number;
fps: number;
primaryColor: string;
glowColor: string;
secondaryGlow: string;
fontSize: number;
isRTL: boolean;
glitchIntensity: number;
lineIndex: number;
}
const NeonLine: React.FC<NeonLineProps> = ({
line,
frame,
fps,
primaryColor,
glowColor,
secondaryGlow,
fontSize,
isRTL,
glitchIntensity,
lineIndex,
}) => {
const lineStartFrame = line.lineStart * fps;
const lineEndFrame = line.lineEnd * fps;
// Line not visible yet or already gone (extended for slower decay)
if (frame < lineStartFrame - 5 || frame > lineEndFrame + 40) return null;
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'baseline',
flexWrap: 'wrap',
gap: '4px 0',
direction: isRTL ? 'rtl' : 'ltr',
}}
>
{line.words.map((word, index) => (
<NeonWord
key={`${word.word}-${index}`}
word={word}
lineEnd={line.lineEnd}
frame={frame}
fps={fps}
index={index}
primaryColor={primaryColor}
glowColor={glowColor}
secondaryGlow={secondaryGlow}
fontSize={fontSize}
isRTL={isRTL}
glitchIntensity={glitchIntensity}
seed={`line-${lineIndex}-word-${index}`}
/>
))}
</div>
);
};
// Main Neon Lyrics Overlay component
export const LyricsOverlayNeon: React.FC<LyricsOverlayNeonProps> = ({
videoSrc,
lyrics,
fontSize = 60,
position = 'bottom',
primaryColor = '#E0E0E0',
glowColor = '#FF00FF', // Magenta neon
secondaryGlow = '#00FFFF', // Cyan
fontFamily = '"Orbitron", "Rajdhani", system-ui, sans-serif',
isRTL,
showGradientOverlay = true,
useOffthreadVideo = false,
glitchIntensity = 0.5,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Auto-detect RTL if not specified
const effectiveRTL = isRTL ?? (lyrics.lines.length > 0 && lyrics.lines[0].words.length > 0
? isRTLText(lyrics.lines[0].words[0].word)
: false);
// Position styling
const getPositionStyle = (): React.CSSProperties => {
switch (position) {
case 'top':
return { top: 80, left: 60, right: 60 };
case 'center':
return { top: '50%', left: 60, right: 60, transform: 'translateY(-50%)' };
case 'bottom':
default:
return { bottom: 100, left: 60, right: 60 };
}
};
// Dark gradient with color tint
const getGradientStyle = (): React.CSSProperties => {
const gradientColor = 'rgba(10, 0, 20, 0.85)'; // Dark purple-black
switch (position) {
case 'top':
return {
top: 0,
left: 0,
right: 0,
height: '40%',
background: `linear-gradient(to bottom, ${gradientColor} 0%, rgba(10, 0, 20, 0.5) 60%, transparent 100%)`,
};
case 'center':
return {
top: '25%',
left: 0,
right: 0,
height: '50%',
background: `linear-gradient(to bottom, transparent 0%, ${gradientColor} 30%, ${gradientColor} 70%, transparent 100%)`,
};
case 'bottom':
default:
return {
bottom: 0,
left: 0,
right: 0,
height: '40%',
background: `linear-gradient(to top, ${gradientColor} 0%, rgba(10, 0, 20, 0.5) 60%, transparent 100%)`,
};
}
};
const VideoComponent = useOffthreadVideo ? OffthreadVideo : Video;
return (
<AbsoluteFill style={{ fontFamily }}>
{/* Video background */}
<VideoComponent
src={videoSrc}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{/* Gradient overlay for text readability */}
{showGradientOverlay && (
<div
style={{
position: 'absolute',
...getGradientStyle(),
pointerEvents: 'none',
}}
/>
)}
{/* Scanline effect */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.1) 2px, rgba(0,0,0,0.1) 4px)',
pointerEvents: 'none',
opacity: 0.3,
}}
/>
{/* Lyrics container */}
<div
style={{
position: 'absolute',
...getPositionStyle(),
}}
>
{lyrics.lines.map((line, index) => (
<NeonLine
key={index}
line={line}
lineIndex={index}
frame={frame}
fps={fps}
primaryColor={primaryColor}
glowColor={glowColor}
secondaryGlow={secondaryGlow}
fontSize={fontSize}
isRTL={effectiveRTL}
glitchIntensity={glitchIntensity}
/>
))}
</div>
</AbsoluteFill>
);
};
// Re-export utility functions
export { parseSRT, parseElevenLabsTranscript } from '../utils/lyricsParser';
import {
AbsoluteFill,
Video,
OffthreadVideo,
useCurrentFrame,
useVideoConfig,
interpolate,
Easing,
} from 'remotion';
import { LyricsData, LyricLine, LyricWord } from './LyricsOverlay';
export type { LyricsData, LyricLine, LyricWord };
export type TypewriterPosition = 'bottom' | 'center' | 'top';
export type TypewriterTheme = 'classic' | 'terminal' | 'vintage';
export interface LyricsOverlayTypewriterProps {
videoSrc: string;
lyrics: LyricsData;
fontSize?: number;
position?: TypewriterPosition;
theme?: TypewriterTheme;
fontFamily?: string;
isRTL?: boolean;
useOffthreadVideo?: boolean;
showCursor?: boolean;
typeSpeed?: number; // characters per second
}
const isRTLText = (text: string): boolean => {
const rtlRegex = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F]/;
return rtlRegex.test(text);
};
// Theme configurations
const themes = {
classic: {
bgColor: 'rgba(0, 0, 0, 0.7)',
textColor: '#FFFFFF',
cursorColor: '#FFFFFF',
paperTexture: false,
},
terminal: {
bgColor: 'rgba(0, 20, 0, 0.85)',
textColor: '#00FF00',
cursorColor: '#00FF00',
paperTexture: false,
},
vintage: {
bgColor: 'rgba(45, 35, 25, 0.85)',
textColor: '#D4C5A9',
cursorColor: '#8B7355',
paperTexture: true,
},
};
// Typewriter line - character by character reveal
interface TypewriterLineProps {
line: LyricLine;
frame: number;
fps: number;
theme: TypewriterTheme;
fontSize: number;
isRTL: boolean;
showCursor: boolean;
typeSpeed: number;
lineIndex: number;
}
const TypewriterLine: React.FC<TypewriterLineProps> = ({
line,
frame,
fps,
theme,
fontSize,
isRTL,
showCursor,
typeSpeed,
lineIndex,
}) => {
const lineStartFrame = line.lineStart * fps;
const lineEndFrame = line.lineEnd * fps;
// Extended visibility for slower decay
if (frame < lineStartFrame || frame > lineEndFrame + 45) return null;
const themeConfig = themes[theme];
// Build the full line text with timing
const fullText = line.words.map(w => w.word).join(' ');
const lineDuration = line.lineEnd - line.lineStart;
const framesPerChar = fps / typeSpeed;
// Calculate how many characters to show
const localFrame = frame - lineStartFrame;
const charsToShow = Math.floor(localFrame / framesPerChar);
const visibleText = fullText.slice(0, Math.min(charsToShow, fullText.length));
const isTyping = charsToShow < fullText.length;
// Current word highlighting
let currentWordIndex = -1;
let charCount = 0;
for (let i = 0; i < line.words.length; i++) {
const word = line.words[i];
if (frame >= word.start * fps && frame <= word.end * fps) {
currentWordIndex = i;
break;
}
charCount += word.word.length + 1; // +1 for space
}
// Exit animation - slower decay
const exitStart = lineEndFrame;
const exitProgress = frame > exitStart
? interpolate(frame, [exitStart, exitStart + 40], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
: 0;
const exitOpacity = 1 - exitProgress;
// Cursor blink
const cursorVisible = showCursor && isTyping && Math.floor(frame / 8) % 2 === 0;
// Render text with word highlighting
const renderText = () => {
let charIdx = 0;
return line.words.map((word, wordIdx) => {
const wordStart = charIdx;
const wordEnd = charIdx + word.word.length;
charIdx = wordEnd + 1; // +1 for space
// Only show if within visible range
if (wordStart >= visibleText.length) return null;
const visibleWord = word.word.slice(0, Math.max(0, visibleText.length - wordStart));
if (!visibleWord) return null;
const isCurrentWord = wordIdx === currentWordIndex;
return (
<span key={wordIdx}>
<span
style={{
color: isCurrentWord ? (theme === 'terminal' ? '#AAFFAA' : '#FFD700') : themeConfig.textColor,
fontWeight: isCurrentWord ? 700 : 500,
textShadow: isCurrentWord
? `0 0 10px ${theme === 'terminal' ? '#00FF00' : '#FFD700'}`
: 'none',
transition: 'all 0.1s ease',
}}
>
{visibleWord}
</span>
{wordEnd < visibleText.length && ' '}
</span>
);
});
};
return (
<div
style={{
opacity: exitOpacity,
padding: '20px 40px',
background: themeConfig.bgColor,
borderRadius: theme === 'vintage' ? 0 : 8,
border: theme === 'vintage' ? '2px solid #8B7355' : 'none',
boxShadow: theme === 'terminal'
? '0 0 20px rgba(0, 255, 0, 0.2), inset 0 0 60px rgba(0, 255, 0, 0.03)'
: '0 4px 30px rgba(0, 0, 0, 0.5)',
direction: isRTL ? 'rtl' : 'ltr',
textAlign: isRTL ? 'right' : 'left',
maxWidth: '90%',
margin: '0 auto',
}}
>
{/* Paper texture for vintage */}
{theme === 'vintage' && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: 0.1,
background: 'url("data:image/svg+xml,%3Csvg viewBox=\'0 0 200 200\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'noise\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.9\' numOctaves=\'4\' stitchTiles=\'stitch\'/%3E%3C/filter%3E%3Crect width=\'100%25\' height=\'100%25\' filter=\'url(%23noise)\'/%3E%3C/svg%3E")',
pointerEvents: 'none',
}}
/>
)}
<span
style={{
fontSize,
fontFamily: theme === 'terminal'
? '"Fira Code", "Source Code Pro", monospace'
: theme === 'vintage'
? '"American Typewriter", "Courier New", monospace'
: '"Courier Prime", "Courier New", monospace',
letterSpacing: theme === 'terminal' ? '0.05em' : '0.02em',
lineHeight: 1.4,
}}
>
{/* Terminal prompt */}
{theme === 'terminal' && (
<span style={{ color: '#00AA00', marginRight: 8 }}>{'>'}</span>
)}
{renderText()}
{/* Cursor */}
{cursorVisible && (
<span
style={{
display: 'inline-block',
width: theme === 'terminal' ? '0.6em' : '2px',
height: '1.1em',
background: themeConfig.cursorColor,
marginLeft: 2,
verticalAlign: 'text-bottom',
boxShadow: theme === 'terminal' ? '0 0 5px #00FF00' : 'none',
}}
/>
)}
</span>
</div>
);
};
export const LyricsOverlayTypewriter: React.FC<LyricsOverlayTypewriterProps> = ({
videoSrc,
lyrics,
fontSize = 48,
position = 'center',
theme = 'classic',
fontFamily,
isRTL,
useOffthreadVideo = false,
showCursor = true,
typeSpeed = 20,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const effectiveRTL = isRTL ?? (lyrics.lines.length > 0 && lyrics.lines[0].words.length > 0
? isRTLText(lyrics.lines[0].words[0].word)
: false);
const getPositionStyle = (): React.CSSProperties => {
switch (position) {
case 'top':
return { top: 80, left: 40, right: 40 };
case 'center':
return { top: '50%', left: 40, right: 40, transform: 'translateY(-50%)' };
case 'bottom':
default:
return { bottom: 80, left: 40, right: 40 };
}
};
const VideoComponent = useOffthreadVideo ? OffthreadVideo : Video;
return (
<AbsoluteFill>
<VideoComponent
src={videoSrc}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{/* Scanlines for terminal theme */}
{theme === 'terminal' && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.15) 2px, rgba(0,0,0,0.15) 4px)',
pointerEvents: 'none',
opacity: 0.5,
}}
/>
)}
{/* Lyrics container */}
<div
style={{
position: 'absolute',
...getPositionStyle(),
display: 'flex',
flexDirection: 'column',
gap: 20,
}}
>
{lyrics.lines.map((line, index) => (
<TypewriterLine
key={index}
line={line}
lineIndex={index}
frame={frame}
fps={fps}
theme={theme}
fontSize={fontSize}
isRTL={effectiveRTL}
showCursor={showCursor}
typeSpeed={typeSpeed}
/>
))}
</div>
</AbsoluteFill>
);
};
export { parseSRT, parseElevenLabsTranscript } from '../utils/lyricsParser';
import type { LyricsData, LyricLine, LyricWord } from '../compositions/LyricsOverlay';
// ElevenLabs transcript format
export interface ElevenLabsWord {
word: string;
start: number;
end: number;
speaker_id?: string;
}
export interface ElevenLabsTranscript {
text: string;
words: ElevenLabsWord[];
duration: number;
language?: string;
}
// SRT parsing types
interface SRTEntry {
index: number;
startTime: number;
endTime: number;
text: string;
}
/**
* Parse SRT timestamp to seconds
* Format: HH:MM:SS,mmm or HH:MM:SS.mmm
*/
function parseSRTTime(timeStr: string): number {
const parts = timeStr.replace(',', '.').split(':');
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parseFloat(parts[2]);
return hours * 3600 + minutes * 60 + seconds;
}
/**
* Parse SRT file content into LyricsData
* SRT format typically has line-level timing, not word-level
* This function creates approximate word timings by distributing time evenly
*/
export function parseSRT(srtContent: string): LyricsData {
const lines: LyricLine[] = [];
// Split by double newline to get entries
const entries = srtContent.trim().split(/\n\n+/);
for (const entry of entries) {
const entryLines = entry.trim().split('\n');
if (entryLines.length < 3) continue;
// Skip index line (first line is the entry number)
const timeLine = entryLines[1];
const textLines = entryLines.slice(2).join(' ');
// Parse time codes
const timeMatch = timeLine.match(/(\d{2}:\d{2}:\d{2}[,\.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,\.]\d{3})/);
if (!timeMatch) continue;
const startTime = parseSRTTime(timeMatch[1]);
const endTime = parseSRTTime(timeMatch[2]);
// Split text into words
const wordTexts = textLines.split(/\s+/).filter(w => w.length > 0);
if (wordTexts.length === 0) continue;
// Distribute time evenly across words
const totalDuration = endTime - startTime;
const wordDuration = totalDuration / wordTexts.length;
const words: LyricWord[] = wordTexts.map((word, idx) => ({
word,
start: startTime + idx * wordDuration,
end: startTime + (idx + 1) * wordDuration,
emphasis: 'normal' as const,
}));
lines.push({
lineStart: startTime,
lineEnd: endTime,
words,
});
}
const duration = lines.length > 0 ? lines[lines.length - 1].lineEnd : 0;
return { lines, duration };
}
/**
* Parse ElevenLabs JSON transcript into LyricsData
* ElevenLabs provides word-level timing which we can use directly
*/
export function parseElevenLabsTranscript(
transcript: ElevenLabsTranscript,
options: {
maxWordsPerLine?: number;
lineGapThreshold?: number; // seconds - if gap between words exceeds this, start new line
punctuationBreak?: boolean; // break lines on sentence-ending punctuation
} = {}
): LyricsData {
const {
maxWordsPerLine = 8,
lineGapThreshold = 0.8,
punctuationBreak = true,
} = options;
const lines: LyricLine[] = [];
let currentLine: LyricWord[] = [];
let lineStart = 0;
// Filter out whitespace-only words
const meaningfulWords = transcript.words.filter(w => w.word.trim().length > 0);
for (let i = 0; i < meaningfulWords.length; i++) {
const word = meaningfulWords[i];
const nextWord = meaningfulWords[i + 1];
const lyricWord: LyricWord = {
word: word.word.trim(),
start: word.start,
end: word.end,
emphasis: detectEmphasis(word.word),
};
if (currentLine.length === 0) {
lineStart = word.start;
}
currentLine.push(lyricWord);
// Decide whether to end the current line
let shouldEndLine = false;
// Max words reached
if (currentLine.length >= maxWordsPerLine) {
shouldEndLine = true;
}
// Gap before next word
if (nextWord && (nextWord.start - word.end) > lineGapThreshold) {
shouldEndLine = true;
}
// Punctuation break (sentence endings)
if (punctuationBreak && /[.!?]$/.test(word.word.trim())) {
shouldEndLine = true;
}
// Last word
if (!nextWord) {
shouldEndLine = true;
}
if (shouldEndLine && currentLine.length > 0) {
lines.push({
lineStart,
lineEnd: word.end,
words: [...currentLine],
});
currentLine = [];
}
}
return {
lines,
duration: transcript.duration,
};
}
/**
* Detect word emphasis based on punctuation and common patterns
*/
function detectEmphasis(word: string): 'hero' | 'strong' | 'normal' {
const trimmed = word.trim();
// Exclamation or all caps = hero
if (/!$/.test(trimmed) || (trimmed.length > 2 && trimmed === trimmed.toUpperCase())) {
return 'hero';
}
// Question marks, quotes = strong
if (/[?"]/.test(trimmed)) {
return 'strong';
}
return 'normal';
}
/**
* Convert LyricsData to SRT format string
*/
export function lyricsToSRT(lyrics: LyricsData): string {
const lines: string[] = [];
lyrics.lines.forEach((line, index) => {
const startTime = formatSRTTime(line.lineStart);
const endTime = formatSRTTime(line.lineEnd);
const text = line.words.map(w => w.word).join(' ');
lines.push(`${index + 1}`);
lines.push(`${startTime} --> ${endTime}`);
lines.push(text);
lines.push('');
});
return lines.join('\n');
}
/**
* Format seconds to SRT timestamp
*/
function formatSRTTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const ms = Math.round((seconds % 1) * 1000);
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')},${String(ms).padStart(3, '0')}`;
}
/**
* Merge multiple LyricsData objects (for multi-part songs)
*/
export function mergeLyrics(...parts: LyricsData[]): LyricsData {
const allLines: LyricLine[] = [];
let maxDuration = 0;
for (const part of parts) {
allLines.push(...part.lines);
maxDuration = Math.max(maxDuration, part.duration);
}
// Sort by start time
allLines.sort((a, b) => a.lineStart - b.lineStart);
return {
lines: allLines,
duration: maxDuration,
};
}
/**
* Shift all timing in lyrics by offset (positive = later, negative = earlier)
*/
export function shiftLyricsTiming(lyrics: LyricsData, offsetSeconds: number): LyricsData {
return {
lines: lyrics.lines.map(line => ({
lineStart: Math.max(0, line.lineStart + offsetSeconds),
lineEnd: Math.max(0, line.lineEnd + offsetSeconds),
words: line.words.map(word => ({
...word,
start: Math.max(0, word.start + offsetSeconds),
end: Math.max(0, word.end + offsetSeconds),
})),
})),
duration: Math.max(0, lyrics.duration + offsetSeconds),
};
}
/**
* Scale lyrics timing by factor (useful for tempo changes)
*/
export function scaleLyricsTiming(lyrics: LyricsData, factor: number): LyricsData {
return {
lines: lyrics.lines.map(line => ({
lineStart: line.lineStart * factor,
lineEnd: line.lineEnd * factor,
words: line.words.map(word => ({
...word,
start: word.start * factor,
end: word.end * factor,
})),
})),
duration: lyrics.duration * factor,
};
}
/**
* Apply emphasis to specific words based on patterns or word list
*/
export function applyEmphasis(
lyrics: LyricsData,
heroWords: string[] = [],
strongWords: string[] = []
): LyricsData {
const heroSet = new Set(heroWords.map(w => w.toLowerCase()));
const strongSet = new Set(strongWords.map(w => w.toLowerCase()));
return {
...lyrics,
lines: lyrics.lines.map(line => ({
...line,
words: line.words.map(word => {
const cleanWord = word.word.toLowerCase().replace(/[.,!?]/g, '');
let emphasis = word.emphasis || 'normal';
if (heroSet.has(cleanWord)) {
emphasis = 'hero';
} else if (strongSet.has(cleanWord)) {
emphasis = 'strong';
}
return { ...word, emphasis };
}),
})),
};
}
Related skills
FAQ
How many overlay styles are available?
Seven: karaoke, minimal, fade, neon, cinematic, bounce, and typewriter.
Where does word timing come from?
From the transcribe skill's ElevenLabs word-level transcript JSON, parsed by parseElevenLabsTranscript.