diff --git a/internal/service/subtitle_convert.go b/internal/service/subtitle_convert.go index ce6661c..6565abf 100644 --- a/internal/service/subtitle_convert.go +++ b/internal/service/subtitle_convert.go @@ -27,6 +27,7 @@ func srtToVTT(body string) string { func assToVTT(body string) string { out := strings.Builder{} out.WriteString("WEBVTT\n\n") + seen := make(map[string]struct{}) for i, line := range strings.Split(body, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "Dialogue:") { @@ -36,11 +37,22 @@ func assToVTT(body string) string { if len(parts) < 10 { continue } + start := normaliseTimecode(parts[1]) + end := normaliseTimecode(parts[2]) + text := stripASSTags(parts[9]) + if text == "" { + continue + } + key := start + "\x00" + end + "\x00" + text + if _, duplicate := seen[key]; duplicate { + continue + } + seen[key] = struct{}{} fmt.Fprintf(&out, "%d\n%s --> %s\n%s\n\n", i, - normaliseTimecode(parts[1]), - normaliseTimecode(parts[2]), - stripASSTags(parts[9]), + start, + end, + text, ) } return out.String() @@ -86,5 +98,9 @@ func normaliseTimecode(t string) string { var assTag = regexp.MustCompile(`\{[^}]*\}`) func stripASSTags(s string) string { - return assTag.ReplaceAllString(s, "") + s = assTag.ReplaceAllString(s, "") + s = strings.ReplaceAll(s, `\N`, "\n") + s = strings.ReplaceAll(s, `\n`, "\n") + s = strings.ReplaceAll(s, `\h`, "\u00a0") + return strings.TrimSpace(s) } diff --git a/internal/service/subtitle_test.go b/internal/service/subtitle_test.go index ef663de..db8f465 100644 --- a/internal/service/subtitle_test.go +++ b/internal/service/subtitle_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/glebarez/sqlite" @@ -84,6 +85,25 @@ func TestNormaliseTimecode(t *testing.T) { } } +func TestAssToVTTDeduplicatesDialogueAndNormalisesLineBreaks(t *testing.T) { + body := strings.Join([]string{ + `Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,{\an2}第一行\N第二行`, + `Dialogue: 1,0:00:01.00,0:00:02.00,Copy,,0,0,0,,{\bord2}第一行\N第二行`, + `Dialogue: 0,0:00:03.00,0:00:04.00,Default,,0,0,0,,{\i1}`, + }, "\n") + + got := assToVTT(body) + if strings.Count(got, "第一行\n第二行") != 1 { + t.Fatalf("duplicate ASS dialogue was not collapsed:\n%s", got) + } + if strings.Contains(got, `\N`) || strings.Contains(got, `\an2`) { + t.Fatalf("ASS control sequences leaked into WebVTT:\n%s", got) + } + if strings.Contains(got, "00:00:03.000 --> 00:00:04.000") { + t.Fatalf("empty styled dialogue should be omitted:\n%s", got) + } +} + func TestSubtitleServeRawWritesSourceBytes(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) if err != nil { diff --git a/web/src/pages/PlayerVideoStage.tsx b/web/src/pages/PlayerVideoStage.tsx index 1ca393c..2a51d40 100644 --- a/web/src/pages/PlayerVideoStage.tsx +++ b/web/src/pages/PlayerVideoStage.tsx @@ -47,6 +47,14 @@ function parseWebVTTCues(body: string): SubtitleCue[] { return cues } +function uniqueSubtitleTexts(texts: string[]): string[] { + const unique = [...new Set(texts.map((text) => text.trim()).filter(Boolean))] + // ponytail: WebVTT cannot preserve ASS layers/positions. Keep at most two + // simultaneous blocks for bilingual subtitles; use libass if full ASS + // typesetting support is added later. + return unique.slice(-2) +} + type PlayerVideoStageProps = { media: Media | null /** 媒体元数据加载失败提示(非空时替代「加载中」展示)。 */ @@ -255,6 +263,7 @@ export function PlayerVideoStage({ const selectedTrack = subs[subtitleIndex] if ( !video || + !tracksArmed || subs.length === 0 || subtitleIndex < 0 || !selectedTrack || @@ -267,9 +276,11 @@ export function PlayerVideoStage({ const updateCue = () => { const absoluteTime = video.currentTime + (streamOffset ?? 0) if (subtitleTimeline?.path === selectedTrack.path) { - const texts = subtitleTimeline.cues - .filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime) - .map((cue) => cue.text) + const texts = uniqueSubtitleTexts( + subtitleTimeline.cues + .filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime) + .map((cue) => cue.text), + ) setActiveCueText(texts.join('\n')) return } @@ -304,7 +315,7 @@ export function PlayerVideoStage({ } } } - setActiveCueText(texts.join('\n')) + setActiveCueText(uniqueSubtitleTexts(texts).join('\n')) } const apply = () => { @@ -359,7 +370,7 @@ export function PlayerVideoStage({ } } } - }, [subtitleIndex, subs, videoRef, media, streamOffset, subtitleTimeline]) + }, [subtitleIndex, subs, videoRef, media, streamOffset, subtitleTimeline, tracksArmed]) // 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100% const isWiderThanStage = @@ -412,7 +423,6 @@ export function PlayerVideoStage({ src={subtitlesAPI.url(media.id, track.path)} srcLang={track.lang} label={track.label || track.lang} - default={subtitleIndex === index} /> ), )}