From 2864207006a005516fb27db327ff9d4d9f9ba9f3 Mon Sep 17 00:00:00 2001 From: Sakurao Yoshitatsu Date: Sat, 15 Aug 2026 22:12:36 +0900 Subject: [PATCH] feat(remotion-composer): CJK-aware captions and caption styling props CaptionOverlay assumes space-delimited text: it hardcodes a " " between words and lets lines wrap anywhere inside a word. For CJK captions this produces spurious inter-word spaces (Japanese does not use them) and mid-word line breaks, including punctuation stranded at line starts. Changes: - CaptionOverlay: new wordSeparator prop (default " " keeps current behavior; CJK callers pass ""). Each word renders as an unbreakable inline block so lines wrap only at word boundaries - visually unchanged for space-delimited text, fixes mid-word breaks for CJK. - WordCaption: optional pageBreakAfter flag; buildPages flushes the page early when set, so pages can align with sentence/scene boundaries instead of splitting a clause across pages. - TalkingHead: exposes captionColor / captionBackgroundColor (previously hardcoded inline), captionFontFamily, and captionWordSeparator, all defaulting to the current values. Tested by rendering TalkingHead stills with Japanese captions before and after (screenshots in PR), and with default props to confirm the space-delimited rendering is unchanged. Used in production for Japanese vertical ad videos. --- remotion-composer/src/TalkingHead.tsx | 15 ++++++++-- .../src/components/CaptionOverlay.tsx | 30 +++++++++++++++---- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/remotion-composer/src/TalkingHead.tsx b/remotion-composer/src/TalkingHead.tsx index 05156e2a..c6077e6d 100644 --- a/remotion-composer/src/TalkingHead.tsx +++ b/remotion-composer/src/TalkingHead.tsx @@ -302,6 +302,11 @@ export interface TalkingHeadProps { wordsPerPage?: number; fontSize?: number; highlightColor?: string; + captionColor?: string; + captionBackgroundColor?: string; + captionFontFamily?: string; + // Pass "" for CJK captions (no inter-word spacing); defaults to " ". + captionWordSeparator?: string; } export const TalkingHead: React.FC = ({ @@ -311,6 +316,10 @@ export const TalkingHead: React.FC = ({ wordsPerPage = 4, fontSize = 52, highlightColor = "#22D3EE", + captionColor = "#FFFFFF", + captionBackgroundColor = "rgba(0, 0, 0, 0.65)", + captionFontFamily, + captionWordSeparator, }) => { const { fps } = useVideoConfig(); @@ -345,8 +354,10 @@ export const TalkingHead: React.FC = ({ wordsPerPage={wordsPerPage} fontSize={fontSize} highlightColor={highlightColor} - backgroundColor="rgba(0, 0, 0, 0.65)" - color="#FFFFFF" + backgroundColor={captionBackgroundColor} + color={captionColor} + {...(captionFontFamily ? { fontFamily: captionFontFamily } : {})} + {...(captionWordSeparator !== undefined ? { wordSeparator: captionWordSeparator } : {})} /> ); diff --git a/remotion-composer/src/components/CaptionOverlay.tsx b/remotion-composer/src/components/CaptionOverlay.tsx index 1f9cf207..0142d682 100644 --- a/remotion-composer/src/components/CaptionOverlay.tsx +++ b/remotion-composer/src/components/CaptionOverlay.tsx @@ -12,6 +12,9 @@ export interface WordCaption { word: string; startMs: number; endMs: number; + // Force a page break after this word (e.g. sentence or scene boundaries). + // Useful for CJK captions where pages should align with clause boundaries. + pageBreakAfter?: boolean; } type CaptionOverlayProps = { @@ -23,6 +26,9 @@ type CaptionOverlayProps = { highlightColor?: string; backgroundColor?: string; fontFamily?: string; + // Separator rendered between words. Space-delimited languages want the + // default " "; CJK languages (no inter-word spacing) should pass "". + wordSeparator?: string; }; interface CaptionPage { @@ -33,15 +39,21 @@ interface CaptionPage { function buildPages(words: WordCaption[], wordsPerPage: number): CaptionPage[] { const pages: CaptionPage[] = []; - for (let i = 0; i < words.length; i += wordsPerPage) { - const pageWords = words.slice(i, i + wordsPerPage); - if (pageWords.length === 0) continue; + let pageWords: WordCaption[] = []; + const flush = () => { + if (pageWords.length === 0) return; pages.push({ words: pageWords, startMs: pageWords[0].startMs, endMs: pageWords[pageWords.length - 1].endMs, }); + pageWords = []; + }; + for (const w of words) { + pageWords.push(w); + if (pageWords.length >= wordsPerPage || w.pageBreakAfter) flush(); } + flush(); return pages; } @@ -52,7 +64,8 @@ const PageRenderer: React.FC<{ highlightColor: string; backgroundColor: string; fontFamily: string; -}> = ({ page, fontSize, color, highlightColor, backgroundColor, fontFamily }) => { + wordSeparator: string; +}> = ({ page, fontSize, color, highlightColor, backgroundColor, fontFamily, wordSeparator }) => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); @@ -100,6 +113,11 @@ const PageRenderer: React.FC<{ - {w.word}{i < page.words.length - 1 ? " " : ""} + {w.word}{i < page.words.length - 1 ? wordSeparator : ""} ); })} @@ -125,6 +143,7 @@ export const CaptionOverlay: React.FC = ({ highlightColor = "#22D3EE", backgroundColor = "rgba(15, 23, 42, 0.75)", fontFamily = "Space Grotesk, Inter, system-ui, sans-serif", + wordSeparator = " ", }) => { const { fps } = useVideoConfig(); const pages = buildPages(words, wordsPerPage); @@ -148,6 +167,7 @@ export const CaptionOverlay: React.FC = ({ highlightColor={highlightColor} backgroundColor={backgroundColor} fontFamily={fontFamily} + wordSeparator={wordSeparator} /> );