feat: show 3 words at a time in text overlay (TikTok style)

Groups word timings by chunks of 3. The active word is highlighted in
yellow/scaled; the 2 surrounding words show in white. Gives enough
reading time while staying in sync with the voice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-03-24 19:41:29 +01:00
1 parent 4072f8954f
commit 36fe05c04b
1 file changed
+46 -40
+46 -40
View File
@@ -120,47 +120,53 @@ export const ImageComposition = ({
{/* Background music */} {/* Background music */}
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />} {musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />}
{/* TikTok-style word-by-word text overlay (content section only) */} {/* TikTok-style 3-words-at-a-time overlay (content section only) */}
{frame < endingStart && ( {frame < endingStart && (
wordTimings && wordTimings.length > 0 ? ( wordTimings && wordTimings.length > 0 && activeWordIdx >= 0 ? (() => {
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}> // Group index: which chunk of 3 is currently active
<div const groupIdx = Math.floor(activeWordIdx / 3);
style={{ const groupStart = groupIdx * 3;
maxWidth: "88%", const groupWords = wordTimings.slice(groupStart, groupStart + 3);
textAlign: "center", return (
display: "flex", <AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}>
flexWrap: "wrap", <div
justifyContent: "center", style={{
gap: "0 14px", maxWidth: "88%",
alignItems: "baseline", textAlign: "center",
filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.7))", display: "flex",
}} flexWrap: "wrap",
> justifyContent: "center",
{wordTimings.map((w, i) => { gap: "0 18px",
const isActive = i === activeWordIdx; alignItems: "baseline",
if (!isActive) return null; filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.7))",
return ( }}
<span >
key={i} {groupWords.map((w, gi) => {
style={{ const isActive = groupStart + gi === activeWordIdx;
color: "#FFE600", return (
fontFamily: "'Arial Black', 'Impact', sans-serif", <span
fontSize: 80, key={groupStart + gi}
fontWeight: 900, style={{
lineHeight: 1.2, color: isActive ? "#FFE600" : "white",
WebkitTextStroke: "3px rgba(0,0,0,0.8)", fontFamily: "'Arial Black', 'Impact', sans-serif",
textShadow: "0 4px 20px rgba(0,0,0,0.9)", fontSize: 76,
display: "inline-block", fontWeight: 900,
textTransform: "uppercase", lineHeight: 1.2,
}} WebkitTextStroke: isActive ? "3px rgba(0,0,0,0.8)" : "2px rgba(0,0,0,0.7)",
> textShadow: "0 4px 20px rgba(0,0,0,0.9)",
{w.word} display: "inline-block",
</span> textTransform: "uppercase",
); transform: isActive ? "scale(1.08)" : "scale(1)",
})} }}
</div> >
</AbsoluteFill> {w.word}
) : overlayText ? ( </span>
);
})}
</div>
</AbsoluteFill>
);
})() : overlayText ? (
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}> <AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}>
<div <div
style={{ style={{