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.
This commit is contained in:
Sakurao Yoshitatsu
2026-08-15 22:12:36 +09:00
parent 95e1c3d0ab
commit 2864207006
2 changed files with 38 additions and 7 deletions

View File

@@ -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<TalkingHeadProps> = ({
@@ -311,6 +316,10 @@ export const TalkingHead: React.FC<TalkingHeadProps> = ({
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<TalkingHeadProps> = ({
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 } : {})}
/>
</AbsoluteFill>
);

View File

@@ -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<{
<span
key={`${w.startMs}-${i}`}
style={{
// Keep each word unbroken so lines wrap only at word
// boundaries. For space-delimited text this matches the
// previous behavior; for CJK it prevents mid-word breaks.
display: "inline-block",
whiteSpace: "nowrap",
color: isActive ? highlightColor : isPast ? color : `${color}99`,
transition: "none", // CSS transitions forbidden in Remotion
textShadow: isActive
@@ -107,7 +125,7 @@ const PageRenderer: React.FC<{
: "0 2px 4px rgba(0,0,0,0.5)",
}}
>
{w.word}{i < page.words.length - 1 ? " " : ""}
{w.word}{i < page.words.length - 1 ? wordSeparator : ""}
</span>
);
})}
@@ -125,6 +143,7 @@ export const CaptionOverlay: React.FC<CaptionOverlayProps> = ({
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<CaptionOverlayProps> = ({
highlightColor={highlightColor}
backgroundColor={backgroundColor}
fontFamily={fontFamily}
wordSeparator={wordSeparator}
/>
</Sequence>
);