← Catalog
No preview yet
Source
331 lines// Captions Only Block — full-frame video with TikTok-style karaoke
// captions overlay. Strips out the news-image panel from ai_news. Good
// for "talking head with captions" where there's no B-roll needed.
//
// Same caption-normalization logic as ai_news: accepts native shape,
// STT full-object, or word_timestamps flat array.
import {
AbsoluteFill,
Audio,
OffthreadVideo,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
import { loadFont as loadInter } from '@remotion/google-fonts/Inter';
import { loadFont as loadRoboto } from '@remotion/google-fonts/Roboto';
import { loadFont } from '@remotion/google-fonts/SpaceGrotesk';
const { fontFamily: SPACE_GROTESK } = loadFont();
const { fontFamily: INTER } = loadInter();
const { fontFamily: ROBOTO } = loadRoboto();
const FONT_MAP: Record<string, string> = {
SpaceGrotesk: SPACE_GROTESK,
Inter: INTER,
Roboto: ROBOTO,
};
type CaptionSegment = {
startFrame: number;
endFrame: number;
text: string;
words?: Array<{ word: string; startFrame: number; endFrame: number }>;
};
function normalizeCaptions(raw: unknown, fps: number): CaptionSegment[] {
if (!raw) return [];
if (typeof raw === 'string') {
try {
raw = JSON.parse(raw);
} catch {
return [];
}
}
if (!Array.isArray(raw)) {
const r = raw as { chunks?: unknown };
if (r && typeof r === 'object' && Array.isArray(r.chunks)) raw = [raw];
else return [];
}
const arr = raw as unknown[];
if (arr.length === 0) return [];
const first = arr[0] as Record<string, unknown>;
if (first && typeof first.startFrame === 'number')
return arr as CaptionSegment[];
if (
first &&
typeof first.word === 'string' &&
(typeof first.start === 'number' || typeof first.end === 'number')
) {
const words = arr as Array<{ word: string; start?: number; end?: number }>;
const out: CaptionSegment[] = [];
const wpc = 6;
for (let i = 0; i < words.length; i += wpc) {
const group = words.slice(i, i + wpc);
const startSec = group[0]?.start ?? 0;
const endSec = group[group.length - 1]?.end ?? startSec + 1;
out.push({
text: group.map((w) => w.word).join(' '),
startFrame: Math.round(startSec * fps),
endFrame: Math.round(endSec * fps),
words: group.map((w) => ({
word: String(w.word ?? ''),
startFrame: Math.round((w.start ?? 0) * fps),
endFrame: Math.round((w.end ?? 0) * fps),
})),
});
}
return out;
}
const items = arr as Array<{
text?: string;
chunks?: Array<{ text: string; timestamp: [number, number] }>;
}>;
const out: CaptionSegment[] = [];
for (const item of items) {
const full = String(item?.text ?? '').trim();
const chunks = Array.isArray(item?.chunks) ? item.chunks : [];
if (chunks.length === 0) continue;
const sentences = full.length
? full.split(/(?<=[.!?])\s+/).filter(Boolean)
: [full || chunks.map((c) => c.text).join(' ')];
let chunkIdx = 0;
for (const sentence of sentences) {
const wordCount = sentence
.replace(/[^A-Za-z0-9\s]/g, ' ')
.split(/\s+/)
.filter(Boolean).length;
const seg: typeof chunks = [];
while (chunkIdx < chunks.length && seg.length < Math.max(1, wordCount)) {
seg.push(chunks[chunkIdx]);
chunkIdx += 1;
}
if (seg.length === 0) continue;
const startSec = seg[0].timestamp?.[0] ?? 0;
const endSec = seg[seg.length - 1].timestamp?.[1] ?? startSec + 1;
out.push({
text: sentence,
startFrame: Math.round(startSec * fps),
endFrame: Math.round(endSec * fps),
words: seg.map((c) => ({
word: String(c.text ?? ''),
startFrame: Math.round((c.timestamp?.[0] ?? 0) * fps),
endFrame: Math.round((c.timestamp?.[1] ?? 0) * fps),
})),
});
}
}
return out;
}
type CaptionProps = {
captions: CaptionSegment[];
frame: number;
fps: number;
activeColor: string;
inactiveColor: string;
fontFamily: string;
fontSize: number;
topPercent: number;
};
function KaraokeCaptions({
captions,
frame,
fps,
activeColor,
inactiveColor,
fontFamily,
fontSize,
topPercent,
}: CaptionProps) {
const active = captions.find(
(c) => frame >= c.startFrame && frame < c.endFrame
);
if (!active) return null;
let allWords = active.words ?? [];
if (allWords.length === 0) {
const raw = (active.text ?? '').split(/\s+/).filter(Boolean);
const dur = Math.max(
1,
(active.endFrame - active.startFrame) / Math.max(1, raw.length)
);
allWords = raw.map((word, i) => ({
word,
startFrame: active.startFrame + Math.round(i * dur),
endFrame: active.startFrame + Math.round((i + 1) * dur),
}));
}
const CHUNK = 3;
const chunks: Array<typeof allWords> = [];
for (let i = 0; i < allWords.length; i += CHUNK) {
chunks.push(allWords.slice(i, i + CHUNK));
}
let activeIdx = allWords.findIndex(
(w) => frame >= w.startFrame && frame < w.endFrame
);
if (activeIdx < 0) {
for (let i = allWords.length - 1; i >= 0; i--) {
if (frame >= allWords[i].startFrame) {
activeIdx = i;
break;
}
}
}
if (activeIdx < 0) return null;
const chunk = chunks[Math.floor(activeIdx / CHUNK)];
if (!chunk) return null;
const chunkStart = chunk[0].startFrame;
const chunkEnd = chunk[chunk.length - 1].endFrame;
const localFrame = frame - chunkStart;
const chunkDuration = Math.max(1, chunkEnd - chunkStart);
const scale = spring({
frame: localFrame,
fps,
config: { damping: 14, stiffness: 220, mass: 0.4 },
});
const opacity = interpolate(
localFrame,
[0, 2, chunkDuration - 2, chunkDuration],
[0, 1, 1, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
);
return (
<div
style={{
position: 'absolute',
top: `${topPercent}%`,
left: 0,
right: 0,
display: 'flex',
justifyContent: 'center',
zIndex: 10,
pointerEvents: 'none',
}}
>
<div
style={{
transform: `scale(${scale})`,
opacity,
display: 'flex',
gap: '0 12px',
padding: '10px 28px',
}}
>
{chunk.map((wt, i) => {
const isCurrent = frame >= wt.startFrame && frame < wt.endFrame;
return (
<span
key={`${chunkStart}-${i}`}
style={{
color: isCurrent ? activeColor : inactiveColor,
fontSize,
fontWeight: 700,
fontFamily,
textTransform: 'uppercase',
letterSpacing: -0.5,
WebkitTextStroke: isCurrent ? '0px' : '1.5px rgba(0,0,0,0.6)',
paintOrder: 'stroke fill',
textShadow: isCurrent
? `0 0 20px ${activeColor}99, 0 0 40px ${activeColor}55, 0 2px 8px rgba(0,0,0,0.8)`
: '0 2px 8px rgba(0,0,0,0.9)',
whiteSpace: 'nowrap',
}}
>
{wt.word}
</span>
);
})}
</div>
</div>
);
}
export type CaptionsOnlyProps = {
video: string;
audioFile: string;
captions: unknown;
videoScale: number;
videoOffsetY: string;
captionActiveColor: string;
captionInactiveColor: string;
captionFont: 'SpaceGrotesk' | 'Inter' | 'Roboto';
captionFontSize: number;
captionTopPercent: number;
backgroundColor: string;
};
export default function CaptionsOnly({
video,
audioFile,
captions,
videoScale = 1.0,
videoOffsetY = '0%',
captionActiveColor = '#00F0FF',
captionInactiveColor = '#FFFFFF',
captionFont = 'SpaceGrotesk',
captionFontSize = 52,
captionTopPercent = 75,
backgroundColor = '#05060a',
}: Partial<CaptionsOnlyProps>) {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const normalized = normalizeCaptions(captions, fps);
const fontFamily = FONT_MAP[captionFont] ?? SPACE_GROTESK;
const hasVideo = typeof video === 'string' && /^https?:\/\//i.test(video);
const hasAudio =
typeof audioFile === 'string' && /^https?:\/\//i.test(audioFile);
return (
<AbsoluteFill style={{ backgroundColor }}>
{hasVideo ? (
<OffthreadVideo
src={video as string}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
transform: `scale(${videoScale}) translateY(${videoOffsetY})`,
transformOrigin: 'center center',
}}
/>
) : (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'rgba(255,255,255,0.35)',
fontFamily: SPACE_GROTESK,
fontSize: 32,
}}
>
Connect a video to start
</div>
)}
<KaraokeCaptions
captions={normalized}
frame={frame}
fps={fps}
activeColor={captionActiveColor}
inactiveColor={captionInactiveColor}
fontFamily={fontFamily}
fontSize={captionFontSize}
topPercent={captionTopPercent}
/>
{hasAudio ? <Audio src={audioFile as string} /> : null}
</AbsoluteFill>
);
}
Schema (Zod)
import { z } from 'zod';
export const Schema = z.object({
video: z.string().describe('$port:video Main video'),
audioFile: z.string().describe('$port:audio Voiceover audio'),
captions: z.any().describe('$port:json Captions'),
videoScale: z.number().default(1.0).describe('$style:scale Video zoom'),
videoOffsetY: z
.string()
.default('0%')
.describe('$style:offset Video vertical offset'),
captionActiveColor: z
.string()
.default('#00F0FF')
.describe('$style:color Active word color'),
captionInactiveColor: z
.string()
.default('#FFFFFF')
.describe('$style:color Inactive word color'),
captionFont: z
.enum(['SpaceGrotesk', 'Inter', 'Roboto'])
.default('SpaceGrotesk')
.describe('$style:font Caption font'),
captionFontSize: z
.number()
.default(52)
.describe('$style:size Caption font size'),
captionTopPercent: z
.number()
.default(75)
.describe('$style:position Caption vertical position (%)'),
backgroundColor: z
.string()
.default('#05060a')
.describe('$style:color Background color'),
});
Captions Only
captions_onlyFull-frame video with TikTok-style karaoke captions overlay. No news image panel — good for talking-head clips.
Workflow Inputs (3)
- Main video
videoVIDEORequired - Voiceover audio
audioFileAUDIORequired - Captions
captionsJSON
Config Fields (8)
videoScalecaptionFontvideoOffsetYbackgroundColorcaptionFontSizecaptionTopPercentcaptionActiveColorcaptionInactiveColor
Meta
- Updated
- 7/10/2026