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; }; type BasicCaptionsLayerProps = Omit<BasicCaptionsProps, 'captions'> & { readonly callerStyle: React.CSSProperties | null; readonly captions: Caption[]; }; const defaultCombineTokensWithinMilliseconds = 2000; const maximumTextWidth = 800; 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, }, callerStyle: {type: 'hidden'}, ...Interactive.transformSchema, } as const satisfies InteractivitySchema; const BasicCaptionsContent: React.FC<{ readonly captionAreaWidth: number | null; readonly captions: Caption[]; readonly combineTokensWithinMilliseconds: number; }> = ({captionAreaWidth, 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 aria-label={page.text} aria-live="off" role="group" 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, maxWidth: Math.min( maximumTextWidth, captionAreaWidth ?? maximumTextWidth, ), overflow: 'hidden', padding: '14px 22px', textAlign: 'center', textWrap: 'balance', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, whiteSpace: 'pre-wrap', }} > {page.text.trim()} </div> ); }; const BasicCaptionsInner = forwardRef< HTMLDivElement, BasicCaptionsLayerProps & { readonly controls: SequenceControls | undefined; } >( ( { callerStyle, captions, combineTokensWithinMilliseconds = defaultCombineTokensWithinMilliseconds, controls, height, name, style, width, ...interactiveProps }, ref, ) => { const outlineRef = useRef<HTMLDivElement>(null); const { rotate: callerRotate, scale: callerScale, transform: callerTransform, transformBox: callerTransformBox, transformOrigin: callerTransformOrigin, transformStyle: callerTransformStyle, translate: callerTranslate, ...callerContentStyle } = callerStyle ?? {}; useImperativeHandle(ref, () => outlineRef.current as HTMLDivElement, []); return ( <Sequence layout="none" {...interactiveProps} controls={controls} name={name ?? '<BasicCaptions>'} outlineRef={outlineRef} > <div style={{ height: height ?? '100%', rotate: callerRotate, scale: callerScale, transform: callerTransform, transformBox: callerTransformBox, transformOrigin: callerTransformOrigin, transformStyle: callerTransformStyle, translate: callerTranslate, width: width ?? '100%', }} > <div ref={outlineRef} style={{ alignItems: 'center', display: 'flex', height: '100%', justifyContent: 'center', width: '100%', ...style, ...callerContentStyle, }} > <BasicCaptionsContent captionAreaWidth={width ?? null} captions={captions} combineTokensWithinMilliseconds={combineTokensWithinMilliseconds} /> </div> </div> </Sequence> ); }, ); const BasicCaptionsLayer = Interactive.withSchema({ Component: BasicCaptionsInner, componentName: '<BasicCaptions>', componentIdentity: null, schema: basicCaptionsSchema, supportsEffects: false, }) as React.FC<BasicCaptionsLayerProps>; export const BasicCaptions: React.FC<BasicCaptionsProps> = ({ captions, style, ...props }) => { if (captions) { return ( <BasicCaptionsLayer {...props} callerStyle={style ?? null} captions={captions} style={{translate: '0px 0px'}} /> ); } return ( <div style={{ alignItems: 'center', display: 'flex', height: 220, justifyContent: 'center', width: 900, }} > <BasicCaptionsLayer {...props} callerStyle={style ?? null} captions={[ { text: 'Simple captions,\nready for every video.', startMs: 0, endMs: 2200, timestampMs: 1100, confidence: null, pageBreakAfter: true, }, { text: 'No animation,\njust clear text.', startMs: 2200, endMs: 4400, timestampMs: 3300, confidence: null, pageBreakAfter: true, }, { text: 'Easy to read,\nand easy to customize.', startMs: 4400, endMs: 7000, timestampMs: 5700, confidence: null, }, ]} height={props.height ?? 220} width={props.width ?? 900} style={{translate: '0px 0px'}} /> </div> ); };