Rounded Captions
Based on the @remotion/rounded-text-box demo.

rounded-captions.tsximport type {Caption } from '@remotion/captions'; import {createTikTokStyleCaptions } from '@remotion/captions'; import {loadFont } from '@remotion/google-fonts/Figtree'; import {fitTextOnNLines ,measureText } from '@remotion/layout-utils'; import {createRoundedTextBox } from '@remotion/rounded-text-box'; importReact , {forwardRef ,useEffect ,useImperativeHandle ,useMemo ,useRef ,useState , } from 'react'; import {cancelRender ,Interactive ,Sequence ,useCurrentFrame ,useVideoConfig , typeInteractiveBaseProps , typeInteractiveTransformProps , typeInteractivitySchema , typeSequenceControls , typeSequenceProps , } from 'remotion'; typeRoundedCaptionsProps =InteractiveBaseProps &InteractiveTransformProps &Pick <SequenceProps , 'width' | 'height'> & { readonlycaptions :Caption []; readonlyplaybackRate : number | null; readonlycombineTokensWithinMilliseconds : number | null; }; constdefaultCombineTokensWithinMilliseconds = 2000; constdefaultWidth = 900; constdefaultHeight = 220; constfontWeight = '700'; constmaxFontSize = 64; constlineHeight = 1.5; consthorizontalPadding = 22; constborderRadius = 20; constroundedCaptionsSchema = { ...Interactive .baseSchema , ...Interactive .captionsSchema ,width : {type : 'number',min :horizontalPadding * 2 + 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 , } asconst satisfiesInteractivitySchema ; const {fontFamily ,waitUntilDone } =loadFont ('normal', {weights : [fontWeight ],subsets : ['latin'], }); constRoundedCaptionsContent :React .FC <{ readonlycaptions :Caption []; readonlyplaybackRate : number; readonlytrimBefore : number; readonlycombineTokensWithinMilliseconds : number; readonlyfontLoaded : boolean; readonlywidth : number; readonlyheight : number; }> = ({captions ,playbackRate ,trimBefore ,combineTokensWithinMilliseconds ,fontLoaded ,width ,height , }) => { constframe =useCurrentFrame (); const {fps } =useVideoConfig (); constpages =useMemo ( () =>createTikTokStyleCaptions ({captions ,combineTokensWithinMilliseconds , }).pages , [captions ,combineTokensWithinMilliseconds ], ); // The Sequence frame already includes trimBefore; only elapsed frames speed up. constcurrentTimeMs = ((trimBefore + (frame -trimBefore ) *playbackRate ) /fps ) * 1000; constpage =pages .find ( (candidate ) =>currentTimeMs >=candidate .startMs &¤tTimeMs <candidate .startMs +candidate .durationMs , ); constlayout =useMemo (() => { if (!fontLoaded || !page ?.text .trim ()) { return null; } constparagraphs =page .text .trim () .split (/\r?\n/) .map ((line ) =>line .trim ()) .filter (Boolean ); constmaxLines =Math .max (2,paragraphs .length ); constfitted =paragraphs .map ((text ) =>fitTextOnNLines ({text ,maxLines :paragraphs .length === 1 ?maxLines : 1,maxBoxWidth :Math .max (1,width -horizontalPadding * 2),fontFamily ,fontWeight ,maxFontSize :Math .min (maxFontSize ,height / (maxLines *lineHeight )),validateFontIsLoaded : true, }), ); if (fitted .some ((result ) =>result .lines .length === 0)) { return null; } constfontSize =Math .min (...fitted .map ((result ) =>result .fontSize )); constlines =fitted .flatMap ((result ) =>result .lines ); consttextMeasurements =lines .map ((text ) =>measureText ({text ,fontFamily ,fontSize ,fontWeight ,additionalStyles : {lineHeight },validateFontIsLoaded : true, }), ); return {fontSize ,lines , ...createRoundedTextBox ({textMeasurements ,textAlign : 'center',horizontalPadding ,borderRadius , }), }; }, [fontLoaded ,height ,page ,width ]); if (!layout ) { return null; } return ( <div style ={{position : 'relative',width :layout .boundingBox .width ,height :layout .boundingBox .height ,flexShrink : 0, }} > <svg viewBox ={layout .boundingBox .viewBox }style ={{position : 'absolute',left : 0,top : 0,width :layout .boundingBox .width ,height :layout .boundingBox .height ,overflow : 'visible', }} > <path fill ="#ffffff"d ={layout .d } /> </svg > <div style ={{position : 'relative'}}> {layout .lines .map ((line ,index ) => ( <div // eslint-disable-next-line react/no-array-index-keykey ={index }style ={{color : '#000000',fontFamily ,fontSize :layout .fontSize ,fontWeight ,lineHeight ,paddingInline :horizontalPadding ,textAlign : 'center',whiteSpace : 'pre', }} > {line } </div > ))} </div > </div > ); }; constRoundedCaptionsInner =forwardRef <HTMLDivElement ,RoundedCaptionsProps & { readonlycontrols :SequenceControls | undefined; } >( ( {captions ,combineTokensWithinMilliseconds ,controls ,height =defaultHeight ,name ,playbackRate ,style ,trimBefore ,width =defaultWidth , ...interactiveProps },ref , ) => { constoutlineRef =useRef <HTMLDivElement >(null); const [fontLoaded ,setFontLoaded ] =useState (false);useImperativeHandle (ref , () =>outlineRef .current asHTMLDivElement , []);useEffect (() => {waitUntilDone () .then (() =>setFontLoaded (true)) .catch ((error ) =>cancelRender (error )); }, []); return ( <Sequence layout ="none" {...interactiveProps }controls ={controls }name ={name ?? '<RoundedCaptions>'}trimBefore ={trimBefore }outlineRef ={outlineRef } > <div ref ={outlineRef }style ={{alignItems : 'center',display : 'flex',justifyContent : 'center',marginInline : 'auto',width ,height , ...style , }} > <RoundedCaptionsContent captions ={captions }combineTokensWithinMilliseconds ={combineTokensWithinMilliseconds ??defaultCombineTokensWithinMilliseconds }fontLoaded ={fontLoaded }playbackRate ={playbackRate ?? 1}trimBefore ={trimBefore ?? 0}width ={width }height ={height } /> </div > </Sequence > ); }, ); export constRoundedCaptions =Interactive .withSchema ({Component :RoundedCaptionsInner ,componentName : '<RoundedCaptions>',schema :roundedCaptionsSchema ,supportsEffects : false, }) asReact .FC <RoundedCaptionsProps >;