Spaces:
Sleeping
Sleeping
import {useEffect, useRef, useState} from 'react'; | |
import robotoFontFamilyJson from '../assets/RobotoMono-Regular-msdf.json?url'; | |
import robotoFontTexture from '../assets/RobotoMono-Regular.png'; | |
import ThreeMeshUIText, {ThreeMeshUITextType} from './ThreeMeshUIText'; | |
import supportedCharSet from './supportedCharSet'; | |
const NUM_LINES = 3; | |
export const CHARS_PER_LINE = 37; | |
const MAX_WIDTH = 0.89; | |
const CHAR_WIDTH = 0.0235; | |
const Y_COORD_START = -0.38; | |
const Z_COORD = -1.3; | |
const LINE_HEIGHT = 0.062; | |
const BLOCK_SPACING = 0.02; | |
const FONT_SIZE = 0.038; | |
const SCROLL_Y_DELTA = 0.001; | |
// Overlay an extra block for padding due to inflexibilities of native padding | |
const OFFSET = 0.01; | |
const OFFSET_WIDTH = OFFSET * 3; | |
const CHARS_PER_SECOND = 10; | |
// The tick interval | |
const RENDER_INTERVAL = 300; | |
const CURSOR_BLINK_INTERVAL_MS = 1000; | |
type TextBlockProps = { | |
content: string; | |
// The actual position or end position when animating | |
y: number; | |
// The start position when animating | |
startY: number; | |
textOpacity: number; | |
backgroundOpacity: number; | |
index: number; | |
isBottomLine: boolean; | |
// key: number; | |
}; | |
type TranscriptState = { | |
textBlocksProps: TextBlockProps[]; | |
lastTranslationStringIndex: number; | |
lastTranslationLineStartIndex: number; | |
transcriptLines: string[]; | |
lastRenderTime: number; | |
}; | |
function TextBlock({ | |
content, | |
y, | |
startY, | |
textOpacity, | |
backgroundOpacity, | |
index, | |
isBottomLine, | |
}: TextBlockProps) { | |
const [scrollY, setScrollY] = useState<number>(y); | |
// We are reusing text blocks so this keeps track of when we changed rows so we can restart animation | |
const lastIndex = useRef<number>(index); | |
useEffect(() => { | |
if (index != lastIndex.current) { | |
lastIndex.current = index; | |
!isBottomLine && setScrollY(startY); | |
} else if (scrollY < y) { | |
setScrollY((prev) => prev + SCROLL_Y_DELTA); | |
} | |
}, [isBottomLine, index, scrollY, setScrollY, startY, y]); | |
const [cursorBlinkOn, setCursorBlinkOn] = useState(false); | |
useEffect(() => { | |
if (isBottomLine) { | |
const interval = setInterval(() => { | |
setCursorBlinkOn((prev) => !prev); | |
}, CURSOR_BLINK_INTERVAL_MS); | |
return () => clearInterval(interval); | |
} else { | |
setCursorBlinkOn(false); | |
} | |
}, [isBottomLine]); | |
const numChars = content.length; | |
if (cursorBlinkOn) { | |
content = content + '|'; | |
} | |
// Accounting for potential cursor for block width (the +1) | |
const width = | |
(numChars + (isBottomLine ? 1.1 : 0) + (numChars < 10 ? 1 : 0)) * | |
CHAR_WIDTH; | |
const height = LINE_HEIGHT; | |
// This is needed to update text content (doesn't work if we just update the content prop) | |
const textRef = useRef<ThreeMeshUITextType>(); | |
useEffect(() => { | |
if (textRef.current != null) { | |
textRef.current.set({content}); | |
} | |
}, [content, textRef, y, startY]); | |
// Width starts from 0 and goes 1/2 in each direction | |
const xPosition = width / 2 - MAX_WIDTH / 2 + OFFSET_WIDTH; | |
return ( | |
<> | |
<block | |
args={[ | |
{ | |
backgroundOpacity, | |
width: width + OFFSET_WIDTH, | |
height: height, | |
borderRadius: 0, | |
}, | |
]} | |
position={[-OFFSET_WIDTH + xPosition, scrollY, Z_COORD]}></block> | |
<block | |
args={[{padding: 0, backgroundOpacity: 0, width, height}]} | |
position={[xPosition, scrollY + OFFSET, Z_COORD]}> | |
<block | |
args={[ | |
{ | |
width, | |
height, | |
fontSize: FONT_SIZE, | |
textAlign: 'left', | |
backgroundOpacity: 0, | |
// TODO: support more language charsets | |
// This renders using MSDF format supported in WebGL. Renderable characters are defined in the "charset" json | |
// Currently supports most default keyboard inputs but this would exclude many non latin charset based languages. | |
// You can use https://msdf-bmfont.donmccurdy.com/ for easily generating these files | |
// fontFamily: '/src/assets/Roboto-msdf.json', | |
// fontTexture: '/src/assets/Roboto-msdf.png' | |
fontFamily: robotoFontFamilyJson, | |
fontTexture: robotoFontTexture, | |
}, | |
]}> | |
<ThreeMeshUIText ref={textRef} content="" fontOpacity={textOpacity} /> | |
</block> | |
</block> | |
</> | |
); | |
} | |
function initialTextBlockProps(count: number): TextBlockProps[] { | |
return Array.from({length: count}).map(() => { | |
// Push in non display blocks because mesh UI crashes if elements are add / removed from screen. | |
return { | |
y: Y_COORD_START, | |
startY: 0, | |
index: 0, | |
textOpacity: 0, | |
backgroundOpacity: 0, | |
width: MAX_WIDTH, | |
height: LINE_HEIGHT, | |
content: '', | |
isBottomLine: true, | |
}; | |
}); | |
} | |
export default function TextBlocks({ | |
translationText, | |
}: { | |
translationText: string; | |
}) { | |
const transcriptStateRef = useRef<TranscriptState>({ | |
textBlocksProps: initialTextBlockProps(NUM_LINES), | |
lastTranslationStringIndex: 0, | |
lastTranslationLineStartIndex: 0, | |
transcriptLines: [], | |
lastRenderTime: new Date().getTime(), | |
}); | |
const transcriptState = transcriptStateRef.current; | |
const {textBlocksProps, lastTranslationStringIndex, lastRenderTime} = | |
transcriptState; | |
const [charsToRender, setCharsToRender] = useState<number>(0); | |
useEffect(() => { | |
const interval = setInterval(() => { | |
const currentTime = new Date().getTime(); | |
const charsToRender = Math.round( | |
((currentTime - lastRenderTime) * CHARS_PER_SECOND) / 1000, | |
); | |
setCharsToRender(charsToRender); | |
}, RENDER_INTERVAL); | |
return () => clearInterval(interval); | |
}, [lastRenderTime]); | |
const currentTime = new Date().getTime(); | |
if (charsToRender < 1) { | |
return textBlocksProps.map((props, idx) => ( | |
<TextBlock {...props} key={idx} /> | |
)); | |
} | |
const nextTranslationStringIndex = Math.min( | |
lastTranslationStringIndex + charsToRender, | |
translationText.length, | |
); | |
const newString = translationText.substring( | |
lastTranslationStringIndex, | |
nextTranslationStringIndex, | |
); | |
if (nextTranslationStringIndex === lastTranslationStringIndex) { | |
transcriptState.lastRenderTime = currentTime; | |
return textBlocksProps.map((props, idx) => ( | |
<TextBlock {...props} key={idx} /> | |
)); | |
} | |
// Wait until more characters are accumulated if its just blankspace | |
if (/^\s*$/.test(newString)) { | |
transcriptState.lastRenderTime = currentTime; | |
return textBlocksProps.map((props, idx) => ( | |
<TextBlock {...props} key={idx} /> | |
)); | |
} | |
// Ideally we continue where we left off but this is complicated when we have mid-words. Recalculating for now | |
const runAll = true; | |
const newSentences = runAll | |
? translationText.substring(0, nextTranslationStringIndex).split('\n') | |
: newString.split('\n'); | |
const transcriptLines = runAll ? [''] : transcriptState.transcriptLines; | |
newSentences.forEach((newSentence, sentenceIdx) => { | |
const words = newSentence.split(/\s+/); | |
words.forEach((word) => { | |
const filteredWord = [...word] | |
.filter((c) => { | |
if (supportedCharSet().has(c)) { | |
return true; | |
} | |
console.error( | |
`Unsupported char ${c} - make sure this is supported in the font family msdf file`, | |
); | |
return false; | |
}) | |
.join(''); | |
const lastLineSoFar = transcriptLines[0]; | |
const charCount = lastLineSoFar.length + filteredWord.length + 1; | |
if (charCount <= CHARS_PER_LINE) { | |
transcriptLines[0] = lastLineSoFar + ' ' + filteredWord; | |
} else { | |
transcriptLines.unshift(filteredWord); | |
} | |
}); | |
if (sentenceIdx < newSentences.length - 1) { | |
transcriptLines.unshift('\n'); | |
transcriptLines.unshift(''); | |
} | |
}); | |
transcriptState.transcriptLines = transcriptLines; | |
transcriptState.lastTranslationStringIndex = nextTranslationStringIndex; | |
const newTextBlocksProps: TextBlockProps[] = []; | |
let currentY = Y_COORD_START; | |
transcriptLines.forEach((line, i) => { | |
if (newTextBlocksProps.length == NUM_LINES) { | |
return; | |
} | |
// const line = transcriptLines[i]; | |
if (line === '\n') { | |
currentY += BLOCK_SPACING; | |
return; | |
} | |
const y = currentY + LINE_HEIGHT / 2; | |
const isBottomLine = newTextBlocksProps.length === 0; | |
const textOpacity = 1 - 0.1 * newTextBlocksProps.length; | |
newTextBlocksProps.push({ | |
y, | |
startY: currentY, | |
index: i, | |
textOpacity, | |
backgroundOpacity: 0.98, | |
content: line, | |
isBottomLine, | |
}); | |
currentY = y + LINE_HEIGHT / 2; | |
}); | |
const numRemainingBlocks = NUM_LINES - newTextBlocksProps.length; | |
if (numRemainingBlocks > 0) { | |
newTextBlocksProps.push(...initialTextBlockProps(numRemainingBlocks)); | |
} | |
transcriptState.textBlocksProps = newTextBlocksProps; | |
transcriptState.lastRenderTime = currentTime; | |
return newTextBlocksProps.map((props, idx) => ( | |
<TextBlock {...props} key={idx} /> | |
)); | |
} | |