From 2fa12dbcde043132ffd25391a92b01cf82a84fd7 Mon Sep 17 00:00:00 2001 From: cdn0x12 Date: Wed, 23 Apr 2025 15:29:27 +0800 Subject: [PATCH] feat: support vtt in sync and fmt --- docs/commands.md | 14 +++++ docs/zh-Hans/commands.md | 14 +++++ internal/formatter/formatter.go | 3 ++ internal/sync/sync.go | 92 ++++++++++++++++++++++++++++++++- 4 files changed, 122 insertions(+), 1 deletion(-) diff --git a/docs/commands.md b/docs/commands.md index 6198ac5..7082575 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -107,6 +107,7 @@ sub-cli sync Currently, synchronization only works between files of the same format: - SRT to SRT - LRC to LRC +- VTT to VTT ### Behavior Details @@ -127,6 +128,16 @@ Currently, synchronization only works between files of the same format: - **Preserved from target**: All subtitle text content. - **Modified in target**: Timestamps are updated and entry numbers are standardized (sequential from 1). +#### For VTT Files: + +- **When entry counts match**: Both start and end times from the source are directly applied to the target entries. +- **When entry counts differ**: A scaled approach is used, similar to SRT synchronization: + - Start times are taken from proportionally matched source entries + - End times are calculated based on source entry durations + - The timing relationship between entries is preserved +- **Preserved from target**: All subtitle text content, formatting, cue settings, and styling. +- **Modified in target**: Timestamps are updated and cue identifiers are standardized (sequential from 1). + ### Edge Cases - If the source file has no timing information, the target remains unchanged. @@ -142,6 +153,9 @@ sub-cli sync reference.srt target.srt # Synchronize an LRC file using another LRC file as reference sub-cli sync reference.lrc target.lrc + +# Synchronize a VTT file using another VTT file as reference +sub-cli sync reference.vtt target.vtt ``` ## fmt diff --git a/docs/zh-Hans/commands.md b/docs/zh-Hans/commands.md index c509a4f..bfc4013 100644 --- a/docs/zh-Hans/commands.md +++ b/docs/zh-Hans/commands.md @@ -107,6 +107,7 @@ sub-cli sync <源文件> <目标文件> 目前,同步仅适用于相同格式的文件之间: - SRT到SRT - LRC到LRC +- VTT到VTT ### 行为详情 @@ -127,6 +128,16 @@ sub-cli sync <源文件> <目标文件> - **从目标保留**: 所有字幕文本内容。 - **在目标中修改**: 更新时间戳并标准化条目编号(从1开始顺序编号)。 +#### 对于VTT文件: + +- **当条目数匹配时**: 源的开始和结束时间直接应用于目标条目。 +- **当条目数不同时**: 使用缩放方法: + - 开始时间取自按比例匹配的源条目 + - 结束时间根据源条目时长计算 + - 保持条目之间的时间关系 +- **从目标保留**: 所有字幕文本内容和样式信息。 +- **在目标中修改**: 更新时间戳并标准化提示标识符。 + ### 边缘情况 - 如果源文件没有时间信息,目标保持不变。 @@ -142,6 +153,9 @@ sub-cli sync reference.srt target.srt # 使用另一个LRC文件作为参考来同步LRC文件 sub-cli sync reference.lrc target.lrc + +# 使用另一个VTT文件作为参考来同步VTT文件 +sub-cli sync reference.vtt target.vtt ``` ## fmt diff --git a/internal/formatter/formatter.go b/internal/formatter/formatter.go index cf8d179..eb76fb1 100644 --- a/internal/formatter/formatter.go +++ b/internal/formatter/formatter.go @@ -7,6 +7,7 @@ import ( "sub-cli/internal/format/lrc" "sub-cli/internal/format/srt" + "sub-cli/internal/format/vtt" ) // Format formats a subtitle file to ensure consistent formatting @@ -18,6 +19,8 @@ func Format(filePath string) error { return lrc.Format(filePath) case "srt": return srt.Format(filePath) + case "vtt": + return vtt.Format(filePath) default: return fmt.Errorf("unsupported format for formatting: %s", ext) } diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 44332af..952c699 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -7,6 +7,7 @@ import ( "sub-cli/internal/format/lrc" "sub-cli/internal/format/srt" + "sub-cli/internal/format/vtt" "sub-cli/internal/model" ) @@ -20,8 +21,10 @@ func SyncLyrics(sourceFile, targetFile string) error { return syncLRCFiles(sourceFile, targetFile) } else if sourceFmt == "srt" && targetFmt == "srt" { return syncSRTFiles(sourceFile, targetFile) + } else if sourceFmt == "vtt" && targetFmt == "vtt" { + return syncVTTFiles(sourceFile, targetFile) } else { - return fmt.Errorf("sync only supports files of the same format (lrc-to-lrc or srt-to-srt)") + return fmt.Errorf("sync only supports files of the same format (lrc-to-lrc, srt-to-srt, or vtt-to-vtt)") } } @@ -75,6 +78,31 @@ func syncSRTFiles(sourceFile, targetFile string) error { return srt.Generate(syncedEntries, targetFile) } +// syncVTTFiles synchronizes two VTT files +func syncVTTFiles(sourceFile, targetFile string) error { + sourceSubtitle, err := vtt.Parse(sourceFile) + if err != nil { + return fmt.Errorf("error parsing source VTT file: %w", err) + } + + targetSubtitle, err := vtt.Parse(targetFile) + if err != nil { + return fmt.Errorf("error parsing target VTT file: %w", err) + } + + // Check if entry counts match + if len(sourceSubtitle.Entries) != len(targetSubtitle.Entries) { + fmt.Printf("Warning: Source (%d entries) and target (%d entries) have different entry counts. Timeline will be adjusted.\n", + len(sourceSubtitle.Entries), len(targetSubtitle.Entries)) + } + + // Sync the timelines + syncedSubtitle := syncVTTTimeline(sourceSubtitle, targetSubtitle) + + // Write the synced subtitle to the target file + return vtt.Generate(syncedSubtitle, targetFile) +} + // syncLRCTimeline applies the timeline from the source lyrics to the target lyrics func syncLRCTimeline(source, target model.Lyrics) model.Lyrics { result := model.Lyrics{ @@ -151,6 +179,68 @@ func syncSRTTimeline(sourceEntries, targetEntries []model.SRTEntry) []model.SRTE return result } +// syncVTTTimeline applies the timing from source VTT subtitle to target VTT subtitle +func syncVTTTimeline(source, target model.Subtitle) model.Subtitle { + result := model.NewSubtitle() + result.Format = "vtt" + result.Title = target.Title + result.Metadata = target.Metadata + result.Styles = target.Styles + + // Create entries array with same length as target + result.Entries = make([]model.SubtitleEntry, len(target.Entries)) + + // Copy target entries + copy(result.Entries, target.Entries) + + // If source and target have the same number of entries, directly apply timings + if len(source.Entries) == len(target.Entries) { + for i := range result.Entries { + result.Entries[i].StartTime = source.Entries[i].StartTime + result.Entries[i].EndTime = source.Entries[i].EndTime + } + } else { + // If entry counts differ, scale the timing similar to SRT sync + for i := range result.Entries { + // Calculate scaled index + sourceIdx := 0 + if len(source.Entries) > 1 { + sourceIdx = i * (len(source.Entries) - 1) / (len(target.Entries) - 1) + } + + // Ensure the index is within bounds + if sourceIdx >= len(source.Entries) { + sourceIdx = len(source.Entries) - 1 + } + + // Apply the scaled timing + result.Entries[i].StartTime = source.Entries[sourceIdx].StartTime + + // Calculate end time: if not the last entry, use duration from source + if i < len(result.Entries)-1 { + // If next source entry exists, calculate duration + var duration model.Timestamp + if sourceIdx+1 < len(source.Entries) { + duration = calculateDuration(source.Entries[sourceIdx].StartTime, source.Entries[sourceIdx+1].StartTime) + } else { + duration = calculateDuration(source.Entries[sourceIdx].StartTime, source.Entries[sourceIdx].EndTime) + } + result.Entries[i].EndTime = addDuration(result.Entries[i].StartTime, duration) + } else { + // For the last entry, use the end time from source + result.Entries[i].EndTime = source.Entries[sourceIdx].EndTime + } + } + } + + // Ensure proper index numbering + for i := range result.Entries { + result.Entries[i].Index = i + 1 + } + + return result +} + // scaleTimeline scales a timeline to match a different number of entries func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestamp { if targetCount <= 0 || len(timeline) == 0 {