chore: seperate large files

This commit is contained in:
CDN 2025-04-23 19:22:41 +08:00
parent ebbf516689
commit 76e1298ded
Signed by: CDN
GPG key ID: 0C656827F9F80080
44 changed files with 5745 additions and 4173 deletions

80
internal/sync/ass.go Normal file
View file

@ -0,0 +1,80 @@
package sync
import (
"fmt"
"sub-cli/internal/format/ass"
"sub-cli/internal/model"
)
// syncASSFiles synchronizes two ASS files
func syncASSFiles(sourceFile, targetFile string) error {
sourceSubtitle, err := ass.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source ASS file: %w", err)
}
targetSubtitle, err := ass.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target ASS file: %w", err)
}
// Check if entry counts match
if len(sourceSubtitle.Events) != len(targetSubtitle.Events) {
fmt.Printf("Warning: Source (%d events) and target (%d events) have different event counts. Timeline will be adjusted.\n",
len(sourceSubtitle.Events), len(targetSubtitle.Events))
}
// Sync the timelines
syncedSubtitle := syncASSTimeline(sourceSubtitle, targetSubtitle)
// Write the synced subtitle to the target file
return ass.Generate(syncedSubtitle, targetFile)
}
// syncASSTimeline applies the timing from source ASS subtitle to target ASS subtitle
func syncASSTimeline(source, target model.ASSFile) model.ASSFile {
result := model.ASSFile{
ScriptInfo: target.ScriptInfo,
Styles: target.Styles,
Events: make([]model.ASSEvent, len(target.Events)),
}
// Copy target events
copy(result.Events, target.Events)
// If there are no events in either source or target, return as is
if len(source.Events) == 0 || len(target.Events) == 0 {
return result
}
// Extract start and end timestamps from source
sourceStartTimes := make([]model.Timestamp, len(source.Events))
sourceEndTimes := make([]model.Timestamp, len(source.Events))
for i, event := range source.Events {
sourceStartTimes[i] = event.StartTime
sourceEndTimes[i] = event.EndTime
}
// Scale timestamps if source and target event counts differ
var scaledStartTimes, scaledEndTimes []model.Timestamp
if len(source.Events) == len(target.Events) {
// If counts match, use source times directly
scaledStartTimes = sourceStartTimes
scaledEndTimes = sourceEndTimes
} else {
// Scale the timelines to match target count
scaledStartTimes = scaleTimeline(sourceStartTimes, len(target.Events))
scaledEndTimes = scaleTimeline(sourceEndTimes, len(target.Events))
}
// Apply scaled timeline to target events
for i := range result.Events {
result.Events[i].StartTime = scaledStartTimes[i]
result.Events[i].EndTime = scaledEndTimes[i]
}
return result
}

465
internal/sync/ass_test.go Normal file
View file

@ -0,0 +1,465 @@
package sync
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestSyncASSTimeline(t *testing.T) {
testCases := []struct {
name string
source model.ASSFile
target model.ASSFile
verify func(t *testing.T, result model.ASSFile)
}{
{
name: "Equal event counts",
source: model.ASSFile{
ScriptInfo: map[string]string{"Title": "Source ASS"},
Styles: []model.ASSStyle{
{
Name: "Default",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour",
"Style": "Default,Arial,20,&H00FFFFFF",
},
},
},
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Style: "Default",
Text: "Source line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Style: "Default",
Text: "Source line two.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 9},
EndTime: model.Timestamp{Seconds: 12},
Style: "Default",
Text: "Source line three.",
},
},
},
target: model.ASSFile{
ScriptInfo: map[string]string{"Title": "Target ASS"},
Styles: []model.ASSStyle{
{
Name: "Default",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour",
"Style": "Default,Arial,20,&H00FFFFFF",
},
},
{
Name: "Alternate",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour",
"Style": "Alternate,Times New Roman,20,&H0000FFFF",
},
},
},
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Style: "Default",
Text: "Target line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Style: "Alternate",
Text: "Target line two.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Style: "Default",
Text: "Target line three.",
},
},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 3 {
t.Errorf("Expected 3 events, got %d", len(result.Events))
return
}
// Check that source timings are applied to target events
if result.Events[0].StartTime.Seconds != 1 || result.Events[0].EndTime.Seconds != 4 {
t.Errorf("First event timing mismatch: got %+v", result.Events[0])
}
if result.Events[1].StartTime.Seconds != 5 || result.Events[1].EndTime.Seconds != 8 {
t.Errorf("Second event timing mismatch: got %+v", result.Events[1])
}
if result.Events[2].StartTime.Seconds != 9 || result.Events[2].EndTime.Seconds != 12 {
t.Errorf("Third event timing mismatch: got %+v", result.Events[2])
}
// Check that target content and styles are preserved
if result.Events[0].Text != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Events[0].Text)
}
if result.Events[1].Style != "Alternate" {
t.Errorf("Style should be preserved, got: %s", result.Events[1].Style)
}
// Check that script info and style definitions are preserved
if result.ScriptInfo["Title"] != "Target ASS" {
t.Errorf("ScriptInfo should be preserved, got: %+v", result.ScriptInfo)
}
if len(result.Styles) != 2 {
t.Errorf("Expected 2 styles, got %d", len(result.Styles))
}
if result.Styles[1].Name != "Alternate" {
t.Errorf("Style definitions should be preserved, got: %+v", result.Styles[1])
}
},
},
{
name: "More target events than source",
source: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Style: "Default",
Text: "Source line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Style: "Default",
Text: "Source line two.",
},
},
},
target: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Style: "Default",
Text: "Target line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Style: "Default",
Text: "Target line two.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Style: "Default",
Text: "Target line three.",
},
},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 3 {
t.Errorf("Expected 3 events, got %d", len(result.Events))
return
}
// First event should use first source timing
if result.Events[0].StartTime.Seconds != 1 {
t.Errorf("First event should have first source timing, got: %+v", result.Events[0].StartTime)
}
// Last event should use last source timing
if result.Events[2].StartTime.Seconds != 5 {
t.Errorf("Last event should have last source timing, got: %+v", result.Events[2].StartTime)
}
// Verify content is preserved
if result.Events[2].Text != "Target line three." {
t.Errorf("Content should be preserved, got: %s", result.Events[2].Text)
}
},
},
{
name: "More source events than target",
source: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 3},
Style: "Default",
Text: "Source line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 4},
EndTime: model.Timestamp{Seconds: 6},
Style: "Default",
Text: "Source line two.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 7},
EndTime: model.Timestamp{Seconds: 9},
Style: "Default",
Text: "Source line three.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 12},
Style: "Default",
Text: "Source line four.",
},
},
},
target: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Style: "Default",
Text: "Target line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Style: "Default",
Text: "Target line two.",
},
},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 2 {
t.Errorf("Expected 2 events, got %d", len(result.Events))
return
}
// First event should have first source timing
if result.Events[0].StartTime.Seconds != 1 {
t.Errorf("First event should have first source timing, got: %+v", result.Events[0].StartTime)
}
// Last event should have last source timing
if result.Events[1].StartTime.Seconds != 10 {
t.Errorf("Last event should have last source timing, got: %+v", result.Events[1].StartTime)
}
// Check that target content is preserved
if result.Events[0].Text != "Target line one." || result.Events[1].Text != "Target line two." {
t.Errorf("Content should be preserved, got: %+v", result.Events)
}
},
},
{
name: "Empty target events",
source: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Style: "Default",
Text: "Source line one.",
},
},
},
target: model.ASSFile{
ScriptInfo: map[string]string{"Title": "Empty Target"},
Events: []model.ASSEvent{},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 0 {
t.Errorf("Expected 0 events, got %d", len(result.Events))
}
// ScriptInfo should be preserved
if result.ScriptInfo["Title"] != "Empty Target" {
t.Errorf("ScriptInfo should be preserved, got: %+v", result.ScriptInfo)
}
},
},
{
name: "Empty source events",
source: model.ASSFile{
Events: []model.ASSEvent{},
},
target: model.ASSFile{
ScriptInfo: map[string]string{"Title": "Target with content"},
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 15},
Style: "Default",
Text: "Target line one.",
},
},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 1 {
t.Errorf("Expected 1 event, got %d", len(result.Events))
return
}
// Timing should be preserved since source is empty
if result.Events[0].StartTime.Seconds != 10 || result.Events[0].EndTime.Seconds != 15 {
t.Errorf("Timing should match target when source is empty, got: %+v", result.Events[0])
}
// Content should be preserved
if result.Events[0].Text != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Events[0].Text)
}
// Title should be preserved
if result.ScriptInfo["Title"] != "Target with content" {
t.Errorf("Title should be preserved, got: %+v", result.ScriptInfo)
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := syncASSTimeline(tc.source, tc.target)
if tc.verify != nil {
tc.verify(t, result)
}
})
}
}
func TestSyncASSFiles(t *testing.T) {
// Create temporary test directory
tempDir := t.TempDir()
// Test case for testing the sync of ASS files
sourceContent := `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 480
Timer: 100.0000
Title: Source ASS File
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,Source line one.
Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,Source line two.
Dialogue: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,Source line three.
`
targetContent := `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 480
Timer: 100.0000
Title: Target ASS File
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: Alternate,Arial,20,&H0000FFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:01:00.00,0:01:03.00,Default,,0,0,0,,Target line one.
Dialogue: 0,0:01:05.00,0:01:08.00,Alternate,,0,0,0,,Target line two.
Dialogue: 0,0:01:10.00,0:01:13.00,Default,,0,0,0,,Target line three.
`
sourceFile := filepath.Join(tempDir, "source.ass")
targetFile := filepath.Join(tempDir, "target.ass")
// Write test files
if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil {
t.Fatalf("Failed to write source file: %v", err)
}
if err := os.WriteFile(targetFile, []byte(targetContent), 0644); err != nil {
t.Fatalf("Failed to write target file: %v", err)
}
// Run syncASSFiles
err := syncASSFiles(sourceFile, targetFile)
if err != nil {
t.Fatalf("syncASSFiles returned error: %v", err)
}
// Read the modified target file
modifiedContent, err := os.ReadFile(targetFile)
if err != nil {
t.Fatalf("Failed to read modified file: %v", err)
}
// Verify the result
// Should have source timings
if !strings.Contains(string(modifiedContent), "0:00:01.00") {
t.Errorf("Output should have source timing 0:00:01.00, got: %s", string(modifiedContent))
}
// Should preserve target content and styles
if !strings.Contains(string(modifiedContent), "Target line one.") {
t.Errorf("Output should preserve target content, got: %s", string(modifiedContent))
}
if !strings.Contains(string(modifiedContent), "Style: Alternate") {
t.Errorf("Output should preserve target styles, got: %s", string(modifiedContent))
}
// Should preserve title
if !strings.Contains(string(modifiedContent), "Title: Target ASS File") {
t.Errorf("Output should preserve target title, got: %s", string(modifiedContent))
}
}

64
internal/sync/lrc.go Normal file
View file

@ -0,0 +1,64 @@
package sync
import (
"fmt"
"sub-cli/internal/format/lrc"
"sub-cli/internal/model"
)
// 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)
}
target, err := lrc.Parse(targetFile)
if err != nil {
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 := syncLRCTimeline(source, target)
// Write the synced lyrics to the target file
return lrc.Generate(syncedLyrics, 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,
}
// If target has no content, return empty result with metadata only
if len(target.Content) == 0 {
result.Timeline = []model.Timestamp{}
return result
}
// If source has no timeline, keep target as is
if len(source.Timeline) == 0 {
result.Timeline = target.Timeline
return result
}
// Scale the source timeline to match the target content length
if len(source.Timeline) != len(target.Content) {
result.Timeline = scaleTimeline(source.Timeline, len(target.Content))
} else {
// If lengths match, directly use source timeline
result.Timeline = make([]model.Timestamp, len(source.Timeline))
copy(result.Timeline, source.Timeline)
}
return result
}

265
internal/sync/lrc_test.go Normal file
View file

@ -0,0 +1,265 @@
package sync
import (
"testing"
"sub-cli/internal/model"
)
func TestSyncLRCTimeline(t *testing.T) {
testCases := []struct {
name string
source model.Lyrics
target model.Lyrics
verify func(t *testing.T, result model.Lyrics)
}{
{
name: "Equal content length",
source: model.Lyrics{
Metadata: map[string]string{
"ti": "Source LRC",
"ar": "Test Artist",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 1, Milliseconds: 0},
{Minutes: 0, Seconds: 5, Milliseconds: 0},
{Minutes: 0, Seconds: 9, Milliseconds: 500},
},
Content: []string{
"This is line one.",
"This is line two.",
"This is line three.",
},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Target LRC",
"ar": "Different Artist",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 10, Milliseconds: 0},
{Minutes: 0, Seconds: 20, Milliseconds: 0},
{Minutes: 0, Seconds: 30, Milliseconds: 0},
},
Content: []string{
"This is line one with different timing.",
"This is line two with different timing.",
"This is line three with different timing.",
},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 3 {
t.Errorf("Expected 3 timeline entries, got %d", len(result.Timeline))
return
}
// Verify that source timings are applied
if result.Timeline[0].Seconds != 1 || result.Timeline[0].Milliseconds != 0 {
t.Errorf("First timeline entry should have source timing, got: %+v", result.Timeline[0])
}
if result.Timeline[1].Seconds != 5 || result.Timeline[1].Milliseconds != 0 {
t.Errorf("Second timeline entry should have source timing, got: %+v", result.Timeline[1])
}
if result.Timeline[2].Seconds != 9 || result.Timeline[2].Milliseconds != 500 {
t.Errorf("Third timeline entry should have source timing, got: %+v", result.Timeline[2])
}
// Verify that target content is preserved
if result.Content[0] != "This is line one with different timing." {
t.Errorf("Content should be preserved, got: %s", result.Content[0])
}
// Verify that target metadata is preserved
if result.Metadata["ti"] != "Target LRC" || result.Metadata["ar"] != "Different Artist" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
},
},
{
name: "More target content than source timeline",
source: model.Lyrics{
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 1, Milliseconds: 0},
{Minutes: 0, Seconds: 5, Milliseconds: 0},
},
Content: []string{
"This is line one.",
"This is line two.",
},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Target LRC",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 10, Milliseconds: 0},
{Minutes: 0, Seconds: 20, Milliseconds: 0},
{Minutes: 0, Seconds: 30, Milliseconds: 0},
},
Content: []string{
"This is line one with different timing.",
"This is line two with different timing.",
"This is line three with different timing.",
},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 3 {
t.Errorf("Expected 3 timeline entries, got %d", len(result.Timeline))
return
}
// Verify that source timings are scaled
if result.Timeline[0].Seconds != 1 {
t.Errorf("First timeline entry should have first source timing, got: %+v", result.Timeline[0])
}
if result.Timeline[2].Seconds != 5 {
t.Errorf("Last timeline entry should have last source timing, got: %+v", result.Timeline[2])
}
// Verify that target content is preserved
if result.Content[2] != "This is line three with different timing." {
t.Errorf("Content should be preserved, got: %s", result.Content[2])
}
// Verify that target metadata is preserved
if result.Metadata["ti"] != "Target LRC" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
},
},
{
name: "More source timeline than target content",
source: model.Lyrics{
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 1, Milliseconds: 0},
{Minutes: 0, Seconds: 3, Milliseconds: 0},
{Minutes: 0, Seconds: 5, Milliseconds: 0},
{Minutes: 0, Seconds: 7, Milliseconds: 0},
},
Content: []string{
"Source line one.",
"Source line two.",
"Source line three.",
"Source line four.",
},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Target LRC",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 10, Milliseconds: 0},
{Minutes: 0, Seconds: 20, Milliseconds: 0},
},
Content: []string{
"Target line one.",
"Target line two.",
},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 2 {
t.Errorf("Expected 2 timeline entries, got %d", len(result.Timeline))
return
}
// Verify that source timings are scaled
if result.Timeline[0].Seconds != 1 {
t.Errorf("First timeline entry should have first source timing, got: %+v", result.Timeline[0])
}
if result.Timeline[1].Seconds != 7 {
t.Errorf("Last timeline entry should have last source timing, got: %+v", result.Timeline[1])
}
// Verify that target content is preserved
if result.Content[0] != "Target line one." || result.Content[1] != "Target line two." {
t.Errorf("Content should be preserved, got: %+v", result.Content)
}
},
},
{
name: "Empty target content",
source: model.Lyrics{
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 1, Milliseconds: 0},
},
Content: []string{
"Source line one.",
},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Empty Target",
},
Timeline: []model.Timestamp{},
Content: []string{},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 0 {
t.Errorf("Expected 0 timeline entries, got %d", len(result.Timeline))
}
if len(result.Content) != 0 {
t.Errorf("Expected 0 content entries, got %d", len(result.Content))
}
// Verify that target metadata is preserved
if result.Metadata["ti"] != "Empty Target" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
},
},
{
name: "Empty source timeline",
source: model.Lyrics{
Timeline: []model.Timestamp{},
Content: []string{},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Target with content",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 10, Milliseconds: 0},
},
Content: []string{
"Target line one.",
},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 1 {
t.Errorf("Expected 1 timeline entry, got %d", len(result.Timeline))
return
}
// Verify that target timing is preserved when source is empty
if result.Timeline[0].Seconds != 10 {
t.Errorf("Timeline should match target when source is empty, got: %+v", result.Timeline[0])
}
// Verify that target content is preserved
if result.Content[0] != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Content[0])
}
// Verify that target metadata is preserved
if result.Metadata["ti"] != "Target with content" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := syncLRCTimeline(tc.source, tc.target)
if tc.verify != nil {
tc.verify(t, result)
}
})
}
}

100
internal/sync/srt.go Normal file
View file

@ -0,0 +1,100 @@
package sync
import (
"fmt"
"sub-cli/internal/format/srt"
"sub-cli/internal/model"
)
// 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)
}
// 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 is empty, just return the target entries as is
if len(sourceEntries) == 0 {
// Ensure proper sequence numbering
for i := range result {
result[i].Number = i + 1
}
return result
}
// 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
}

274
internal/sync/srt_test.go Normal file
View file

@ -0,0 +1,274 @@
package sync
import (
"testing"
"sub-cli/internal/model"
)
func TestSyncSRTTimeline(t *testing.T) {
testCases := []struct {
name string
sourceEntries []model.SRTEntry
targetEntries []model.SRTEntry
verify func(t *testing.T, result []model.SRTEntry)
}{
{
name: "Equal entry counts",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Source line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Content: "Source line two.",
},
{
Number: 3,
StartTime: model.Timestamp{Seconds: 9},
EndTime: model.Timestamp{Seconds: 12},
Content: "Source line three.",
},
},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Content: "Target line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Content: "Target line two.",
},
{
Number: 3,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Content: "Target line three.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 3 {
t.Errorf("Expected 3 entries, got %d", len(result))
return
}
// Check that source timings are applied to target entries
if result[0].StartTime.Seconds != 1 || result[0].EndTime.Seconds != 4 {
t.Errorf("First entry timing mismatch: got %+v", result[0])
}
if result[1].StartTime.Seconds != 5 || result[1].EndTime.Seconds != 8 {
t.Errorf("Second entry timing mismatch: got %+v", result[1])
}
if result[2].StartTime.Seconds != 9 || result[2].EndTime.Seconds != 12 {
t.Errorf("Third entry timing mismatch: got %+v", result[2])
}
// Check that target content is preserved
if result[0].Content != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result[0].Content)
}
// Check that numbering is correct
if result[0].Number != 1 || result[1].Number != 2 || result[2].Number != 3 {
t.Errorf("Entry numbers should be sequential: %+v", result)
}
},
},
{
name: "More target entries than source",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Source line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Content: "Source line two.",
},
},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Content: "Target line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Content: "Target line two.",
},
{
Number: 3,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Content: "Target line three.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 3 {
t.Errorf("Expected 3 entries, got %d", len(result))
return
}
// Check that source timings are scaled appropriately
if result[0].StartTime.Seconds != 1 {
t.Errorf("First entry should have first source start time, got: %+v", result[0].StartTime)
}
if result[2].StartTime.Seconds != 5 {
t.Errorf("Last entry should have last source start time, got: %+v", result[2].StartTime)
}
// Check that content is preserved
if result[2].Content != "Target line three." {
t.Errorf("Content should be preserved, got: %s", result[2].Content)
}
// Check that numbering is correct
if result[0].Number != 1 || result[1].Number != 2 || result[2].Number != 3 {
t.Errorf("Entry numbers should be sequential: %+v", result)
}
},
},
{
name: "More source entries than target",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 3},
Content: "Source line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Seconds: 4},
EndTime: model.Timestamp{Seconds: 6},
Content: "Source line two.",
},
{
Number: 3,
StartTime: model.Timestamp{Seconds: 7},
EndTime: model.Timestamp{Seconds: 9},
Content: "Source line three.",
},
{
Number: 4,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 12},
Content: "Source line four.",
},
},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Content: "Target line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Content: "Target line two.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 2 {
t.Errorf("Expected 2 entries, got %d", len(result))
return
}
// Check that source timings are scaled appropriately
if result[0].StartTime.Seconds != 1 {
t.Errorf("First entry should have first source timing, got: %+v", result[0].StartTime)
}
if result[1].StartTime.Seconds != 10 {
t.Errorf("Last entry should have last source timing, got: %+v", result[1].StartTime)
}
// Check that content is preserved
if result[0].Content != "Target line one." || result[1].Content != "Target line two." {
t.Errorf("Content should be preserved, got: %+v", result)
}
// Check that numbering is correct
if result[0].Number != 1 || result[1].Number != 2 {
t.Errorf("Entry numbers should be sequential: %+v", result)
}
},
},
{
name: "Empty target entries",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Source line one.",
},
},
targetEntries: []model.SRTEntry{},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 0 {
t.Errorf("Expected 0 entries, got %d", len(result))
}
},
},
{
name: "Empty source entries",
sourceEntries: []model.SRTEntry{},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Target line one.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 1 {
t.Errorf("Expected 1 entry, got %d", len(result))
return
}
// Check that numbering is correct even with empty source
if result[0].Number != 1 {
t.Errorf("Entry number should be 1, got: %d", result[0].Number)
}
// Content should be preserved
if result[0].Content != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result[0].Content)
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := syncSRTTimeline(tc.sourceEntries, tc.targetEntries)
if tc.verify != nil {
tc.verify(t, result)
}
})
}
}

View file

@ -1,15 +1,9 @@
package sync
import (
"fmt"
"path/filepath"
"strings"
"sub-cli/internal/format/ass"
"sub-cli/internal/format/lrc"
"sub-cli/internal/format/srt"
"sub-cli/internal/format/vtt"
"sub-cli/internal/model"
"fmt"
"path/filepath"
"strings"
)
// SyncLyrics synchronizes the timeline of a source lyrics file with a target lyrics file
@ -30,438 +24,3 @@ func SyncLyrics(sourceFile, targetFile string) error {
return fmt.Errorf("sync only supports files of the same format (lrc-to-lrc, srt-to-srt, vtt-to-vtt, or ass-to-ass)")
}
}
// 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)
}
target, err := lrc.Parse(targetFile)
if err != nil {
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 := syncLRCTimeline(source, target)
// Write the synced lyrics to the target file
return lrc.Generate(syncedLyrics, targetFile)
}
// 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)
}
// 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)
}
// syncASSFiles synchronizes two ASS files
func syncASSFiles(sourceFile, targetFile string) error {
sourceSubtitle, err := ass.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source ASS file: %w", err)
}
targetSubtitle, err := ass.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target ASS file: %w", err)
}
// Check if entry counts match
if len(sourceSubtitle.Events) != len(targetSubtitle.Events) {
fmt.Printf("Warning: Source (%d events) and target (%d events) have different event counts. Timeline will be adjusted.\n",
len(sourceSubtitle.Events), len(targetSubtitle.Events))
}
// Sync the timelines
syncedSubtitle := syncASSTimeline(sourceSubtitle, targetSubtitle)
// Write the synced subtitle to the target file
return ass.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{
Metadata: target.Metadata,
Content: target.Content,
}
// Create timeline with same length as target content
result.Timeline = make([]model.Timestamp, len(target.Content))
// Use source timeline if available and lengths match
if len(source.Timeline) > 0 && len(source.Timeline) == len(target.Content) {
copy(result.Timeline, source.Timeline)
} else if len(source.Timeline) > 0 {
// If lengths don't match, scale timeline using our improved scaleTimeline function
result.Timeline = scaleTimeline(source.Timeline, len(target.Content))
}
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 is empty, just return the target entries as is
if len(sourceEntries) == 0 {
// Ensure proper sequence numbering
for i := range result {
result[i].Number = i + 1
}
return result
}
// 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
}
// 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 len(source.Entries) == 0 || len(target.Entries) == 0 {
// 确保索引编号正确
for i := range result.Entries {
result.Entries[i].Index = i + 1
}
return result
}
// 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
}
// syncASSTimeline applies the timing from source ASS subtitle to target ASS subtitle
func syncASSTimeline(source, target model.ASSFile) model.ASSFile {
result := model.ASSFile{
ScriptInfo: target.ScriptInfo,
Styles: target.Styles,
Events: make([]model.ASSEvent, len(target.Events)),
}
// Copy target events to preserve content
copy(result.Events, target.Events)
// If there are no events in either source or target, return as is
if len(source.Events) == 0 || len(target.Events) == 0 {
return result
}
// Create a timeline of source start and end times
sourceStartTimes := make([]model.Timestamp, len(source.Events))
sourceEndTimes := make([]model.Timestamp, len(source.Events))
for i, event := range source.Events {
sourceStartTimes[i] = event.StartTime
sourceEndTimes[i] = event.EndTime
}
// Scale the timeline if source and target have different number of events
var scaledStartTimes, scaledEndTimes []model.Timestamp
if len(source.Events) != len(target.Events) {
scaledStartTimes = scaleTimeline(sourceStartTimes, len(target.Events))
scaledEndTimes = scaleTimeline(sourceEndTimes, len(target.Events))
} else {
scaledStartTimes = sourceStartTimes
scaledEndTimes = sourceEndTimes
}
// Apply scaled timeline to target events
for i := range result.Events {
result.Events[i].StartTime = scaledStartTimes[i]
result.Events[i].EndTime = scaledEndTimes[i]
}
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 {
return []model.Timestamp{}
}
result := make([]model.Timestamp, targetCount)
if targetCount == 1 {
result[0] = timeline[0]
return result
}
sourceLength := len(timeline)
// Handle simple case: same length
if targetCount == sourceLength {
copy(result, timeline)
return result
}
// Handle case where target is longer than source
// We need to interpolate timestamps between source entries
for i := 0; i < targetCount; i++ {
if sourceLength == 1 {
// If source has only one entry, use it for all target entries
result[i] = timeline[0]
continue
}
// Calculate a floating-point position in the source timeline
floatIndex := float64(i) * float64(sourceLength-1) / float64(targetCount-1)
lowerIndex := int(floatIndex)
upperIndex := lowerIndex + 1
// Handle boundary case
if upperIndex >= sourceLength {
upperIndex = sourceLength - 1
lowerIndex = upperIndex - 1
}
// If indices are the same, just use the source timestamp
if lowerIndex == upperIndex || lowerIndex < 0 {
result[i] = timeline[upperIndex]
} else {
// Calculate the fraction between the lower and upper indices
fraction := floatIndex - float64(lowerIndex)
// Convert timestamps to milliseconds for interpolation
lowerMS := timeline[lowerIndex].Hours*3600000 + timeline[lowerIndex].Minutes*60000 +
timeline[lowerIndex].Seconds*1000 + timeline[lowerIndex].Milliseconds
upperMS := timeline[upperIndex].Hours*3600000 + timeline[upperIndex].Minutes*60000 +
timeline[upperIndex].Seconds*1000 + timeline[upperIndex].Milliseconds
// Interpolate
resultMS := int(float64(lowerMS) + fraction*float64(upperMS-lowerMS))
// Convert back to timestamp
hours := resultMS / 3600000
resultMS %= 3600000
minutes := resultMS / 60000
resultMS %= 60000
seconds := resultMS / 1000
milliseconds := resultMS % 1000
result[i] = model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}
}
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 {
// Return zero duration if end is before start
return model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 0,
Milliseconds: 0,
}
}
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,
}
}

File diff suppressed because it is too large Load diff

136
internal/sync/utils.go Normal file
View file

@ -0,0 +1,136 @@
package sync
import (
"sub-cli/internal/model"
)
// 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 {
return []model.Timestamp{}
}
result := make([]model.Timestamp, targetCount)
if targetCount == 1 {
result[0] = timeline[0]
return result
}
sourceLength := len(timeline)
// Handle simple case: same length
if targetCount == sourceLength {
copy(result, timeline)
return result
}
// Handle case where target is longer than source
// We need to interpolate timestamps between source entries
for i := 0; i < targetCount; i++ {
if sourceLength == 1 {
// If source has only one entry, use it for all target entries
result[i] = timeline[0]
continue
}
// Calculate a floating-point position in the source timeline
floatIndex := float64(i) * float64(sourceLength-1) / float64(targetCount-1)
lowerIndex := int(floatIndex)
upperIndex := lowerIndex + 1
// Handle boundary case
if upperIndex >= sourceLength {
upperIndex = sourceLength - 1
lowerIndex = upperIndex - 1
}
// If indices are the same, just use the source timestamp
if lowerIndex == upperIndex || lowerIndex < 0 {
result[i] = timeline[upperIndex]
} else {
// Calculate the fraction between the lower and upper indices
fraction := floatIndex - float64(lowerIndex)
// Convert timestamps to milliseconds for interpolation
lowerMS := timeline[lowerIndex].Hours*3600000 + timeline[lowerIndex].Minutes*60000 +
timeline[lowerIndex].Seconds*1000 + timeline[lowerIndex].Milliseconds
upperMS := timeline[upperIndex].Hours*3600000 + timeline[upperIndex].Minutes*60000 +
timeline[upperIndex].Seconds*1000 + timeline[upperIndex].Milliseconds
// Interpolate
resultMS := int(float64(lowerMS) + fraction*float64(upperMS-lowerMS))
// Convert back to timestamp
hours := resultMS / 3600000
resultMS %= 3600000
minutes := resultMS / 60000
resultMS %= 60000
seconds := resultMS / 1000
milliseconds := resultMS % 1000
result[i] = model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}
}
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 {
// Return zero duration if end is before start
return model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 0,
Milliseconds: 0,
}
}
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,
}
}

236
internal/sync/utils_test.go Normal file
View file

@ -0,0 +1,236 @@
package sync
import (
"testing"
"sub-cli/internal/model"
)
func TestCalculateDuration(t *testing.T) {
testCases := []struct {
name string
start model.Timestamp
end model.Timestamp
expected model.Timestamp
}{
{
name: "Simple duration",
start: model.Timestamp{Minutes: 1, Seconds: 30},
end: model.Timestamp{Minutes: 3, Seconds: 10},
expected: model.Timestamp{Minutes: 1, Seconds: 40},
},
{
name: "Duration with hours",
start: model.Timestamp{Hours: 1, Minutes: 20},
end: model.Timestamp{Hours: 2, Minutes: 10},
expected: model.Timestamp{Hours: 0, Minutes: 50},
},
{
name: "Duration with milliseconds",
start: model.Timestamp{Seconds: 10, Milliseconds: 500},
end: model.Timestamp{Seconds: 20, Milliseconds: 800},
expected: model.Timestamp{Seconds: 10, Milliseconds: 300},
},
{
name: "End before start (should return zero)",
start: model.Timestamp{Minutes: 5},
end: model.Timestamp{Minutes: 3},
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0},
},
{
name: "Complex duration with carry",
start: model.Timestamp{Hours: 1, Minutes: 45, Seconds: 30, Milliseconds: 500},
end: model.Timestamp{Hours: 3, Minutes: 20, Seconds: 15, Milliseconds: 800},
expected: model.Timestamp{Hours: 1, Minutes: 34, Seconds: 45, Milliseconds: 300},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := calculateDuration(tc.start, tc.end)
if result.Hours != tc.expected.Hours ||
result.Minutes != tc.expected.Minutes ||
result.Seconds != tc.expected.Seconds ||
result.Milliseconds != tc.expected.Milliseconds {
t.Errorf("Expected %+v, got %+v", tc.expected, result)
}
})
}
}
func TestAddDuration(t *testing.T) {
testCases := []struct {
name string
start model.Timestamp
duration model.Timestamp
expected model.Timestamp
}{
{
name: "Simple addition",
start: model.Timestamp{Minutes: 1, Seconds: 30},
duration: model.Timestamp{Minutes: 2, Seconds: 15},
expected: model.Timestamp{Minutes: 3, Seconds: 45},
},
{
name: "Addition with carry",
start: model.Timestamp{Minutes: 58, Seconds: 45},
duration: model.Timestamp{Minutes: 4, Seconds: 30},
expected: model.Timestamp{Hours: 1, Minutes: 3, Seconds: 15},
},
{
name: "Addition with milliseconds",
start: model.Timestamp{Seconds: 10, Milliseconds: 500},
duration: model.Timestamp{Seconds: 5, Milliseconds: 800},
expected: model.Timestamp{Seconds: 16, Milliseconds: 300},
},
{
name: "Zero duration",
start: model.Timestamp{Minutes: 5, Seconds: 30},
duration: model.Timestamp{},
expected: model.Timestamp{Minutes: 5, Seconds: 30},
},
{
name: "Complex addition with multiple carries",
start: model.Timestamp{Hours: 1, Minutes: 59, Seconds: 59, Milliseconds: 900},
duration: model.Timestamp{Hours: 2, Minutes: 10, Seconds: 15, Milliseconds: 200},
expected: model.Timestamp{Hours: 4, Minutes: 10, Seconds: 15, Milliseconds: 100},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := addDuration(tc.start, tc.duration)
if result.Hours != tc.expected.Hours ||
result.Minutes != tc.expected.Minutes ||
result.Seconds != tc.expected.Seconds ||
result.Milliseconds != tc.expected.Milliseconds {
t.Errorf("Expected %+v, got %+v", tc.expected, result)
}
})
}
}
func TestScaleTimeline(t *testing.T) {
testCases := []struct {
name string
timeline []model.Timestamp
targetCount int
expected []model.Timestamp
}{
{
name: "Same length timeline",
timeline: []model.Timestamp{
{Seconds: 1},
{Seconds: 2},
{Seconds: 3},
},
targetCount: 3,
expected: []model.Timestamp{
{Seconds: 1},
{Seconds: 2},
{Seconds: 3},
},
},
{
name: "Empty timeline",
timeline: []model.Timestamp{},
targetCount: 3,
expected: []model.Timestamp{},
},
{
name: "Zero target count",
timeline: []model.Timestamp{
{Seconds: 1},
{Seconds: 2},
},
targetCount: 0,
expected: []model.Timestamp{},
},
{
name: "Single item timeline",
timeline: []model.Timestamp{
{Seconds: 5},
},
targetCount: 3,
expected: []model.Timestamp{
{Seconds: 5},
{Seconds: 5},
{Seconds: 5},
},
},
{
name: "Scale up timeline",
timeline: []model.Timestamp{
{Seconds: 0},
{Seconds: 10},
},
targetCount: 5,
expected: []model.Timestamp{
{Seconds: 0},
{Seconds: 2, Milliseconds: 500},
{Seconds: 5},
{Seconds: 7, Milliseconds: 500},
{Seconds: 10},
},
},
{
name: "Scale down timeline",
timeline: []model.Timestamp{
{Seconds: 0},
{Seconds: 5},
{Seconds: 10},
{Seconds: 15},
{Seconds: 20},
},
targetCount: 3,
expected: []model.Timestamp{
{Seconds: 0},
{Seconds: 10},
{Seconds: 20},
},
},
{
name: "Target count 1",
timeline: []model.Timestamp{
{Seconds: 5},
{Seconds: 10},
{Seconds: 15},
},
targetCount: 1,
expected: []model.Timestamp{
{Seconds: 5},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := scaleTimeline(tc.timeline, tc.targetCount)
if len(result) != len(tc.expected) {
t.Errorf("Expected result length %d, got %d", len(tc.expected), len(result))
return
}
for i := range result {
// Allow 1ms difference due to floating point calculations
if abs(result[i].Hours - tc.expected[i].Hours) > 0 ||
abs(result[i].Minutes - tc.expected[i].Minutes) > 0 ||
abs(result[i].Seconds - tc.expected[i].Seconds) > 0 ||
abs(result[i].Milliseconds - tc.expected[i].Milliseconds) > 1 {
t.Errorf("At index %d: expected %+v, got %+v", i, tc.expected[i], result[i])
}
}
})
}
}
// Helper function for timestamp comparison
func abs(x int) int {
if x < 0 {
return -x
}
return x
}

104
internal/sync/vtt.go Normal file
View file

@ -0,0 +1,104 @@
package sync
import (
"fmt"
"sub-cli/internal/format/vtt"
"sub-cli/internal/model"
)
// 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)
}
// 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 subtitle is empty or target subtitle is empty, return copied target
if len(source.Entries) == 0 || len(target.Entries) == 0 {
// Ensure proper index numbering
for i := range result.Entries {
result.Entries[i].Index = i + 1
}
return result
}
// 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
}

342
internal/sync/vtt_test.go Normal file
View file

@ -0,0 +1,342 @@
package sync
import (
"testing"
"sub-cli/internal/model"
)
func TestSyncVTTTimeline(t *testing.T) {
testCases := []struct {
name string
source model.Subtitle
target model.Subtitle
verify func(t *testing.T, result model.Subtitle)
}{
{
name: "Equal entry counts",
source: model.Subtitle{
Format: "vtt",
Title: "Source VTT",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Text: "Source line two.",
},
{
Index: 3,
StartTime: model.Timestamp{Seconds: 9},
EndTime: model.Timestamp{Seconds: 12},
Text: "Source line three.",
},
},
},
target: model.Subtitle{
Format: "vtt",
Title: "Target VTT",
Styles: map[string]string{
"style1": ".style1 { color: red; }",
},
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Text: "Target line one.",
Styles: map[string]string{
"align": "start",
"position": "10%",
},
},
{
Index: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Text: "Target line two.",
Styles: map[string]string{
"align": "middle",
},
},
{
Index: 3,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Text: "Target line three.",
},
},
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(result.Entries))
return
}
// Check that source timings are applied to target entries
if result.Entries[0].StartTime.Seconds != 1 || result.Entries[0].EndTime.Seconds != 4 {
t.Errorf("First entry timing mismatch: got %+v", result.Entries[0])
}
if result.Entries[1].StartTime.Seconds != 5 || result.Entries[1].EndTime.Seconds != 8 {
t.Errorf("Second entry timing mismatch: got %+v", result.Entries[1])
}
if result.Entries[2].StartTime.Seconds != 9 || result.Entries[2].EndTime.Seconds != 12 {
t.Errorf("Third entry timing mismatch: got %+v", result.Entries[2])
}
// Check that target content is preserved
if result.Entries[0].Text != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Entries[0].Text)
}
// Check that styles are preserved
if result.Entries[0].Styles["align"] != "start" || result.Entries[0].Styles["position"] != "10%" {
t.Errorf("Styles should be preserved, got: %+v", result.Entries[0].Styles)
}
// Check that global styles are preserved
if result.Styles["style1"] != ".style1 { color: red; }" {
t.Errorf("Global styles should be preserved, got: %+v", result.Styles)
}
// Check that numbering is correct
if result.Entries[0].Index != 1 || result.Entries[1].Index != 2 || result.Entries[2].Index != 3 {
t.Errorf("Entry indices should be sequential: %+v", result.Entries)
}
},
},
{
name: "More target entries than source",
source: model.Subtitle{
Format: "vtt",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Text: "Source line two.",
},
},
},
target: model.Subtitle{
Format: "vtt",
Title: "Target VTT",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Text: "Target line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Text: "Target line two.",
},
{
Index: 3,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Text: "Target line three.",
},
},
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(result.Entries))
return
}
// First entry should use first source timing
if result.Entries[0].StartTime.Seconds != 1 {
t.Errorf("First entry should have first source timing, got: %+v", result.Entries[0].StartTime)
}
// Last entry should use last source timing
if result.Entries[2].StartTime.Seconds != 5 {
t.Errorf("Last entry should have last source timing, got: %+v", result.Entries[2].StartTime)
}
// Check that target content is preserved
if result.Entries[2].Text != "Target line three." {
t.Errorf("Content should be preserved, got: %s", result.Entries[2].Text)
}
// Check that title is preserved
if result.Title != "Target VTT" {
t.Errorf("Title should be preserved, got: %s", result.Title)
}
},
},
{
name: "More source entries than target",
source: model.Subtitle{
Format: "vtt",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 3},
Text: "Source line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Seconds: 4},
EndTime: model.Timestamp{Seconds: 6},
Text: "Source line two.",
},
{
Index: 3,
StartTime: model.Timestamp{Seconds: 7},
EndTime: model.Timestamp{Seconds: 9},
Text: "Source line three.",
},
{
Index: 4,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 12},
Text: "Source line four.",
},
},
},
target: model.Subtitle{
Format: "vtt",
Metadata: map[string]string{
"Region": "metadata region",
},
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Text: "Target line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Text: "Target line two.",
},
},
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(result.Entries))
return
}
// First entry should have first source timing
if result.Entries[0].StartTime.Seconds != 1 {
t.Errorf("First entry should have first source timing, got: %+v", result.Entries[0].StartTime)
}
// Last entry should have last source timing
if result.Entries[1].StartTime.Seconds != 10 {
t.Errorf("Last entry should have last source timing, got: %+v", result.Entries[1].StartTime)
}
// Check that metadata is preserved
if result.Metadata["Region"] != "metadata region" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
// Check that target content is preserved
if result.Entries[0].Text != "Target line one." || result.Entries[1].Text != "Target line two." {
t.Errorf("Content should be preserved, got: %+v", result.Entries)
}
},
},
{
name: "Empty target entries",
source: model.Subtitle{
Format: "vtt",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line one.",
},
},
},
target: model.Subtitle{
Format: "vtt",
Title: "Empty Target",
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 0 {
t.Errorf("Expected 0 entries, got %d", len(result.Entries))
}
// Title should be preserved
if result.Title != "Empty Target" {
t.Errorf("Title should be preserved, got: %s", result.Title)
}
},
},
{
name: "Empty source entries",
source: model.Subtitle{
Format: "vtt",
},
target: model.Subtitle{
Format: "vtt",
Title: "Target with content",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 15},
Text: "Target line one.",
},
},
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 1 {
t.Errorf("Expected 1 entry, got %d", len(result.Entries))
return
}
// Timing should be preserved since source is empty
if result.Entries[0].StartTime.Seconds != 10 || result.Entries[0].EndTime.Seconds != 15 {
t.Errorf("Timing should match target when source is empty, got: %+v", result.Entries[0])
}
// Content should be preserved
if result.Entries[0].Text != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Entries[0].Text)
}
// Title should be preserved
if result.Title != "Target with content" {
t.Errorf("Title should be preserved, got: %s", result.Title)
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := syncVTTTimeline(tc.source, tc.target)
if tc.verify != nil {
tc.verify(t, result)
}
})
}
}