|
|
@@ -7,6 +7,40 @@ interface SubtitleBarProps {
|
|
|
style?: React.CSSProperties;
|
|
|
}
|
|
|
|
|
|
+interface Segment {
|
|
|
+ words: WordTimestamp[];
|
|
|
+ startSeconds: number;
|
|
|
+ endSeconds: number;
|
|
|
+}
|
|
|
+
|
|
|
+function groupIntoSegments(wordTimestamps: WordTimestamp[]): Segment[] {
|
|
|
+ if (wordTimestamps.length === 0) return [];
|
|
|
+
|
|
|
+ const segmentEnds = new Set<number>();
|
|
|
+ for (let i = 0; i < wordTimestamps.length; i++) {
|
|
|
+ const w = wordTimestamps[i].word;
|
|
|
+ if (/[。!?;]/.test(w)) {
|
|
|
+ segmentEnds.add(i);
|
|
|
+ } else if (/[,、,;]/.test(w) && i - (segmentEnds.size > 0 ? [...segmentEnds].pop()! : -1) >= 8) {
|
|
|
+ segmentEnds.add(i);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ segmentEnds.add(wordTimestamps.length - 1);
|
|
|
+
|
|
|
+ const segments: Segment[] = [];
|
|
|
+ let segStart = 0;
|
|
|
+ for (const end of [...segmentEnds].sort((a, b) => a - b)) {
|
|
|
+ const words = wordTimestamps.slice(segStart, end + 1);
|
|
|
+ segments.push({
|
|
|
+ words,
|
|
|
+ startSeconds: words[0].startSeconds,
|
|
|
+ endSeconds: words[words.length - 1].endSeconds,
|
|
|
+ });
|
|
|
+ segStart = end + 1;
|
|
|
+ }
|
|
|
+ return segments;
|
|
|
+}
|
|
|
+
|
|
|
export const SubtitleBar: React.FC<SubtitleBarProps> = ({
|
|
|
wordTimestamps,
|
|
|
style,
|
|
|
@@ -15,48 +49,26 @@ export const SubtitleBar: React.FC<SubtitleBarProps> = ({
|
|
|
const { fps } = useVideoConfig();
|
|
|
const currentTime = frame / fps;
|
|
|
|
|
|
- // Find current word index
|
|
|
+ const segments = React.useMemo(() => groupIntoSegments(wordTimestamps), [wordTimestamps]);
|
|
|
+
|
|
|
+ const currentSegment = segments.find(
|
|
|
+ (s) => currentTime >= s.startSeconds && currentTime <= s.endSeconds
|
|
|
+ ) ?? segments[segments.length - 1];
|
|
|
+
|
|
|
+ if (!currentSegment) return null;
|
|
|
+
|
|
|
let currentWordIndex = -1;
|
|
|
- for (let i = 0; i < wordTimestamps.length; i++) {
|
|
|
+ for (let i = 0; i < currentSegment.words.length; i++) {
|
|
|
if (
|
|
|
- currentTime >= wordTimestamps[i].startSeconds &&
|
|
|
- currentTime <= wordTimestamps[i].endSeconds
|
|
|
+ currentTime >= currentSegment.words[i].startSeconds &&
|
|
|
+ currentTime <= currentSegment.words[i].endSeconds
|
|
|
) {
|
|
|
currentWordIndex = i;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Dynamic window size: fewer words when there's lots of text
|
|
|
- const totalWords = wordTimestamps.length;
|
|
|
- const windowSize =
|
|
|
- totalWords > 50 ? 8 : totalWords > 30 ? 12 : 16;
|
|
|
-
|
|
|
- const start = Math.max(0, currentWordIndex - Math.floor(windowSize / 2));
|
|
|
- const end = Math.min(wordTimestamps.length, start + windowSize);
|
|
|
- const visibleWords = wordTimestamps.slice(start, end).map((w, i) => ({
|
|
|
- text: w.word,
|
|
|
- globalIndex: start + i,
|
|
|
- }));
|
|
|
-
|
|
|
- const opacity = interpolate(frame, [0, 8], [0, 1], {
|
|
|
- extrapolateRight: "clamp",
|
|
|
- });
|
|
|
-
|
|
|
- // Continuous font scaling based on total word count
|
|
|
- const baseFontSize = 42;
|
|
|
- const minScale = 0.5;
|
|
|
- const scaleAt50 = 0.65;
|
|
|
- const fontSize =
|
|
|
- totalWords <= 15
|
|
|
- ? baseFontSize
|
|
|
- : totalWords <= 50
|
|
|
- ? baseFontSize * (1 - (1 - scaleAt50) * ((totalWords - 15) / 35))
|
|
|
- : baseFontSize * (minScale + (scaleAt50 - minScale) * Math.max(0, 1 - (totalWords - 50) / 100));
|
|
|
-
|
|
|
- // Dynamic max height: smaller when text is dense
|
|
|
- const maxHeight =
|
|
|
- totalWords > 50 ? 80 : totalWords > 30 ? 100 : 120;
|
|
|
+ const fadeIn = interpolate(frame, [0, 6], [0, 1], { extrapolateRight: "clamp" });
|
|
|
|
|
|
return (
|
|
|
<div
|
|
|
@@ -67,7 +79,7 @@ export const SubtitleBar: React.FC<SubtitleBarProps> = ({
|
|
|
right: 0,
|
|
|
display: "flex",
|
|
|
justifyContent: "center",
|
|
|
- opacity,
|
|
|
+ opacity: fadeIn,
|
|
|
padding: "0 5%",
|
|
|
...style,
|
|
|
}}
|
|
|
@@ -76,10 +88,8 @@ export const SubtitleBar: React.FC<SubtitleBarProps> = ({
|
|
|
style={{
|
|
|
backgroundColor: "rgba(0,0,0,0.8)",
|
|
|
borderRadius: 12,
|
|
|
- padding: "10px 24px",
|
|
|
- maxWidth: "100%",
|
|
|
- maxHeight,
|
|
|
- overflow: "hidden",
|
|
|
+ padding: "14px 28px",
|
|
|
+ maxWidth: "90%",
|
|
|
display: "flex",
|
|
|
alignItems: "center",
|
|
|
justifyContent: "center",
|
|
|
@@ -87,8 +97,8 @@ export const SubtitleBar: React.FC<SubtitleBarProps> = ({
|
|
|
>
|
|
|
<span
|
|
|
style={{
|
|
|
- fontSize,
|
|
|
- lineHeight: 1.3,
|
|
|
+ fontSize: 40,
|
|
|
+ lineHeight: 1.6,
|
|
|
fontFamily: "sans-serif",
|
|
|
display: "-webkit-box",
|
|
|
WebkitLineClamp: 2,
|
|
|
@@ -96,12 +106,12 @@ export const SubtitleBar: React.FC<SubtitleBarProps> = ({
|
|
|
overflow: "hidden",
|
|
|
}}
|
|
|
>
|
|
|
- {visibleWords.map((w, i) => {
|
|
|
- const isCurrent = w.globalIndex === currentWordIndex;
|
|
|
- const isPast = currentTime > wordTimestamps[w.globalIndex].endSeconds;
|
|
|
+ {currentSegment.words.map((w, i) => {
|
|
|
+ const isCurrent = i === currentWordIndex;
|
|
|
+ const isPast = currentTime > w.endSeconds;
|
|
|
return (
|
|
|
<span
|
|
|
- key={w.globalIndex}
|
|
|
+ key={i}
|
|
|
style={{
|
|
|
color: isCurrent
|
|
|
? "#fbbf24"
|
|
|
@@ -111,7 +121,7 @@ export const SubtitleBar: React.FC<SubtitleBarProps> = ({
|
|
|
fontWeight: isCurrent ? 700 : 400,
|
|
|
}}
|
|
|
>
|
|
|
- {w.text}
|
|
|
+ {w.word}
|
|
|
</span>
|
|
|
);
|
|
|
})}
|