From ba2e477dc03e40e5a201cbb0d7feda791864b21e Mon Sep 17 00:00:00 2001 From: cdn0x12 Date: Wed, 23 Apr 2025 10:27:59 +0800 Subject: [PATCH] feat: srt sync and formatting --- internal/config/constants.go | 15 ++- internal/format/srt/srt.go | 17 ++++ internal/formatter/formatter.go | 3 + internal/sync/sync.go | 158 ++++++++++++++++++++++++++++++-- 4 files changed, 181 insertions(+), 12 deletions(-) diff --git a/internal/config/constants.go b/internal/config/constants.go index 935c8a1..8abc4fd 100644 --- a/internal/config/constants.go +++ b/internal/config/constants.go @@ -6,18 +6,23 @@ const Version = "0.4.0" // Usage stores the general usage information const Usage = `Usage: sub-cli [command] [options] Commands: - sync Synchronize timeline of two lyrics files - convert Convert lyrics file to another format - fmt Format lyrics file + sync Synchronize timeline of two subtitle files + convert Convert subtitle file to another format + fmt Format subtitle file help Show help` // SyncUsage stores the usage information for the sync command -const SyncUsage = `Usage: sub-cli sync ` +const SyncUsage = `Usage: sub-cli sync + Note: + Currently supports synchronizing between files of the same format: + - LRC to LRC + - SRT to SRT + If source and target have different numbers of entries, a warning will be shown.` // ConvertUsage stores the usage information for the convert command const ConvertUsage = `Usage: sub-cli convert Note: Target format is determined by file extension. Supported formats: - .txt Plain text format(No meta/timeline tags, only support as target format) + .txt Plain text format (No meta/timeline tags, only support as target format) .srt SubRip Subtitle format .lrc LRC format` diff --git a/internal/format/srt/srt.go b/internal/format/srt/srt.go index 0934126..6a4ce0c 100644 --- a/internal/format/srt/srt.go +++ b/internal/format/srt/srt.go @@ -122,6 +122,23 @@ func formatSRTTimestamp(ts model.Timestamp) string { ts.Milliseconds) } +// Format standardizes and formats an SRT file +func Format(filePath string) error { + // Parse the file + entries, err := Parse(filePath) + if err != nil { + return fmt.Errorf("error parsing SRT file: %w", err) + } + + // Standardize entry numbering and ensure consistent formatting + for i := range entries { + entries[i].Number = i + 1 // Ensure sequential numbering + } + + // Write back the formatted content + return Generate(entries, filePath) +} + // ConvertToLyrics converts SRT entries to a Lyrics structure func ConvertToLyrics(entries []model.SRTEntry) model.Lyrics { lyrics := model.Lyrics{ diff --git a/internal/formatter/formatter.go b/internal/formatter/formatter.go index ddee025..cf8d179 100644 --- a/internal/formatter/formatter.go +++ b/internal/formatter/formatter.go @@ -6,6 +6,7 @@ import ( "strings" "sub-cli/internal/format/lrc" + "sub-cli/internal/format/srt" ) // Format formats a subtitle file to ensure consistent formatting @@ -15,6 +16,8 @@ func Format(filePath string) error { switch ext { case "lrc": return lrc.Format(filePath) + case "srt": + return srt.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 1df9b4f..44332af 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -6,6 +6,7 @@ import ( "strings" "sub-cli/internal/format/lrc" + "sub-cli/internal/format/srt" "sub-cli/internal/model" ) @@ -14,11 +15,18 @@ func SyncLyrics(sourceFile, targetFile string) error { sourceFmt := strings.TrimPrefix(filepath.Ext(sourceFile), ".") targetFmt := strings.TrimPrefix(filepath.Ext(targetFile), ".") - // Currently only supports LRC files - if sourceFmt != "lrc" || targetFmt != "lrc" { - return fmt.Errorf("sync only supports LRC files currently") + // Check for supported format combinations + if sourceFmt == "lrc" && targetFmt == "lrc" { + return syncLRCFiles(sourceFile, targetFile) + } else if sourceFmt == "srt" && targetFmt == "srt" { + return syncSRTFiles(sourceFile, targetFile) + } else { + return fmt.Errorf("sync only supports files of the same format (lrc-to-lrc or srt-to-srt)") } - +} + +// syncLRCFiles synchronizes two LRC files +func syncLRCFiles(sourceFile, targetFile string) error { source, err := lrc.Parse(sourceFile) if err != nil { return fmt.Errorf("error parsing source file: %w", err) @@ -29,15 +37,46 @@ func SyncLyrics(sourceFile, targetFile string) error { return fmt.Errorf("error parsing target file: %w", err) } + // Check if line counts match + if len(source.Timeline) != len(target.Content) { + fmt.Printf("Warning: Source timeline (%d entries) and target content (%d entries) have different counts. Timeline will be scaled.\n", + len(source.Timeline), len(target.Content)) + } + // Apply timeline from source to target - syncedLyrics := syncTimeline(source, target) + syncedLyrics := syncLRCTimeline(source, target) // Write the synced lyrics to the target file return lrc.Generate(syncedLyrics, targetFile) } -// syncTimeline applies the timeline from the source lyrics to the target lyrics -func syncTimeline(source, target model.Lyrics) model.Lyrics { +// syncSRTFiles synchronizes two SRT files +func syncSRTFiles(sourceFile, targetFile string) error { + sourceEntries, err := srt.Parse(sourceFile) + if err != nil { + return fmt.Errorf("error parsing source SRT file: %w", err) + } + + targetEntries, err := srt.Parse(targetFile) + if err != nil { + return fmt.Errorf("error parsing target SRT file: %w", err) + } + + // Check if entry counts match + if len(sourceEntries) != len(targetEntries) { + fmt.Printf("Warning: Source (%d entries) and target (%d entries) have different entry counts. Timeline will be adjusted.\n", + len(sourceEntries), len(targetEntries)) + } + + // Sync the timelines + syncedEntries := syncSRTTimeline(sourceEntries, targetEntries) + + // Write the synced entries to the target file + return srt.Generate(syncedEntries, targetFile) +} + +// syncLRCTimeline applies the timeline from the source lyrics to the target lyrics +func syncLRCTimeline(source, target model.Lyrics) model.Lyrics { result := model.Lyrics{ Metadata: target.Metadata, Content: target.Content, @@ -54,6 +93,64 @@ func syncTimeline(source, target model.Lyrics) model.Lyrics { return result } +// syncSRTTimeline applies the timing from source SRT entries to target SRT entries +func syncSRTTimeline(sourceEntries, targetEntries []model.SRTEntry) []model.SRTEntry { + result := make([]model.SRTEntry, len(targetEntries)) + + // Copy target entries + copy(result, targetEntries) + + // If source and target have the same number of entries, directly apply timings + if len(sourceEntries) == len(targetEntries) { + for i := range result { + result[i].StartTime = sourceEntries[i].StartTime + result[i].EndTime = sourceEntries[i].EndTime + } + } else { + // If entry counts differ, scale the timing + for i := range result { + // Calculate scaled index + sourceIdx := 0 + if len(sourceEntries) > 1 { + sourceIdx = i * (len(sourceEntries) - 1) / (len(targetEntries) - 1) + } + + // Ensure the index is within bounds + if sourceIdx >= len(sourceEntries) { + sourceIdx = len(sourceEntries) - 1 + } + + // Apply the scaled timing + result[i].StartTime = sourceEntries[sourceIdx].StartTime + + // Calculate end time: if not the last entry, use duration from source + if i < len(result)-1 { + // If next source entry exists, calculate duration + var duration model.Timestamp + if sourceIdx+1 < len(sourceEntries) { + duration = calculateDuration(sourceEntries[sourceIdx].StartTime, sourceEntries[sourceIdx+1].StartTime) + } else { + // If no next source entry, use the source's end time (usually a few seconds after start) + duration = calculateDuration(sourceEntries[sourceIdx].StartTime, sourceEntries[sourceIdx].EndTime) + } + + // Apply duration to next start time + result[i].EndTime = addDuration(result[i].StartTime, duration) + } else { + // For the last entry, add a fixed duration (e.g., 3 seconds) + result[i].EndTime = sourceEntries[sourceIdx].EndTime + } + } + } + + // Ensure proper sequence numbering + for i := range result { + result[i].Number = 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 { @@ -77,3 +174,50 @@ func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestam return result } + +// calculateDuration calculates the time difference between two timestamps +func calculateDuration(start, end model.Timestamp) model.Timestamp { + startMillis := start.Hours*3600000 + start.Minutes*60000 + start.Seconds*1000 + start.Milliseconds + endMillis := end.Hours*3600000 + end.Minutes*60000 + end.Seconds*1000 + end.Milliseconds + + durationMillis := endMillis - startMillis + if durationMillis < 0 { + durationMillis = 3000 // Default 3 seconds if negative + } + + hours := durationMillis / 3600000 + durationMillis %= 3600000 + minutes := durationMillis / 60000 + durationMillis %= 60000 + seconds := durationMillis / 1000 + milliseconds := durationMillis % 1000 + + return model.Timestamp{ + Hours: hours, + Minutes: minutes, + Seconds: seconds, + Milliseconds: milliseconds, + } +} + +// addDuration adds a duration to a timestamp +func addDuration(start, duration model.Timestamp) model.Timestamp { + startMillis := start.Hours*3600000 + start.Minutes*60000 + start.Seconds*1000 + start.Milliseconds + durationMillis := duration.Hours*3600000 + duration.Minutes*60000 + duration.Seconds*1000 + duration.Milliseconds + + totalMillis := startMillis + durationMillis + + hours := totalMillis / 3600000 + totalMillis %= 3600000 + minutes := totalMillis / 60000 + totalMillis %= 60000 + seconds := totalMillis / 1000 + milliseconds := totalMillis % 1000 + + return model.Timestamp{ + Hours: hours, + Minutes: minutes, + Seconds: seconds, + Milliseconds: milliseconds, + } +}