feat: srt sync and formatting
This commit is contained in:
parent
6bb9f06c52
commit
ba2e477dc0
4 changed files with 181 additions and 12 deletions
|
@ -6,13 +6,18 @@ const Version = "0.4.0"
|
||||||
// Usage stores the general usage information
|
// Usage stores the general usage information
|
||||||
const Usage = `Usage: sub-cli [command] [options]
|
const Usage = `Usage: sub-cli [command] [options]
|
||||||
Commands:
|
Commands:
|
||||||
sync Synchronize timeline of two lyrics files
|
sync Synchronize timeline of two subtitle files
|
||||||
convert Convert lyrics file to another format
|
convert Convert subtitle file to another format
|
||||||
fmt Format lyrics file
|
fmt Format subtitle file
|
||||||
help Show help`
|
help Show help`
|
||||||
|
|
||||||
// SyncUsage stores the usage information for the sync command
|
// SyncUsage stores the usage information for the sync command
|
||||||
const SyncUsage = `Usage: sub-cli sync <source> <target>`
|
const SyncUsage = `Usage: sub-cli sync <source> <target>
|
||||||
|
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
|
// ConvertUsage stores the usage information for the convert command
|
||||||
const ConvertUsage = `Usage: sub-cli convert <source> <target>
|
const ConvertUsage = `Usage: sub-cli convert <source> <target>
|
||||||
|
|
|
@ -122,6 +122,23 @@ func formatSRTTimestamp(ts model.Timestamp) string {
|
||||||
ts.Milliseconds)
|
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
|
// ConvertToLyrics converts SRT entries to a Lyrics structure
|
||||||
func ConvertToLyrics(entries []model.SRTEntry) model.Lyrics {
|
func ConvertToLyrics(entries []model.SRTEntry) model.Lyrics {
|
||||||
lyrics := model.Lyrics{
|
lyrics := model.Lyrics{
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sub-cli/internal/format/lrc"
|
"sub-cli/internal/format/lrc"
|
||||||
|
"sub-cli/internal/format/srt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Format formats a subtitle file to ensure consistent formatting
|
// Format formats a subtitle file to ensure consistent formatting
|
||||||
|
@ -15,6 +16,8 @@ func Format(filePath string) error {
|
||||||
switch ext {
|
switch ext {
|
||||||
case "lrc":
|
case "lrc":
|
||||||
return lrc.Format(filePath)
|
return lrc.Format(filePath)
|
||||||
|
case "srt":
|
||||||
|
return srt.Format(filePath)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported format for formatting: %s", ext)
|
return fmt.Errorf("unsupported format for formatting: %s", ext)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sub-cli/internal/format/lrc"
|
"sub-cli/internal/format/lrc"
|
||||||
|
"sub-cli/internal/format/srt"
|
||||||
"sub-cli/internal/model"
|
"sub-cli/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -14,11 +15,18 @@ func SyncLyrics(sourceFile, targetFile string) error {
|
||||||
sourceFmt := strings.TrimPrefix(filepath.Ext(sourceFile), ".")
|
sourceFmt := strings.TrimPrefix(filepath.Ext(sourceFile), ".")
|
||||||
targetFmt := strings.TrimPrefix(filepath.Ext(targetFile), ".")
|
targetFmt := strings.TrimPrefix(filepath.Ext(targetFile), ".")
|
||||||
|
|
||||||
// Currently only supports LRC files
|
// Check for supported format combinations
|
||||||
if sourceFmt != "lrc" || targetFmt != "lrc" {
|
if sourceFmt == "lrc" && targetFmt == "lrc" {
|
||||||
return fmt.Errorf("sync only supports LRC files currently")
|
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)
|
source, err := lrc.Parse(sourceFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error parsing source file: %w", err)
|
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)
|
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
|
// Apply timeline from source to target
|
||||||
syncedLyrics := syncTimeline(source, target)
|
syncedLyrics := syncLRCTimeline(source, target)
|
||||||
|
|
||||||
// Write the synced lyrics to the target file
|
// Write the synced lyrics to the target file
|
||||||
return lrc.Generate(syncedLyrics, targetFile)
|
return lrc.Generate(syncedLyrics, targetFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncTimeline applies the timeline from the source lyrics to the target lyrics
|
// syncSRTFiles synchronizes two SRT files
|
||||||
func syncTimeline(source, target model.Lyrics) model.Lyrics {
|
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{
|
result := model.Lyrics{
|
||||||
Metadata: target.Metadata,
|
Metadata: target.Metadata,
|
||||||
Content: target.Content,
|
Content: target.Content,
|
||||||
|
@ -54,6 +93,64 @@ func syncTimeline(source, target model.Lyrics) model.Lyrics {
|
||||||
return result
|
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
|
// scaleTimeline scales a timeline to match a different number of entries
|
||||||
func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestamp {
|
func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestamp {
|
||||||
if targetCount <= 0 || len(timeline) == 0 {
|
if targetCount <= 0 || len(timeline) == 0 {
|
||||||
|
@ -77,3 +174,50 @@ func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestam
|
||||||
|
|
||||||
return result
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue