support additional lyric layers (#2211)

This commit is contained in:
jeffvli
2026-07-10 15:12:17 -07:00
parent 908e83a2f8
commit b4e530ea07
12 changed files with 743 additions and 346 deletions
@@ -19,15 +19,60 @@ interface KaraokeLyricLineProps extends ComponentPropsWithoutRef<'div'> {
agents?: LyricAgent[];
alignment: 'center' | 'left' | 'right';
cueLines: SyncedCueLine[];
extraOverlays?: Array<{
cueLines: null | SyncedCueLine[];
text?: null | string;
}>;
fontSize: number;
lineIndex: number;
pronunciationCueLines?: null | SyncedCueLine[];
pronunciationText?: null | string;
romajiCueLines?: (null | SyncedCueLine)[] | null;
romajiText?: null | string;
text?: string;
translatedText?: null | string;
translationCueLines?: null | SyncedCueLine[];
}
type WordSpanVariant = 'main' | 'romaji';
type WordSpanVariant = 'main' | 'overlay-generic' | 'overlay-pronunciation' | 'overlay-translation';
const getWordSpanClasses = (
variant: WordSpanVariant,
options: {
hasFurigana: boolean;
isBackground: boolean;
isRtl: boolean;
isZeroDuration: boolean;
},
) => {
const isOverlay = variant !== 'main';
return clsx(
styles.karaokeWord,
'karaoke-word',
isOverlay && 'karaoke-overlay-word',
variant === 'overlay-generic' && 'karaoke-overlay-generic',
variant === 'overlay-pronunciation' && 'karaoke-overlay-pronunciation',
variant === 'overlay-translation' && 'karaoke-overlay-translation',
options.isRtl && 'karaoke-rtl',
options.isBackground && 'karaoke-bg-vocal',
options.isZeroDuration && 'karaoke-zero-dur',
options.hasFurigana && 'karaoke-furigana-word',
);
};
const getWordSpanIdPrefix = (variant: WordSpanVariant): string => {
switch (variant) {
case 'overlay-generic':
return 'karaoke-overlay-generic';
case 'overlay-pronunciation':
return 'karaoke-overlay-pronunciation';
case 'overlay-translation':
return 'karaoke-overlay-translation';
default:
return 'karaoke';
}
};
const renderWordSpans = (
cueLine: SyncedCueLine,
@@ -36,13 +81,18 @@ const renderWordSpans = (
isBackground: boolean,
variant: WordSpanVariant = 'main',
) => {
const isRomaji = variant === 'romaji';
const idPrefix = isRomaji ? 'karaoke-romaji' : 'karaoke';
const isOverlay = variant !== 'main';
const idPrefix = getWordSpanIdPrefix(variant);
if (!cueLine.words.length) {
return (
<span
className={clsx(styles.karaokeWord, isRomaji && 'karaoke-romaji-word')}
className={getWordSpanClasses(variant, {
hasFurigana: false,
isBackground,
isRtl: false,
isZeroDuration: false,
})}
dangerouslySetInnerHTML={{ __html: sanitize(cueLine.value) }}
data-lyric-time={cueLine.startMs}
/>
@@ -50,7 +100,7 @@ const renderWordSpans = (
}
const splitWords =
isRomaji || cueLine.words.some((word) => hasFuriganaHtml(word.text))
isOverlay || cueLine.words.some((word) => hasFuriganaHtml(word.text))
? cueLine.words
: splitWordCues(cueLine.words);
let wordCounter = 0;
@@ -61,20 +111,17 @@ const renderWordSpans = (
const timeSec = word.startMs / 1000;
const isRtl = testRtl(word.text);
const isZeroDuration = durationMs <= 0;
const hasFurigana = !isRomaji && hasFuriganaHtml(word.text);
const hasFurigana = !isOverlay && hasFuriganaHtml(word.text);
const sanitizedHtml = sanitize(word.text);
const currentWordIndex = wordCounter;
wordCounter += 1;
const wordClassName = clsx(
styles.karaokeWord,
'karaoke-word',
isRomaji && 'karaoke-romaji-word',
isRtl && 'karaoke-rtl',
isBackground && 'karaoke-bg-vocal',
isZeroDuration && 'karaoke-zero-dur',
hasFurigana && 'karaoke-furigana-word',
);
const wordClassName = getWordSpanClasses(variant, {
hasFurigana,
isBackground,
isRtl,
isZeroDuration,
});
const wordKey = `${word.startMs}-${currentWordIndex}`;
const wordProps = {
@@ -106,17 +153,36 @@ const renderWordSpans = (
});
};
const renderOverlayCueLineRow = (
cueLine: SyncedCueLine,
lineIndex: number,
cueLineIndex: number,
variant: 'overlay-generic' | 'overlay-pronunciation' | 'overlay-translation',
lineClassName: string,
) => (
<span className={lineClassName}>
{renderWordSpans(cueLine, lineIndex, cueLineIndex, false, variant)}
</span>
);
const hasSyncedOverlayCueLines = (cueLines: null | SyncedCueLine[] | undefined): boolean =>
!!cueLines?.some((cueLine) => cueLine.words.length > 0);
export const KaraokeLyricLine = memo(
({
agents,
alignment,
className,
cueLines,
extraOverlays,
fontSize,
lineIndex,
pronunciationCueLines,
pronunciationText,
romajiCueLines,
romajiText,
translatedText,
translationCueLines,
...props
}: KaraokeLyricLineProps) => {
const style = useMemo(
@@ -128,6 +194,10 @@ export const KaraokeLyricLine = memo(
);
const hasSyncedRomaji = romajiCueLines != null;
const hasSyncedPronunciation = hasSyncedOverlayCueLines(pronunciationCueLines);
const hasSyncedTranslation = hasSyncedOverlayCueLines(translationCueLines);
const pronunciationFallbackText =
!hasSyncedRomaji && !hasSyncedPronunciation ? (pronunciationText ?? romajiText) : null;
return (
<Box
@@ -140,6 +210,8 @@ export const KaraokeLyricLine = memo(
const agent = agents?.find((entry) => entry.id === cueLine.agentId);
const isBackground = agent?.role === 'bg' || agent?.role === 'group';
const romajiCueLine = romajiCueLines?.[cueLineIndex];
const pronunciationCueLine = pronunciationCueLines?.[cueLineIndex];
const translationCueLine = translationCueLines?.[cueLineIndex];
return (
<div
@@ -164,28 +236,82 @@ export const KaraokeLyricLine = memo(
)}
</span>
{romajiCueLine && (
<span className={styles.romajiLine}>
<span className={styles.overlayLine}>
{renderWordSpans(
romajiCueLine,
lineIndex,
cueLineIndex,
false,
'romaji',
'overlay-pronunciation',
)}
</span>
)}
{!romajiCueLine &&
pronunciationCueLine &&
renderOverlayCueLineRow(
pronunciationCueLine,
lineIndex,
cueLineIndex,
'overlay-pronunciation',
styles.overlayLine,
)}
{translationCueLine &&
renderOverlayCueLineRow(
translationCueLine,
lineIndex,
cueLineIndex,
'overlay-translation',
styles.translationLine,
)}
</div>
);
})}
{!hasSyncedRomaji && romajiText && (
{pronunciationFallbackText && (
<span
className={styles.romajiLine}
dangerouslySetInnerHTML={{ __html: sanitize(romajiText) }}
className={styles.overlayLine}
dangerouslySetInnerHTML={{
__html: sanitize(pronunciationFallbackText),
}}
/>
)}
{translatedText && (
<span dangerouslySetInnerHTML={{ __html: sanitize(translatedText) }} />
{!hasSyncedTranslation && translatedText && (
<span
className={styles.translationLine}
dangerouslySetInnerHTML={{ __html: sanitize(translatedText) }}
/>
)}
{extraOverlays?.map((overlay, overlayIndex) => {
const hasSyncedExtra = hasSyncedOverlayCueLines(overlay.cueLines);
if (hasSyncedExtra && overlay.cueLines) {
return overlay.cueLines.map((cueLine, cueLineIndex) => (
<span
className={styles.overlayLine}
key={`extra-overlay-${overlayIndex}-cue-${cueLineIndex}`}
>
{renderWordSpans(
cueLine,
lineIndex,
cueLineIndex,
false,
'overlay-generic',
)}
</span>
));
}
if (overlay.text) {
return (
<span
className={styles.overlayLine}
dangerouslySetInnerHTML={{ __html: sanitize(overlay.text) }}
key={`extra-overlay-${overlayIndex}`}
/>
);
}
return null;
})}
</Stack>
</Box>
);