Skip to main content

Basic Captions

basic-captions.tsx
import type {Caption} from '@remotion/captions'; import {createTikTokStyleCaptions} from '@remotion/captions'; import React, {forwardRef, useImperativeHandle, useMemo, useRef} from 'react'; import { Interactive, Sequence, useCurrentFrame, useVideoConfig, type InteractiveBaseProps, type InteractiveTransformProps, type InteractivitySchema, type SequenceControls, type SequenceProps, } from 'remotion'; type BasicCaptionsProps = InteractiveBaseProps & InteractiveTransformProps & Pick<SequenceProps, 'width' | 'height'> & { readonly captions: Caption[]; readonly combineTokensWithinMilliseconds?: number; }; const defaultCombineTokensWithinMilliseconds = 2000; const defaultWidth = 900; const defaultHeight = 220; const basicCaptionsSchema = { ...Interactive.baseSchema, ...Interactive.captionsSchema, width: { type: 'number', min: 1, step: 1, default: undefined, description: 'Caption area width', hiddenFromList: false, }, height: { type: 'number', min: 1, step: 1, default: undefined, description: 'Caption area height', hiddenFromList: false, }, combineTokensWithinMilliseconds: { type: 'number', min: 0, step: 50, default: defaultCombineTokensWithinMilliseconds, description: 'Time between caption pages', hiddenFromList: false, }, ...Interactive.transformSchema, } as const satisfies InteractivitySchema; const BasicCaptionsContent: React.FC<{ readonly captions: Caption[]; readonly combineTokensWithinMilliseconds: number; }> = ({captions, combineTokensWithinMilliseconds}) => { const frame = useCurrentFrame(); const {fps} = useVideoConfig(); const pages = useMemo( () => createTikTokStyleCaptions({ captions, combineTokensWithinMilliseconds, }).pages, [captions, combineTokensWithinMilliseconds], ); const currentTimeMs = (frame / fps) * 1000; const page = pages.find( (candidate) => currentTimeMs >= candidate.startMs && currentTimeMs < candidate.startMs + candidate.durationMs, ); if (!page) { return null; } return ( <div style={{ backgroundColor: 'rgba(64, 64, 64, 0.75)', color: '#ffffff', display: '-webkit-box', fontFamily: 'Arial, Helvetica, sans-serif', fontSize: 64, fontWeight: 400, lineHeight: 1.2, overflow: 'hidden', padding: '14px 22px', textAlign: 'center', textWrap: 'balance', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, whiteSpace: 'pre-wrap', }} > {page.text.trim()} </div> ); }; const BasicCaptionsInner = forwardRef< HTMLDivElement, BasicCaptionsProps & { readonly controls: SequenceControls | undefined; } >( ( { captions, combineTokensWithinMilliseconds = defaultCombineTokensWithinMilliseconds, controls, height = defaultHeight, name, style, width = defaultWidth, ...interactiveProps }, ref, ) => { const outlineRef = useRef<HTMLDivElement>(null); useImperativeHandle(ref, () => outlineRef.current as HTMLDivElement, []); return ( <Sequence layout="none" {...interactiveProps} controls={controls} name={name ?? '<BasicCaptions>'} outlineRef={outlineRef} > <div ref={outlineRef} style={{ alignItems: 'center', display: 'flex', justifyContent: 'center', marginInline: 'auto', width, height, ...style, }} > <BasicCaptionsContent captions={captions} combineTokensWithinMilliseconds={combineTokensWithinMilliseconds} /> </div> </Sequence> ); }, ); const BasicCaptionsLayer = Interactive.withSchema({ Component: BasicCaptionsInner, componentName: '<BasicCaptions>', schema: basicCaptionsSchema, supportsEffects: false, }) as React.FC<BasicCaptionsProps>; export const BasicCaptions = BasicCaptionsLayer;