From ebbf516689df427e790591dd049dc2b6b5c74670 Mon Sep 17 00:00:00 2001 From: cdn0x12 Date: Wed, 23 Apr 2025 17:42:13 +0800 Subject: [PATCH] feat: basic ass processing (without style) --- internal/config/constants.go | 7 +- internal/converter/converter.go | 5 + internal/format/ass/ass.go | 534 ++++++++++ internal/format/ass/ass_test.go | 529 ++++++++++ internal/formatter/formatter.go | 3 + internal/model/model.go | 67 ++ internal/model/model_test.go | 115 ++ internal/sync/sync.go | 84 +- internal/sync/sync_test.go | 1750 +++++++++++++++++-------------- internal/testdata/test.ass | 15 + 10 files changed, 2301 insertions(+), 808 deletions(-) create mode 100644 internal/format/ass/ass.go create mode 100644 internal/format/ass/ass_test.go create mode 100644 internal/testdata/test.ass diff --git a/internal/config/constants.go b/internal/config/constants.go index fc6cf95..d58f4ba 100644 --- a/internal/config/constants.go +++ b/internal/config/constants.go @@ -1,7 +1,7 @@ package config // Version stores the current application version -const Version = "0.5.1" +const Version = "0.5.2" // Usage stores the general usage information const Usage = `Usage: sub-cli [command] [options] @@ -17,6 +17,8 @@ const SyncUsage = `Usage: sub-cli sync Currently supports synchronizing between files of the same format: - LRC to LRC - SRT to SRT + - VTT to VTT + - ASS to ASS If source and target have different numbers of entries, a warning will be shown.` // ConvertUsage stores the usage information for the convert command @@ -26,4 +28,5 @@ const ConvertUsage = `Usage: sub-cli convert .txt Plain text format (No meta/timeline tags, only support as target format) .srt SubRip Subtitle format .lrc LRC format - .vtt WebVTT format` + .vtt WebVTT format + .ass Advanced SubStation Alpha format` diff --git a/internal/converter/converter.go b/internal/converter/converter.go index ae3cc9e..56a90bd 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "sub-cli/internal/format/ass" "sub-cli/internal/format/lrc" "sub-cli/internal/format/srt" "sub-cli/internal/format/txt" @@ -45,6 +46,8 @@ func convertToIntermediate(sourceFile, sourceFormat string) (model.Subtitle, err return srt.ConvertToSubtitle(sourceFile) case "vtt": return vtt.ConvertToSubtitle(sourceFile) + case "ass": + return ass.ConvertToSubtitle(sourceFile) default: return model.Subtitle{}, fmt.Errorf("%w: %s", ErrUnsupportedFormat, sourceFormat) } @@ -59,6 +62,8 @@ func convertFromIntermediate(subtitle model.Subtitle, targetFile, targetFormat s return srt.ConvertFromSubtitle(subtitle, targetFile) case "vtt": return vtt.ConvertFromSubtitle(subtitle, targetFile) + case "ass": + return ass.ConvertFromSubtitle(subtitle, targetFile) case "txt": return txt.GenerateFromSubtitle(subtitle, targetFile) default: diff --git a/internal/format/ass/ass.go b/internal/format/ass/ass.go new file mode 100644 index 0000000..a069b8a --- /dev/null +++ b/internal/format/ass/ass.go @@ -0,0 +1,534 @@ +package ass + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "sub-cli/internal/model" +) + +// 常量定义 +const ( + ASSHeader = "[Script Info]" + ASSStylesHeader = "[V4+ Styles]" + ASSEventsHeader = "[Events]" + DefaultFormat = "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text" +) + +// Parse 解析ASS文件为ASSFile结构 +func Parse(filePath string) (model.ASSFile, error) { + file, err := os.Open(filePath) + if err != nil { + return model.ASSFile{}, fmt.Errorf("打开ASS文件失败: %w", err) + } + defer file.Close() + + result := model.NewASSFile() + + scanner := bufio.NewScanner(file) + + // 当前解析的区块 + currentSection := "" + var styleFormat, eventFormat []string + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, ";") { + // 跳过空行和注释行 + continue + } + + // 检查章节标题 + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + currentSection = line + continue + } + + switch currentSection { + case ASSHeader: + // 解析脚本信息 + if strings.Contains(line, ":") { + parts := strings.SplitN(line, ":", 2) + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + result.ScriptInfo[key] = value + } + + case ASSStylesHeader: + // 解析样式格式行和样式定义 + if strings.HasPrefix(line, "Format:") { + formatStr := strings.TrimPrefix(line, "Format:") + styleFormat = parseFormatLine(formatStr) + } else if strings.HasPrefix(line, "Style:") { + styleValues := parseStyleLine(line) + if len(styleFormat) > 0 && len(styleValues) > 0 { + style := model.ASSStyle{ + Name: styleValues[0], // 第一个值通常是样式名称 + Properties: make(map[string]string), + } + + // 将原始格式行保存下来 + style.Properties["Format"] = "Name, " + strings.Join(styleFormat[1:], ", ") + style.Properties["Style"] = strings.Join(styleValues, ", ") + + // 解析各个样式属性 + for i := 0; i < len(styleFormat) && i < len(styleValues); i++ { + style.Properties[styleFormat[i]] = styleValues[i] + } + + result.Styles = append(result.Styles, style) + } + } + + case ASSEventsHeader: + // 解析事件格式行和对话行 + if strings.HasPrefix(line, "Format:") { + formatStr := strings.TrimPrefix(line, "Format:") + eventFormat = parseFormatLine(formatStr) + } else if len(eventFormat) > 0 && + (strings.HasPrefix(line, "Dialogue:") || + strings.HasPrefix(line, "Comment:")) { + + eventType := "Dialogue" + if strings.HasPrefix(line, "Comment:") { + eventType = "Comment" + line = strings.TrimPrefix(line, "Comment:") + } else { + line = strings.TrimPrefix(line, "Dialogue:") + } + + values := parseEventLine(line) + if len(values) >= len(eventFormat) { + event := model.NewASSEvent() + event.Type = eventType + + // 填充事件属性 + for i, format := range eventFormat { + value := values[i] + switch strings.TrimSpace(format) { + case "Layer": + layer, _ := strconv.Atoi(value) + event.Layer = layer + case "Start": + event.StartTime = parseASSTimestamp(value) + case "End": + event.EndTime = parseASSTimestamp(value) + case "Style": + event.Style = value + case "Name": + event.Name = value + case "MarginL": + marginL, _ := strconv.Atoi(value) + event.MarginL = marginL + case "MarginR": + marginR, _ := strconv.Atoi(value) + event.MarginR = marginR + case "MarginV": + marginV, _ := strconv.Atoi(value) + event.MarginV = marginV + case "Effect": + event.Effect = value + case "Text": + // 文本可能包含逗号,所以需要特殊处理 + textStartIndex := strings.Index(line, value) + if textStartIndex >= 0 { + event.Text = line[textStartIndex:] + } else { + event.Text = value + } + } + } + + result.Events = append(result.Events, event) + } + } + } + } + + if err := scanner.Err(); err != nil { + return model.ASSFile{}, fmt.Errorf("读取ASS文件失败: %w", err) + } + + return result, nil +} + +// Generate 生成ASS文件 +func Generate(assFile model.ASSFile, filePath string) error { + // 确保目录存在 + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("创建目录失败: %w", err) + } + + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("创建ASS文件失败: %w", err) + } + defer file.Close() + + writer := bufio.NewWriter(file) + + // 写入脚本信息 + writer.WriteString(ASSHeader + "\n") + for key, value := range assFile.ScriptInfo { + writer.WriteString(fmt.Sprintf("%s: %s\n", key, value)) + } + writer.WriteString("\n") + + // 写入样式信息 + writer.WriteString(ASSStylesHeader + "\n") + if len(assFile.Styles) > 0 { + // 获取样式格式 + format := "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding" + if style := assFile.Styles[0]; style.Properties["Format"] != "" { + format = "Format: " + style.Properties["Format"] + } + writer.WriteString(format + "\n") + + // 写入各个样式 + for _, style := range assFile.Styles { + if style.Properties["Style"] != "" { + writer.WriteString("Style: " + style.Properties["Style"] + "\n") + } else { + // 手动构造样式行 + writer.WriteString(fmt.Sprintf("Style: %s,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n", style.Name)) + } + } + } else { + // 写入默认样式 + writer.WriteString("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n") + writer.WriteString("Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n") + } + writer.WriteString("\n") + + // 写入事件信息 + writer.WriteString(ASSEventsHeader + "\n") + writer.WriteString(DefaultFormat + "\n") + + // 写入各个对话行 + for _, event := range assFile.Events { + startTime := formatASSTimestamp(event.StartTime) + endTime := formatASSTimestamp(event.EndTime) + + line := fmt.Sprintf("%s: %d,%s,%s,%s,%s,%d,%d,%d,%s,%s\n", + event.Type, + event.Layer, + startTime, + endTime, + event.Style, + event.Name, + event.MarginL, + event.MarginR, + event.MarginV, + event.Effect, + event.Text) + + writer.WriteString(line) + } + + return writer.Flush() +} + +// Format 格式化ASS文件 +func Format(filePath string) error { + // 解析文件 + assFile, err := Parse(filePath) + if err != nil { + return err + } + + // 重新生成文件 + return Generate(assFile, filePath) +} + +// ConvertToSubtitle 将ASS文件转换为通用字幕格式 +func ConvertToSubtitle(filePath string) (model.Subtitle, error) { + assFile, err := Parse(filePath) + if err != nil { + return model.Subtitle{}, err + } + + subtitle := model.NewSubtitle() + subtitle.Format = "ass" + + // 复制脚本信息到元数据 + for key, value := range assFile.ScriptInfo { + subtitle.Metadata[key] = value + } + + // 复制样式信息到FormatData + styleMap := make(map[string]model.ASSStyle) + for _, style := range assFile.Styles { + styleMap[style.Name] = style + } + subtitle.FormatData["styles"] = styleMap + + // 转换事件到字幕条目 + for i, event := range assFile.Events { + entry := model.NewSubtitleEntry() + entry.Index = i + 1 + entry.StartTime = event.StartTime + entry.EndTime = event.EndTime + entry.Text = event.Text + + // 保存ASS特定属性到FormatData + eventData := make(map[string]interface{}) + eventData["type"] = event.Type + eventData["layer"] = event.Layer + eventData["style"] = event.Style + eventData["name"] = event.Name + eventData["marginL"] = event.MarginL + eventData["marginR"] = event.MarginR + eventData["marginV"] = event.MarginV + eventData["effect"] = event.Effect + entry.FormatData["ass"] = eventData + + // 设置基本样式属性 + if style, ok := styleMap[event.Style]; ok { + if bold, exists := style.Properties["Bold"]; exists && bold == "1" { + entry.Styles["bold"] = "true" + } + if italic, exists := style.Properties["Italic"]; exists && italic == "1" { + entry.Styles["italic"] = "true" + } + if underline, exists := style.Properties["Underline"]; exists && underline == "1" { + entry.Styles["underline"] = "true" + } + } + + subtitle.Entries = append(subtitle.Entries, entry) + } + + return subtitle, nil +} + +// ConvertFromSubtitle 将通用字幕格式转换为ASS文件 +func ConvertFromSubtitle(subtitle model.Subtitle, filePath string) error { + assFile := model.NewASSFile() + + // 复制元数据到脚本信息 + for key, value := range subtitle.Metadata { + assFile.ScriptInfo[key] = value + } + + // 添加标题(如果有) + if subtitle.Title != "" { + assFile.ScriptInfo["Title"] = subtitle.Title + } + + // 从FormatData恢复样式(如果有) + if styles, ok := subtitle.FormatData["styles"].(map[string]model.ASSStyle); ok { + for _, style := range styles { + assFile.Styles = append(assFile.Styles, style) + } + } + + // 转换字幕条目到ASS事件 + for _, entry := range subtitle.Entries { + event := model.NewASSEvent() + event.StartTime = entry.StartTime + event.EndTime = entry.EndTime + event.Text = entry.Text + + // 从FormatData恢复ASS特定属性(如果有) + if assData, ok := entry.FormatData["ass"].(map[string]interface{}); ok { + if eventType, ok := assData["type"].(string); ok { + event.Type = eventType + } + if layer, ok := assData["layer"].(int); ok { + event.Layer = layer + } + if style, ok := assData["style"].(string); ok { + event.Style = style + } + if name, ok := assData["name"].(string); ok { + event.Name = name + } + if marginL, ok := assData["marginL"].(int); ok { + event.MarginL = marginL + } + if marginR, ok := assData["marginR"].(int); ok { + event.MarginR = marginR + } + if marginV, ok := assData["marginV"].(int); ok { + event.MarginV = marginV + } + if effect, ok := assData["effect"].(string); ok { + event.Effect = effect + } + } else { + // 根据基本样式设置ASS样式 + if _, ok := entry.Styles["bold"]; ok { + // 创建一个加粗样式(如果尚未存在) + styleName := "Bold" + found := false + for _, style := range assFile.Styles { + if style.Name == styleName { + found = true + break + } + } + + if !found { + boldStyle := model.ASSStyle{ + Name: styleName, + Properties: map[string]string{ + "Bold": "1", + }, + } + assFile.Styles = append(assFile.Styles, boldStyle) + } + + event.Style = styleName + } + + if _, ok := entry.Styles["italic"]; ok { + // 创建一个斜体样式(如果尚未存在) + styleName := "Italic" + found := false + for _, style := range assFile.Styles { + if style.Name == styleName { + found = true + break + } + } + + if !found { + italicStyle := model.ASSStyle{ + Name: styleName, + Properties: map[string]string{ + "Italic": "1", + }, + } + assFile.Styles = append(assFile.Styles, italicStyle) + } + + event.Style = styleName + } + + if _, ok := entry.Styles["underline"]; ok { + // 创建一个下划线样式(如果尚未存在) + styleName := "Underline" + found := false + for _, style := range assFile.Styles { + if style.Name == styleName { + found = true + break + } + } + + if !found { + underlineStyle := model.ASSStyle{ + Name: styleName, + Properties: map[string]string{ + "Underline": "1", + }, + } + assFile.Styles = append(assFile.Styles, underlineStyle) + } + + event.Style = styleName + } + } + + assFile.Events = append(assFile.Events, event) + } + + // 生成ASS文件 + return Generate(assFile, filePath) +} + +// 辅助函数 + +// parseFormatLine 解析格式行中的各个字段 +func parseFormatLine(formatStr string) []string { + fields := strings.Split(formatStr, ",") + result := make([]string, 0, len(fields)) + + for _, field := range fields { + result = append(result, strings.TrimSpace(field)) + } + + return result +} + +// parseStyleLine 解析样式行 +func parseStyleLine(line string) []string { + // 去掉"Style:"前缀 + styleStr := strings.TrimPrefix(line, "Style:") + return splitCSV(styleStr) +} + +// parseEventLine 解析事件行 +func parseEventLine(line string) []string { + return splitCSV(line) +} + +// splitCSV 拆分CSV格式的字符串,但保留Text字段中的逗号 +func splitCSV(line string) []string { + var result []string + inText := false + current := "" + + for _, char := range line { + if char == ',' && !inText { + result = append(result, strings.TrimSpace(current)) + current = "" + } else { + current += string(char) + // 这是个简化处理,实际ASS格式更复杂 + // 当处理到足够数量的字段后,剩余部分都当作Text字段 + if len(result) >= 9 { + inText = true + } + } + } + + if current != "" { + result = append(result, strings.TrimSpace(current)) + } + + return result +} + +// parseASSTimestamp 解析ASS格式的时间戳 (h:mm:ss.cc) +func parseASSTimestamp(timeStr string) model.Timestamp { + // 匹配 h:mm:ss.cc 格式 + re := regexp.MustCompile(`(\d+):(\d+):(\d+)\.(\d+)`) + matches := re.FindStringSubmatch(timeStr) + + if len(matches) == 5 { + hours, _ := strconv.Atoi(matches[1]) + minutes, _ := strconv.Atoi(matches[2]) + seconds, _ := strconv.Atoi(matches[3]) + // ASS使用厘秒(1/100秒),需要转换为毫秒 + centiseconds, _ := strconv.Atoi(matches[4]) + milliseconds := centiseconds * 10 + + return model.Timestamp{ + Hours: hours, + Minutes: minutes, + Seconds: seconds, + Milliseconds: milliseconds, + } + } + + // 返回零时间戳,如果解析失败 + return model.Timestamp{} +} + +// formatASSTimestamp 格式化为ASS格式的时间戳 (h:mm:ss.cc) +func formatASSTimestamp(timestamp model.Timestamp) string { + // ASS使用厘秒(1/100秒) + centiseconds := timestamp.Milliseconds / 10 + return fmt.Sprintf("%d:%02d:%02d.%02d", + timestamp.Hours, + timestamp.Minutes, + timestamp.Seconds, + centiseconds) +} diff --git a/internal/format/ass/ass_test.go b/internal/format/ass/ass_test.go new file mode 100644 index 0000000..9ad6a08 --- /dev/null +++ b/internal/format/ass/ass_test.go @@ -0,0 +1,529 @@ +package ass + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "sub-cli/internal/model" +) + +func TestParse(t *testing.T) { + // Create temporary test file + content := `[Script Info] +ScriptType: v4.00+ +Title: Test ASS File +PlayResX: 640 +PlayResY: 480 + +[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: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,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,,This is the first subtitle line. +Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,This is the second subtitle line with bold style. +Comment: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,This is a comment. +` + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "test.ass") + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Test parsing + assFile, err := Parse(testFile) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + // Verify results + // Script info + if assFile.ScriptInfo["Title"] != "Test ASS File" { + t.Errorf("Title mismatch: expected 'Test ASS File', got '%s'", assFile.ScriptInfo["Title"]) + } + if assFile.ScriptInfo["ScriptType"] != "v4.00+" { + t.Errorf("Script type mismatch: expected 'v4.00+', got '%s'", assFile.ScriptInfo["ScriptType"]) + } + + // Styles + if len(assFile.Styles) != 3 { + t.Errorf("Expected 3 styles, got %d", len(assFile.Styles)) + } else { + // Find Bold style + var boldStyle *model.ASSStyle + for i, style := range assFile.Styles { + if style.Name == "Bold" { + boldStyle = &assFile.Styles[i] + break + } + } + + if boldStyle == nil { + t.Errorf("Bold style not found") + } else { + boldValue, exists := boldStyle.Properties["Bold"] + if !exists || boldValue != "1" { + t.Errorf("Bold style Bold property mismatch: expected '1', got '%s'", boldValue) + } + } + } + + // Events + if len(assFile.Events) != 3 { + t.Errorf("Expected 3 events, got %d", len(assFile.Events)) + } else { + // Check first dialogue line + if assFile.Events[0].Type != "Dialogue" { + t.Errorf("First event type mismatch: expected 'Dialogue', got '%s'", assFile.Events[0].Type) + } + if assFile.Events[0].StartTime.Seconds != 1 || assFile.Events[0].StartTime.Milliseconds != 0 { + t.Errorf("First event start time mismatch: expected 00:00:01.00, got %d:%02d:%02d.%03d", + assFile.Events[0].StartTime.Hours, assFile.Events[0].StartTime.Minutes, + assFile.Events[0].StartTime.Seconds, assFile.Events[0].StartTime.Milliseconds) + } + if assFile.Events[0].Text != "This is the first subtitle line." { + t.Errorf("First event text mismatch: expected 'This is the first subtitle line.', got '%s'", assFile.Events[0].Text) + } + + // Check second dialogue line (bold style) + if assFile.Events[1].Style != "Bold" { + t.Errorf("Second event style mismatch: expected 'Bold', got '%s'", assFile.Events[1].Style) + } + + // Check comment line + if assFile.Events[2].Type != "Comment" { + t.Errorf("Third event type mismatch: expected 'Comment', got '%s'", assFile.Events[2].Type) + } + } +} + +func TestGenerate(t *testing.T) { + // Create test ASS file structure + assFile := model.NewASSFile() + assFile.ScriptInfo["Title"] = "Generation Test" + + // Add a custom style + boldStyle := model.ASSStyle{ + Name: "Bold", + Properties: map[string]string{ + "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": "Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1", + "Bold": "1", + }, + } + assFile.Styles = append(assFile.Styles, boldStyle) + + // Add two dialogue events + event1 := model.NewASSEvent() + event1.StartTime = model.Timestamp{Seconds: 1} + event1.EndTime = model.Timestamp{Seconds: 4} + event1.Text = "This is the first line." + + event2 := model.NewASSEvent() + event2.StartTime = model.Timestamp{Seconds: 5} + event2.EndTime = model.Timestamp{Seconds: 8} + event2.Style = "Bold" + event2.Text = "This is the second line with bold style." + + assFile.Events = append(assFile.Events, event1, event2) + + // Generate ASS file + tempDir := t.TempDir() + outputFile := filepath.Join(tempDir, "output.ass") + err := Generate(assFile, outputFile) + if err != nil { + t.Fatalf("Generation failed: %v", err) + } + + // Verify generated content + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + contentStr := string(content) + + // Check script info + if !strings.Contains(contentStr, "Title: Generation Test") { + t.Errorf("Output file should contain title 'Title: Generation Test'") + } + + // Check styles + if !strings.Contains(contentStr, "Style: Bold,Arial,20") { + t.Errorf("Output file should contain Bold style") + } + + // Check dialogue lines + if !strings.Contains(contentStr, "Dialogue: 0,0:00:01.00,0:00:04.00,Default") { + t.Errorf("Output file should contain first dialogue line") + } + if !strings.Contains(contentStr, "This is the first line.") { + t.Errorf("Output file should contain first line text") + } + + if !strings.Contains(contentStr, "Dialogue: 0,0:00:05.00,0:00:08.00,Bold") { + t.Errorf("Output file should contain second dialogue line") + } + if !strings.Contains(contentStr, "This is the second line with bold style.") { + t.Errorf("Output file should contain second line text") + } +} + +func TestFormat(t *testing.T) { + // Create test file (intentionally with mixed formatting) + content := `[Script Info] +ScriptType:v4.00+ + Title: Formatting Test + +[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:0:1.0,0:0:4.0,Default,,0,0,0,,Text before formatting. +` + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "format_test.ass") + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Test formatting + err := Format(testFile) + if err != nil { + t.Fatalf("Formatting failed: %v", err) + } + + // Verify formatted file + formattedContent, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read formatted file: %v", err) + } + + formattedStr := string(formattedContent) + + // Check formatting + if !strings.Contains(formattedStr, "ScriptType: v4.00+") { + t.Errorf("Formatted file should contain standardized ScriptType line") + } + + if !strings.Contains(formattedStr, "Title: Formatting Test") { + t.Errorf("Formatted file should contain standardized Title line") + } + + // Check timestamp formatting + if !strings.Contains(formattedStr, "0:00:01.00,0:00:04.00") { + t.Errorf("Formatted file should contain standardized timestamp format (0:00:01.00,0:00:04.00)") + } +} + +func TestConvertToSubtitle(t *testing.T) { + // Create test file + content := `[Script Info] +ScriptType: v4.00+ +Title: Conversion Test + +[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: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1 +Style: Italic,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,1,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,,Normal text. +Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,Bold text. +Dialogue: 0,0:00:09.00,0:00:12.00,Italic,,0,0,0,,Italic text. +` + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "convert_test.ass") + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Convert to subtitle + subtitle, err := ConvertToSubtitle(testFile) + if err != nil { + t.Fatalf("Conversion failed: %v", err) + } + + // Verify results + if subtitle.Format != "ass" { + t.Errorf("Expected format 'ass', got '%s'", subtitle.Format) + } + + if subtitle.Metadata["Title"] != "Conversion Test" { + t.Errorf("Expected title 'Conversion Test', got '%s'", subtitle.Metadata["Title"]) + } + + if len(subtitle.Entries) != 3 { + t.Errorf("Expected 3 entries, got %d", len(subtitle.Entries)) + } else { + // Check first entry + if subtitle.Entries[0].Text != "Normal text." { + t.Errorf("First entry text mismatch: expected 'Normal text.', got '%s'", subtitle.Entries[0].Text) + } + + // Check second entry (bold) + if subtitle.Entries[1].Text != "Bold text." { + t.Errorf("Second entry text mismatch: expected 'Bold text.', got '%s'", subtitle.Entries[1].Text) + } + bold, ok := subtitle.Entries[1].Styles["bold"] + if !ok || bold != "true" { + t.Errorf("Second entry should have bold=true style") + } + + // Check third entry (italic) + if subtitle.Entries[2].Text != "Italic text." { + t.Errorf("Third entry text mismatch: expected 'Italic text.', got '%s'", subtitle.Entries[2].Text) + } + italic, ok := subtitle.Entries[2].Styles["italic"] + if !ok || italic != "true" { + t.Errorf("Third entry should have italic=true style") + } + } +} + +func TestConvertFromSubtitle(t *testing.T) { + // Create test subtitle + subtitle := model.NewSubtitle() + subtitle.Format = "ass" + subtitle.Title = "Conversion from Subtitle Test" + + // Create a normal entry + entry1 := model.NewSubtitleEntry() + entry1.Index = 1 + entry1.StartTime = model.Timestamp{Seconds: 1} + entry1.EndTime = model.Timestamp{Seconds: 4} + entry1.Text = "Normal text." + + // Create a bold entry + entry2 := model.NewSubtitleEntry() + entry2.Index = 2 + entry2.StartTime = model.Timestamp{Seconds: 5} + entry2.EndTime = model.Timestamp{Seconds: 8} + entry2.Text = "Bold text." + entry2.Styles["bold"] = "true" + + // Create an italic entry + entry3 := model.NewSubtitleEntry() + entry3.Index = 3 + entry3.StartTime = model.Timestamp{Seconds: 9} + entry3.EndTime = model.Timestamp{Seconds: 12} + entry3.Text = "Italic text." + entry3.Styles["italic"] = "true" + + subtitle.Entries = append(subtitle.Entries, entry1, entry2, entry3) + + // Convert from subtitle + tempDir := t.TempDir() + outputFile := filepath.Join(tempDir, "convert_from_subtitle.ass") + err := ConvertFromSubtitle(subtitle, outputFile) + if err != nil { + t.Fatalf("Conversion failed: %v", err) + } + + // Verify converted ASS file + assFile, err := Parse(outputFile) + if err != nil { + t.Fatalf("Failed to parse converted file: %v", err) + } + + // Check script info + if assFile.ScriptInfo["Title"] != "Conversion from Subtitle Test" { + t.Errorf("Expected title 'Conversion from Subtitle Test', got '%s'", assFile.ScriptInfo["Title"]) + } + + // Check events + if len(assFile.Events) != 3 { + t.Errorf("Expected 3 events, got %d", len(assFile.Events)) + } else { + // Check first dialogue line + if assFile.Events[0].Text != "Normal text." { + t.Errorf("First event text mismatch: expected 'Normal text.', got '%s'", assFile.Events[0].Text) + } + + // Check second dialogue line (bold) + if assFile.Events[1].Text != "Bold text." { + t.Errorf("Second event text mismatch: expected 'Bold text.', got '%s'", assFile.Events[1].Text) + } + if assFile.Events[1].Style != "Bold" { + t.Errorf("Second event should use Bold style, got '%s'", assFile.Events[1].Style) + } + + // Check third dialogue line (italic) + if assFile.Events[2].Text != "Italic text." { + t.Errorf("Third event text mismatch: expected 'Italic text.', got '%s'", assFile.Events[2].Text) + } + if assFile.Events[2].Style != "Italic" { + t.Errorf("Third event should use Italic style, got '%s'", assFile.Events[2].Style) + } + } + + // Check styles + styleNames := make(map[string]bool) + for _, style := range assFile.Styles { + styleNames[style.Name] = true + } + + if !styleNames["Bold"] { + t.Errorf("Should contain Bold style") + } + if !styleNames["Italic"] { + t.Errorf("Should contain Italic style") + } +} + +func TestParse_EdgeCases(t *testing.T) { + // Test empty file + tempDir := t.TempDir() + emptyFile := filepath.Join(tempDir, "empty.ass") + if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil { + t.Fatalf("Failed to create empty test file: %v", err) + } + + assFile, err := Parse(emptyFile) + if err != nil { + t.Fatalf("Failed to parse empty file: %v", err) + } + + if len(assFile.Events) != 0 { + t.Errorf("Empty file should have 0 events, got %d", len(assFile.Events)) + } + + // Test file missing required sections + malformedContent := `[Script Info] +Title: Missing Sections Test +` + malformedFile := filepath.Join(tempDir, "malformed.ass") + if err := os.WriteFile(malformedFile, []byte(malformedContent), 0644); err != nil { + t.Fatalf("Failed to create malformed file: %v", err) + } + + assFile, err = Parse(malformedFile) + if err != nil { + t.Fatalf("Failed to parse malformed file: %v", err) + } + + if assFile.ScriptInfo["Title"] != "Missing Sections Test" { + t.Errorf("Should correctly parse the title") + } + if len(assFile.Events) != 0 { + t.Errorf("File missing Events section should have 0 events") + } +} + +func TestParse_FileError(t *testing.T) { + // Test non-existent file + _, err := Parse("/nonexistent/file.ass") + if err == nil { + t.Error("Parsing non-existent file should return an error") + } +} + +func TestGenerate_FileError(t *testing.T) { + // Test invalid path + assFile := model.NewASSFile() + err := Generate(assFile, "/nonexistent/directory/file.ass") + if err == nil { + t.Error("Generating to invalid path should return an error") + } +} + +func TestConvertToSubtitle_FileError(t *testing.T) { + // Test non-existent file + _, err := ConvertToSubtitle("/nonexistent/file.ass") + if err == nil { + t.Error("Converting non-existent file should return an error") + } +} + +func TestConvertFromSubtitle_FileError(t *testing.T) { + // Test invalid path + subtitle := model.NewSubtitle() + err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.ass") + if err == nil { + t.Error("Converting to invalid path should return an error") + } +} + +func TestParseASSTimestamp(t *testing.T) { + testCases := []struct { + name string + input string + expected model.Timestamp + }{ + { + name: "Standard format", + input: "0:00:01.00", + expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, + }, + { + name: "With centiseconds", + input: "0:00:01.50", + expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500}, + }, + { + name: "Complete hours, minutes, seconds", + input: "1:02:03.45", + expected: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450}, + }, + { + name: "Invalid format", + input: "invalid", + expected: model.Timestamp{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := parseASSTimestamp(tc.input) + if result != tc.expected { + t.Errorf("For input '%s', expected %+v, got %+v", tc.input, tc.expected, result) + } + }) + } +} + +func TestFormatASSTimestamp(t *testing.T) { + testCases := []struct { + name string + input model.Timestamp + expected string + }{ + { + name: "Zero timestamp", + input: model.Timestamp{}, + expected: "0:00:00.00", + }, + { + name: "Simple seconds", + input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, + expected: "0:00:01.00", + }, + { + name: "With milliseconds", + input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500}, + expected: "0:00:01.50", + }, + { + name: "Complete timestamp", + input: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450}, + expected: "1:02:03.45", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := formatASSTimestamp(tc.input) + if result != tc.expected { + t.Errorf("For timestamp %+v, expected '%s', got '%s'", tc.input, tc.expected, result) + } + }) + } +} diff --git a/internal/formatter/formatter.go b/internal/formatter/formatter.go index eb76fb1..822ca96 100644 --- a/internal/formatter/formatter.go +++ b/internal/formatter/formatter.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" + "sub-cli/internal/format/ass" "sub-cli/internal/format/lrc" "sub-cli/internal/format/srt" "sub-cli/internal/format/vtt" @@ -21,6 +22,8 @@ func Format(filePath string) error { return srt.Format(filePath) case "vtt": return vtt.Format(filePath) + case "ass": + return ass.Format(filePath) default: return fmt.Errorf("unsupported format for formatting: %s", ext) } diff --git a/internal/model/model.go b/internal/model/model.go index 8b1c6c9..c8905e9 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -53,6 +53,34 @@ type SubtitleRegion struct { Settings map[string]string } +// ASSEvent represents an event entry in an ASS file (dialogue, comment, etc.) +type ASSEvent struct { + Type string // Dialogue, Comment, etc. + Layer int // Layer number (0-based) + StartTime Timestamp // Start time + EndTime Timestamp // End time + Style string // Style name + Name string // Character name + MarginL int // Left margin override + MarginR int // Right margin override + MarginV int // Vertical margin override + Effect string // Transition effect + Text string // The actual text +} + +// ASSStyle represents a style definition in an ASS file +type ASSStyle struct { + Name string // Style name + Properties map[string]string // Font name, size, colors, etc. +} + +// ASSFile represents an Advanced SubStation Alpha (ASS) file +type ASSFile struct { + ScriptInfo map[string]string // Format, Title, ScriptType, etc. + Styles []ASSStyle // Style definitions + Events []ASSEvent // Dialogue lines +} + // Creates a new empty Subtitle func NewSubtitle() Subtitle { return Subtitle{ @@ -82,3 +110,42 @@ func NewSubtitleRegion(id string) SubtitleRegion { Settings: make(map[string]string), } } + +// NewASSFile creates a new empty ASS file structure with minimal defaults +func NewASSFile() ASSFile { + // Create minimal defaults for a valid ASS file + scriptInfo := map[string]string{ + "ScriptType": "v4.00+", + "Collisions": "Normal", + "PlayResX": "640", + "PlayResY": "480", + "Timer": "100.0000", + } + + // Create a default style + defaultStyle := ASSStyle{ + Name: "Default", + Properties: map[string]string{ + "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", + }, + } + + return ASSFile{ + ScriptInfo: scriptInfo, + Styles: []ASSStyle{defaultStyle}, + Events: []ASSEvent{}, + } +} + +// NewASSEvent creates a new ASS event with default values +func NewASSEvent() ASSEvent { + return ASSEvent{ + Type: "Dialogue", + Layer: 0, + Style: "Default", + MarginL: 0, + MarginR: 0, + MarginV: 0, + } +} diff --git a/internal/model/model_test.go b/internal/model/model_test.go index c10d8a2..5208225 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -2,6 +2,7 @@ package model import ( "testing" + "strings" ) func TestNewSubtitle(t *testing.T) { @@ -98,3 +99,117 @@ func TestNewSubtitleRegion(t *testing.T) { t.Errorf("Expected settings to contain lines=3, got %s", val) } } + +func TestNewASSFile(t *testing.T) { + assFile := NewASSFile() + + // Test that script info is initialized with defaults + if assFile.ScriptInfo == nil { + t.Error("Expected ScriptInfo map to be initialized") + } + + // Check default script info values + expectedDefaults := map[string]string{ + "ScriptType": "v4.00+", + "Collisions": "Normal", + "PlayResX": "640", + "PlayResY": "480", + "Timer": "100.0000", + } + + for key, expectedValue := range expectedDefaults { + if value, exists := assFile.ScriptInfo[key]; !exists || value != expectedValue { + t.Errorf("Expected default ScriptInfo[%s] = %s, got %s", key, expectedValue, value) + } + } + + // Test that styles are initialized + if assFile.Styles == nil { + t.Error("Expected Styles slice to be initialized") + } + + // Test that at least the Default style exists + if len(assFile.Styles) < 1 { + t.Error("Expected at least Default style to be created") + } else { + defaultStyleFound := false + for _, style := range assFile.Styles { + if style.Name == "Default" { + defaultStyleFound = true + + // Check the style properties of the default style + styleStr, exists := style.Properties["Style"] + if !exists { + t.Error("Expected Default style to have a Style property, but it wasn't found") + } else if !strings.Contains(styleStr, ",0,0,0,0,") { // Check that Bold, Italic, Underline, StrikeOut are all 0 + t.Errorf("Expected Default style to have Bold/Italic/Underline/StrikeOut set to 0, got: %s", styleStr) + } + + break + } + } + + if !defaultStyleFound { + t.Error("Expected to find a Default style") + } + } + + // Test that events are initialized as an empty slice + if assFile.Events == nil { + t.Error("Expected Events slice to be initialized") + } + + if len(assFile.Events) != 0 { + t.Errorf("Expected 0 events, got %d", len(assFile.Events)) + } +} + +func TestNewASSEvent(t *testing.T) { + event := NewASSEvent() + + // Test default type + if event.Type != "Dialogue" { + t.Errorf("Expected Type to be 'Dialogue', got '%s'", event.Type) + } + + // Test default layer + if event.Layer != 0 { + t.Errorf("Expected Layer to be 0, got %d", event.Layer) + } + + // Test default style + if event.Style != "Default" { + t.Errorf("Expected Style to be 'Default', got '%s'", event.Style) + } + + // Test default name + if event.Name != "" { + t.Errorf("Expected Name to be empty, got '%s'", event.Name) + } + + // Test default margins + if event.MarginL != 0 || event.MarginR != 0 || event.MarginV != 0 { + t.Errorf("Expected all margins to be 0, got L:%d, R:%d, V:%d", + event.MarginL, event.MarginR, event.MarginV) + } + + // Test default effect + if event.Effect != "" { + t.Errorf("Expected Effect to be empty, got '%s'", event.Effect) + } + + // Test default text + if event.Text != "" { + t.Errorf("Expected Text to be empty, got '%s'", event.Text) + } + + // Test start and end times + zeroTime := Timestamp{} + if event.StartTime != zeroTime { + t.Errorf("Expected start time to be zero, got %+v", event.StartTime) + } + + if event.EndTime != zeroTime { + t.Errorf("Expected end time to be zero, got %+v", event.EndTime) + } +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 32385e1..066551d 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" + "sub-cli/internal/format/ass" "sub-cli/internal/format/lrc" "sub-cli/internal/format/srt" "sub-cli/internal/format/vtt" @@ -23,8 +24,10 @@ func SyncLyrics(sourceFile, targetFile string) error { return syncSRTFiles(sourceFile, targetFile) } else if sourceFmt == "vtt" && targetFmt == "vtt" { return syncVTTFiles(sourceFile, targetFile) + } else if sourceFmt == "ass" && targetFmt == "ass" { + return syncASSFiles(sourceFile, targetFile) } else { - return fmt.Errorf("sync only supports files of the same format (lrc-to-lrc, srt-to-srt, or vtt-to-vtt)") + return fmt.Errorf("sync only supports files of the same format (lrc-to-lrc, srt-to-srt, vtt-to-vtt, or ass-to-ass)") } } @@ -103,6 +106,31 @@ func syncVTTFiles(sourceFile, targetFile string) error { 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{ @@ -131,6 +159,15 @@ func syncSRTTimeline(sourceEntries, targetEntries []model.SRTEntry) []model.SRTE // 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 { @@ -253,6 +290,51 @@ func syncVTTTimeline(source, target model.Subtitle) model.Subtitle { 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 { diff --git a/internal/sync/sync_test.go b/internal/sync/sync_test.go index ed046f3..d3b33db 100644 --- a/internal/sync/sync_test.go +++ b/internal/sync/sync_test.go @@ -185,56 +185,42 @@ This is target line three. }, }, { - name: "LRC to SRT sync", - sourceContent: `[00:01.00]This is line one. -[00:05.00]This is line two. + name: "ASS to ASS sync", + sourceContent: `[Script Info] +ScriptType: v4.00+ +PlayResX: 640 +PlayResY: 480 +Title: Source ASS + +[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. `, - sourceExt: "lrc", - targetContent: `1 -00:01:00,000 --> 00:01:03,000 -This is target line one. + sourceExt: "ass", + targetContent: `[Script Info] +ScriptType: v4.00+ +PlayResX: 640 +PlayResY: 480 +Title: Target ASS -2 -00:01:05,000 --> 00:01:08,000 -This is target line two. +[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:01:00.00,0:01:03.00,Default,,0,0,0,,Target line one. +Dialogue: 0,0:01:05.00,0:01:08.00,Default,,0,0,0,,Target line two. +Dialogue: 0,0:01:10.00,0:01:13.00,Default,,0,0,0,,Target line three. `, - targetExt: "srt", - expectedError: true, // Different formats should cause an error - validateOutput: nil, - }, - { - name: "Mismatched entry counts", - sourceContent: `WEBVTT - -1 -00:00:01.000 --> 00:00:04.000 -This is line one. - -2 -00:00:05.000 --> 00:00:08.000 -This is line two. -`, - sourceExt: "vtt", - targetContent: `WEBVTT - -1 -00:01:00.000 --> 00:01:03.000 -This is target line one. - -2 -00:01:05.000 --> 00:01:08.000 -This is target line two. - -3 -00:01:10.000 --> 00:01:13.000 -This is target line three. - -4 -00:01:15.000 --> 00:01:18.000 -This is target line four. -`, - targetExt: "vtt", - expectedError: false, // Mismatched counts should be handled, not error + targetExt: "ass", + expectedError: false, validateOutput: func(t *testing.T, filePath string) { content, err := os.ReadFile(filePath) if err != nil { @@ -243,28 +229,22 @@ This is target line four. contentStr := string(content) - // Should have interpolated timings for all 4 entries - lines := strings.Split(contentStr, "\n") - cueCount := 0 - for _, line := range lines { - if strings.Contains(line, " --> ") { - cueCount++ - } + // Should preserve script info from target + if !strings.Contains(contentStr, "Title: Target ASS") { + t.Errorf("Output should preserve target title, got: %s", contentStr) } - if cueCount != 4 { - t.Errorf("Expected 4 cues in output, got %d", cueCount) + + // Should have source timings but target content + if !strings.Contains(contentStr, "0:00:01.00,0:00:04.00") { + t.Errorf("Output should have source timing 0:00:01.00, got: %s", contentStr) + } + + // Check target content is preserved + if !strings.Contains(contentStr, "Target line one.") { + t.Errorf("Output should preserve target content, got: %s", contentStr) } }, }, - { - name: "Unsupported format", - sourceContent: `Some random content`, - sourceExt: "txt", - targetContent: `[00:01.00]This is line one.`, - targetExt: "lrc", - expectedError: true, - validateOutput: nil, - }, } // Run test cases @@ -304,6 +284,854 @@ This is target line four. } }) } + + // Test unsupported format + t.Run("Unsupported format", func(t *testing.T) { + sourceFile := filepath.Join(tempDir, "source.unknown") + targetFile := filepath.Join(tempDir, "target.unknown") + + // Create source and target files + sourceContent := "Some content in unknown format" + if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil { + t.Fatalf("Failed to create source file: %v", err) + } + + targetContent := "Some target content in unknown format" + if err := os.WriteFile(targetFile, []byte(targetContent), 0644); err != nil { + t.Fatalf("Failed to create target file: %v", err) + } + + // Call SyncLyrics, expect error + if err := SyncLyrics(sourceFile, targetFile); err == nil { + t.Errorf("Expected error for unsupported format, but got none") + } + }) +} + +func TestSyncASSTimeline(t *testing.T) { + t.Run("Equal number of events", func(t *testing.T) { + // Create source ASS file + source := model.ASSFile{ + ScriptInfo: map[string]string{ + "Title": "Source ASS", + "ScriptType": "v4.00+", + }, + Styles: []model.ASSStyle{ + { + Name: "Default", + Properties: map[string]string{ + "Bold": "0", + }, + }, + }, + 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.", + }, + }, + } + + // Create target ASS file + target := model.ASSFile{ + ScriptInfo: map[string]string{ + "Title": "Target ASS", + "ScriptType": "v4.00+", + }, + Styles: []model.ASSStyle{ + { + Name: "Default", + Properties: map[string]string{ + "Bold": "0", + }, + }, + }, + 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.", + }, + }, + } + + // Sync the timelines + result := syncASSTimeline(source, target) + + // Check that the result has the correct number of events + if len(result.Events) != 2 { + t.Errorf("Expected 2 events, got %d", len(result.Events)) + } + + // Check that the script info was preserved from the target + if result.ScriptInfo["Title"] != "Target ASS" { + t.Errorf("Expected title 'Target ASS', got '%s'", result.ScriptInfo["Title"]) + } + + // Check that the first event has the source timing but target text + if result.Events[0].StartTime.Seconds != 1 { + t.Errorf("Expected start time 1 second, got %d", result.Events[0].StartTime.Seconds) + } + if result.Events[0].Text != "Target line one." { + t.Errorf("Expected text 'Target line one.', got '%s'", result.Events[0].Text) + } + + // Check that the second event has the source timing but target text + if result.Events[1].StartTime.Seconds != 5 { + t.Errorf("Expected start time 5 seconds, got %d", result.Events[1].StartTime.Seconds) + } + if result.Events[1].Text != "Target line two." { + t.Errorf("Expected text 'Target line two.', got '%s'", result.Events[1].Text) + } + }) + + t.Run("Different number of events", func(t *testing.T) { + // Create source ASS file with 3 events + source := model.ASSFile{ + ScriptInfo: map[string]string{ + "Title": "Source ASS", + "ScriptType": "v4.00+", + }, + Events: []model.ASSEvent{ + { + Type: "Dialogue", + Layer: 0, + StartTime: model.Timestamp{Seconds: 1}, + EndTime: model.Timestamp{Seconds: 4}, + Text: "Source line one.", + }, + { + Type: "Dialogue", + Layer: 0, + StartTime: model.Timestamp{Seconds: 5}, + EndTime: model.Timestamp{Seconds: 8}, + Text: "Source line two.", + }, + { + Type: "Dialogue", + Layer: 0, + StartTime: model.Timestamp{Seconds: 9}, + EndTime: model.Timestamp{Seconds: 12}, + Text: "Source line three.", + }, + }, + } + + // Create target ASS file with 2 events + target := model.ASSFile{ + ScriptInfo: map[string]string{ + "Title": "Target ASS", + "ScriptType": "v4.00+", + }, + Events: []model.ASSEvent{ + { + Type: "Dialogue", + Layer: 0, + StartTime: model.Timestamp{Minutes: 1, Seconds: 0}, + EndTime: model.Timestamp{Minutes: 1, Seconds: 3}, + Text: "Target line one.", + }, + { + Type: "Dialogue", + Layer: 0, + StartTime: model.Timestamp{Minutes: 1, Seconds: 5}, + EndTime: model.Timestamp{Minutes: 1, Seconds: 8}, + Text: "Target line two.", + }, + }, + } + + // Sync the timelines + result := syncASSTimeline(source, target) + + // Check that the result has the correct number of events + if len(result.Events) != 2 { + t.Errorf("Expected 2 events, got %d", len(result.Events)) + } + + // Timeline should be scaled + if result.Events[0].StartTime.Seconds != 1 { + t.Errorf("Expected first event start time 1 second, got %d", result.Events[0].StartTime.Seconds) + } + + // With 3 source events and 2 target events, the second event should get timing from the third source event + if result.Events[1].StartTime.Seconds != 9 { + t.Errorf("Expected second event start time 9 seconds, got %d", result.Events[1].StartTime.Seconds) + } + }) + + t.Run("Empty events", func(t *testing.T) { + // Create source and target with empty events + source := model.ASSFile{ + ScriptInfo: map[string]string{"Title": "Source ASS"}, + Events: []model.ASSEvent{}, + } + + target := model.ASSFile{ + ScriptInfo: map[string]string{"Title": "Target ASS"}, + Events: []model.ASSEvent{}, + } + + // Sync the timelines + result := syncASSTimeline(source, target) + + // Check that the result has empty events + if len(result.Events) != 0 { + t.Errorf("Expected 0 events, got %d", len(result.Events)) + } + + // Check that the script info was preserved + if result.ScriptInfo["Title"] != "Target ASS" { + t.Errorf("Expected title 'Target ASS', got '%s'", result.ScriptInfo["Title"]) + } + }) + + t.Run("Source has events, target is empty", func(t *testing.T) { + // Create source with events + source := model.ASSFile{ + ScriptInfo: map[string]string{"Title": "Source ASS"}, + Events: []model.ASSEvent{ + { + Type: "Dialogue", + StartTime: model.Timestamp{Seconds: 1}, + EndTime: model.Timestamp{Seconds: 4}, + Text: "Source line.", + }, + }, + } + + // Create target with no events + target := model.ASSFile{ + ScriptInfo: map[string]string{"Title": "Target ASS"}, + Events: []model.ASSEvent{}, + } + + // Sync the timelines + result := syncASSTimeline(source, target) + + // Result should have no events + if len(result.Events) != 0 { + t.Errorf("Expected 0 events, got %d", len(result.Events)) + } + }) +} + +func TestSyncASSFiles(t *testing.T) { + tempDir := t.TempDir() + + t.Run("Sync ASS files", func(t *testing.T) { + // Create source ASS file + sourceFile := filepath.Join(tempDir, "source.ass") + sourceContent := `[Script Info] +ScriptType: v4.00+ +PlayResX: 640 +PlayResY: 480 +Title: Source ASS + +[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. +` + if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil { + t.Fatalf("Failed to create source file: %v", err) + } + + // Create target ASS file + targetFile := filepath.Join(tempDir, "target.ass") + targetContent := `[Script Info] +ScriptType: v4.00+ +PlayResX: 640 +PlayResY: 480 +Title: Target ASS + +[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:01:00.00,0:01:03.00,Default,,0,0,0,,Target line one. +Dialogue: 0,0:01:05.00,0:01:08.00,Default,,0,0,0,,Target line two. +Dialogue: 0,0:01:10.00,0:01:13.00,Default,,0,0,0,,Target line three. +` + if err := os.WriteFile(targetFile, []byte(targetContent), 0644); err != nil { + t.Fatalf("Failed to create target file: %v", err) + } + + // Sync the files + err := syncASSFiles(sourceFile, targetFile) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // Check that the target file exists + if _, err := os.Stat(targetFile); os.IsNotExist(err) { + t.Errorf("Target file no longer exists: %v", err) + } + + // Check the contents of the target file + outputContent, err := os.ReadFile(targetFile) + if err != nil { + t.Fatalf("Failed to read target file: %v", err) + } + + outputContentStr := string(outputContent) + + // Should preserve script info from target + if !strings.Contains(outputContentStr, "Title: Target ASS") { + t.Errorf("Output should preserve target title, got: %s", outputContentStr) + } + + // Should have source timings but target content + if !strings.Contains(outputContentStr, "0:00:01.00,0:00:04.00") { + t.Errorf("Output should have source timing 0:00:01.00, got: %s", outputContentStr) + } + + // Should have target content + if !strings.Contains(outputContentStr, "Target line one.") { + t.Errorf("Output should preserve target content, got: %s", outputContentStr) + } + }) +} + +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 count", + source: func() model.Subtitle { + sub := model.NewSubtitle() + sub.Format = "vtt" + sub.Title = "Source Title" + sub.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.", + }, + } + return sub + }(), + target: func() model.Subtitle { + sub := model.NewSubtitle() + sub.Format = "vtt" + sub.Title = "Target Title" + sub.Metadata = map[string]string{"WEBVTT": "Some Styles"} + sub.Entries = []model.SubtitleEntry{ + { + Index: 1, + StartTime: model.Timestamp{Minutes: 1}, + 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"}, + }, + } + return sub + }(), + verify: func(t *testing.T, result model.Subtitle) { + if result.Format != "vtt" { + t.Errorf("Expected format 'vtt', got '%s'", result.Format) + } + + if result.Title != "Target Title" { + t.Errorf("Expected title 'Target Title', got '%s'", result.Title) + } + + if len(result.Metadata) == 0 || result.Metadata["WEBVTT"] != "Some Styles" { + t.Errorf("Expected to preserve metadata, got %v", result.Metadata) + } + + if len(result.Entries) != 2 { + t.Errorf("Expected 2 entries, got %d", len(result.Entries)) + return + } + + // Check that first entry has source timing + if result.Entries[0].StartTime.Seconds != 1 || result.Entries[0].EndTime.Seconds != 4 { + t.Errorf("First entry timing incorrect, got start: %+v, end: %+v", + result.Entries[0].StartTime, result.Entries[0].EndTime) + } + + // Check that styles are preserved + if result.Entries[0].Styles["align"] != "start" || result.Entries[0].Styles["position"] != "10%" { + t.Errorf("Expected to preserve styles, got %v", result.Entries[0].Styles) + } + + // Check text is preserved + if result.Entries[0].Text != "Target line one." { + t.Errorf("Expected target text, got '%s'", result.Entries[0].Text) + } + + // Check indexes are sequential + if result.Entries[0].Index != 1 || result.Entries[1].Index != 2 { + t.Errorf("Expected sequential indexes 1, 2, got %d, %d", + result.Entries[0].Index, result.Entries[1].Index) + } + }, + }, + { + name: "Different entry count - more source entries", + source: func() model.Subtitle { + sub := model.NewSubtitle() + sub.Format = "vtt" + sub.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.", + }, + } + return sub + }(), + target: func() model.Subtitle { + sub := model.NewSubtitle() + sub.Format = "vtt" + sub.Entries = []model.SubtitleEntry{ + { + Index: 1, + StartTime: model.Timestamp{Minutes: 1}, + 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.", + }, + } + return sub + }(), + verify: func(t *testing.T, result model.Subtitle) { + if len(result.Entries) != 2 { + t.Errorf("Expected 2 entries, got %d", len(result.Entries)) + return + } + + // Check scaling - first entry should get timing from first source + if result.Entries[0].StartTime.Seconds != 1 { + t.Errorf("First entry start time incorrect, got %+v", result.Entries[0].StartTime) + } + + // Second entry should have timing from last source entry due to scaling + if result.Entries[1].StartTime.Seconds != 9 { + t.Errorf("Second entry start time incorrect, expected scaled timing, got %+v", + result.Entries[1].StartTime) + } + + // Check target text preserved + if result.Entries[0].Text != "Target line one." || result.Entries[1].Text != "Target line two." { + t.Errorf("Expected target text to be preserved") + } + }, + }, + { + name: "Empty source entries", + source: func() model.Subtitle { + sub := model.NewSubtitle() + sub.Format = "vtt" + sub.Entries = []model.SubtitleEntry{} + return sub + }(), + target: func() model.Subtitle { + sub := model.NewSubtitle() + sub.Format = "vtt" + sub.Title = "Target Title" + sub.Entries = []model.SubtitleEntry{ + { + Index: 1, + StartTime: model.Timestamp{Minutes: 1}, + EndTime: model.Timestamp{Minutes: 1, Seconds: 3}, + Text: "Target line one.", + Styles: map[string]string{"align": "start"}, + }, + } + return sub + }(), + verify: func(t *testing.T, result model.Subtitle) { + if len(result.Entries) != 1 { + t.Errorf("Expected 1 entry, got %d", len(result.Entries)) + return + } + + // With empty source, target timing should be preserved + if result.Entries[0].StartTime.Minutes != 1 || result.Entries[0].EndTime.Minutes != 1 { + t.Errorf("Empty source should preserve target timing, got start: %+v, end: %+v", + result.Entries[0].StartTime, result.Entries[0].EndTime) + } + + // Check target styles preserved + if _, hasAlign := result.Entries[0].Styles["align"]; !hasAlign || result.Entries[0].Styles["align"] != "start" { + t.Errorf("Expected target styles to be preserved, got %v", result.Entries[0].Styles) + } + + // Check title is preserved + if result.Title != "Target Title" { + t.Errorf("Expected target title to be preserved") + } + }, + }, + { + name: "Empty target entries", + source: func() model.Subtitle { + sub := model.NewSubtitle() + sub.Format = "vtt" + sub.Entries = []model.SubtitleEntry{ + { + Index: 1, + StartTime: model.Timestamp{Seconds: 1}, + EndTime: model.Timestamp{Seconds: 4}, + Text: "Source line one.", + }, + } + return sub + }(), + target: func() model.Subtitle { + sub := model.NewSubtitle() + sub.Format = "vtt" + sub.Title = "Target Title" + sub.Entries = []model.SubtitleEntry{} + return sub + }(), + verify: func(t *testing.T, result model.Subtitle) { + if len(result.Entries) != 0 { + t.Errorf("Expected 0 entries, got %d", len(result.Entries)) + return + } + + // Should keep target metadata + if result.Title != "Target Title" { + t.Errorf("Expected target title to be preserved") + } + }, + }, + { + name: "Different entry count - more target entries", + source: func() model.Subtitle { + sub := model.NewSubtitle() + sub.Format = "vtt" + sub.Entries = []model.SubtitleEntry{ + { + Index: 1, + StartTime: model.Timestamp{Seconds: 1}, + EndTime: model.Timestamp{Seconds: 4}, + Text: "Source line one.", + }, + } + return sub + }(), + target: func() model.Subtitle { + sub := model.NewSubtitle() + sub.Format = "vtt" + sub.Entries = []model.SubtitleEntry{ + { + Index: 1, + StartTime: model.Timestamp{Minutes: 1}, + 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.", + }, + } + return sub + }(), + 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 first entry has source timing + if result.Entries[0].StartTime.Seconds != 1 { + t.Errorf("First entry start time incorrect, got %+v", result.Entries[0].StartTime) + } + + // The other entries should be scaled from the source + // With only one source entry, all target entries should get the same start time + if result.Entries[1].StartTime.Seconds != 1 || result.Entries[2].StartTime.Seconds != 1 { + t.Errorf("All entries should have same timing with only one source entry, got: %+v, %+v", + result.Entries[1].StartTime, result.Entries[2].StartTime) + } + + // Check indexes are sequential + if result.Entries[0].Index != 1 || result.Entries[1].Index != 2 || result.Entries[2].Index != 3 { + t.Errorf("Expected sequential indexes 1, 2, 3, got %d, %d, %d", + result.Entries[0].Index, result.Entries[1].Index, result.Entries[2].Index) + } + }, + }, + } + + 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) + } + }) + } +} + +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 count", + 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}, + 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 first entry + if result[0].StartTime.Seconds != 1 || result[0].EndTime.Seconds != 4 { + t.Errorf("First entry timing incorrect, got start: %+v, end: %+v", + result[0].StartTime, result[0].EndTime) + } + if result[0].Content != "Target line one." { + t.Errorf("Expected content 'Target line one.', got '%s'", result[0].Content) + } + if result[0].Number != 1 { + t.Errorf("Expected entry number 1, got %d", result[0].Number) + } + + // Check second entry + if result[1].StartTime.Seconds != 5 || result[1].EndTime.Seconds != 8 { + t.Errorf("Second entry timing incorrect, got start: %+v, end: %+v", + result[1].StartTime, result[1].EndTime) + } + if result[1].Content != "Target line two." { + t.Errorf("Expected content 'Target line two.', got '%s'", result[1].Content) + } + if result[1].Number != 2 { + t.Errorf("Expected entry number 2, got %d", result[1].Number) + } + }, + }, + { + name: "Different entry count - more source entries", + 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}, + 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 + } + + // First entry should have timing from first source entry + if result[0].StartTime.Seconds != 1 { + t.Errorf("First entry start time incorrect, got %+v", result[0].StartTime) + } + + // Second entry should have scaling from source entry 3 (at index 2) + if result[1].StartTime.Seconds != 9 { + t.Errorf("Second entry start time incorrect, got %+v", result[1].StartTime) + } + + // Check content content preserved + if result[0].Content != "Target line one." || result[1].Content != "Target line two." { + t.Errorf("Expected target content to be preserved") + } + + // Check numbering + if result[0].Number != 1 || result[1].Number != 2 { + t.Errorf("Expected sequential numbering 1, 2, got %d, %d", + result[0].Number, result[1].Number) + } + }, + }, + { + name: "Empty source entries", + sourceEntries: []model.SRTEntry{}, + targetEntries: []model.SRTEntry{ + { + Number: 1, + StartTime: model.Timestamp{Minutes: 1}, + EndTime: model.Timestamp{Minutes: 1, Seconds: 3}, + 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 + } + + // With empty source, target timing should be preserved + if result[0].StartTime.Minutes != 1 || result[0].EndTime.Minutes != 1 || + result[0].EndTime.Seconds != 3 { + t.Errorf("Expected target timing to be preserved with empty source") + } + + // Check content is preserved + if result[0].Content != "Target line one." { + t.Errorf("Expected target content to be preserved") + } + }, + }, + { + 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)) + } + }, + }, + } + + 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) + } + }) + } } func TestCalculateDuration(t *testing.T) { @@ -314,42 +1142,46 @@ func TestCalculateDuration(t *testing.T) { expected model.Timestamp }{ { - name: "Simple case", - start: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - end: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}, - expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 3, Milliseconds: 0}, + 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: "With milliseconds", - start: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500}, - end: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 800}, - expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 3, Milliseconds: 300}, + 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: "Across minute boundary", - start: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 50, Milliseconds: 0}, - end: model.Timestamp{Hours: 0, Minutes: 1, Seconds: 20, Milliseconds: 0}, - expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 30, Milliseconds: 0}, + 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: "Across hour boundary", - start: model.Timestamp{Hours: 0, Minutes: 59, Seconds: 30, Milliseconds: 0}, - end: model.Timestamp{Hours: 1, Minutes: 0, Seconds: 30, Milliseconds: 0}, - expected: model.Timestamp{Hours: 0, Minutes: 1, Seconds: 0, Milliseconds: 0}, + 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: "End before start", - start: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}, - end: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 2, Milliseconds: 0}, - expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0}, // Should return zero duration + 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 != tc.expected { - t.Errorf("Expected duration %+v, got %+v", tc.expected, result) + + 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) } }) } @@ -363,739 +1195,47 @@ func TestAddDuration(t *testing.T) { expected model.Timestamp }{ { - name: "Simple case", - start: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - duration: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 3, Milliseconds: 0}, - expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}, + 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: "With milliseconds", - start: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500}, - duration: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 3, Milliseconds: 300}, - expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 800}, + 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: "Carry milliseconds", - start: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 800}, - duration: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 300}, - expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 2, Milliseconds: 100}, + 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: "Carry seconds", - start: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 58, Milliseconds: 0}, - duration: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}, - expected: model.Timestamp{Hours: 0, Minutes: 1, Seconds: 2, Milliseconds: 0}, + name: "Zero duration", + start: model.Timestamp{Minutes: 5, Seconds: 30}, + duration: model.Timestamp{}, + expected: model.Timestamp{Minutes: 5, Seconds: 30}, }, { - name: "Carry minutes", - start: model.Timestamp{Hours: 0, Minutes: 59, Seconds: 0, Milliseconds: 0}, - duration: model.Timestamp{Hours: 0, Minutes: 2, Seconds: 0, Milliseconds: 0}, - expected: model.Timestamp{Hours: 1, Minutes: 1, Seconds: 0, Milliseconds: 0}, + 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 != tc.expected { - t.Errorf("Expected timestamp %+v, got %+v", tc.expected, result) + + 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 TestSyncVTTTimeline(t *testing.T) { - // Test with matching entry counts - t.Run("Matching entry counts", func(t *testing.T) { - source := model.NewSubtitle() - source.Format = "vtt" - - sourceEntry1 := model.NewSubtitleEntry() - sourceEntry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0} - sourceEntry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0} - sourceEntry1.Index = 1 - - sourceEntry2 := model.NewSubtitleEntry() - sourceEntry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0} - sourceEntry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0} - sourceEntry2.Index = 2 - - source.Entries = append(source.Entries, sourceEntry1, sourceEntry2) - - target := model.NewSubtitle() - target.Format = "vtt" - target.Title = "Test Title" - - targetEntry1 := model.NewSubtitleEntry() - targetEntry1.StartTime = model.Timestamp{Hours: 0, Minutes: 1, Seconds: 0, Milliseconds: 0} - targetEntry1.EndTime = model.Timestamp{Hours: 0, Minutes: 1, Seconds: 3, Milliseconds: 0} - targetEntry1.Text = "Target line one." - targetEntry1.Styles = map[string]string{"align": "start"} - targetEntry1.Index = 1 - - targetEntry2 := model.NewSubtitleEntry() - targetEntry2.StartTime = model.Timestamp{Hours: 0, Minutes: 1, Seconds: 5, Milliseconds: 0} - targetEntry2.EndTime = model.Timestamp{Hours: 0, Minutes: 1, Seconds: 8, Milliseconds: 0} - targetEntry2.Text = "Target line two." - targetEntry2.Index = 2 - - target.Entries = append(target.Entries, targetEntry1, targetEntry2) - - result := syncVTTTimeline(source, target) - - // Check that result preserves target metadata and styling - if result.Title != "Test Title" { - t.Errorf("Expected title 'Test Title', got '%s'", result.Title) - } - - if len(result.Entries) != 2 { - t.Errorf("Expected 2 entries, got %d", len(result.Entries)) - } - - // Check first entry - if result.Entries[0].StartTime != sourceEntry1.StartTime { - t.Errorf("Expected start time %+v, got %+v", sourceEntry1.StartTime, result.Entries[0].StartTime) - } - - if result.Entries[0].EndTime != sourceEntry1.EndTime { - t.Errorf("Expected end time %+v, got %+v", sourceEntry1.EndTime, result.Entries[0].EndTime) - } - - if result.Entries[0].Text != "Target line one." { - t.Errorf("Expected text 'Target line one.', got '%s'", result.Entries[0].Text) - } - - if result.Entries[0].Styles["align"] != "start" { - t.Errorf("Expected style 'align: start', got '%s'", result.Entries[0].Styles["align"]) - } - }) - - // Test with mismatched entry counts - t.Run("Mismatched entry counts", func(t *testing.T) { - source := model.NewSubtitle() - source.Format = "vtt" - - sourceEntry1 := model.NewSubtitleEntry() - sourceEntry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0} - sourceEntry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0} - sourceEntry1.Index = 1 - - sourceEntry2 := model.NewSubtitleEntry() - sourceEntry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0} - sourceEntry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0} - sourceEntry2.Index = 2 - - source.Entries = append(source.Entries, sourceEntry1, sourceEntry2) - - target := model.NewSubtitle() - target.Format = "vtt" - - targetEntry1 := model.NewSubtitleEntry() - targetEntry1.Text = "Target line one." - targetEntry1.Index = 1 - - targetEntry2 := model.NewSubtitleEntry() - targetEntry2.Text = "Target line two." - targetEntry2.Index = 2 - - targetEntry3 := model.NewSubtitleEntry() - targetEntry3.Text = "Target line three." - targetEntry3.Index = 3 - - target.Entries = append(target.Entries, targetEntry1, targetEntry2, targetEntry3) - - result := syncVTTTimeline(source, target) - - if len(result.Entries) != 3 { - t.Errorf("Expected 3 entries, got %d", len(result.Entries)) - } - - // Check that timing was interpolated - if result.Entries[0].StartTime != sourceEntry1.StartTime { - t.Errorf("First entry start time should match source, got %+v", result.Entries[0].StartTime) - } - - // Last entry should end at source's last entry end time - if result.Entries[2].EndTime != sourceEntry2.EndTime { - t.Errorf("Last entry end time should match source's last entry, got %+v", result.Entries[2].EndTime) - } - }) -} - -func TestSyncVTTTimeline_EdgeCases(t *testing.T) { - t.Run("Empty source subtitle", func(t *testing.T) { - source := model.NewSubtitle() - source.Format = "vtt" - - target := model.NewSubtitle() - target.Format = "vtt" - targetEntry := model.NewSubtitleEntry() - targetEntry.Text = "Target content." - targetEntry.Index = 1 - target.Entries = append(target.Entries, targetEntry) - - // 当源字幕为空时,我们不应该直接调用syncVTTTimeline, - // 而是应该测试完整的SyncLyrics函数行为 - // 或者我们需要创建一个临时文件并使用syncVTTFiles, - // 但目前我们修改测试预期 - - // 预期结果应该是一个包含相同文本内容的新字幕,时间戳为零值 - result := model.NewSubtitle() - result.Format = "vtt" - resultEntry := model.NewSubtitleEntry() - resultEntry.Text = "Target content." - resultEntry.Index = 1 - result.Entries = append(result.Entries, resultEntry) - - // 对比两个结果 - if len(result.Entries) != 1 { - t.Errorf("Expected 1 entry, got %d", len(result.Entries)) - } - - if result.Entries[0].Text != "Target content." { - t.Errorf("Expected text content 'Target content.', got '%s'", result.Entries[0].Text) - } - }) - - t.Run("Empty target subtitle", func(t *testing.T) { - source := model.NewSubtitle() - source.Format = "vtt" - sourceEntry := model.NewSubtitleEntry() - sourceEntry.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0} - sourceEntry.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0} - sourceEntry.Index = 1 - - source.Entries = append(source.Entries, sourceEntry) - - target := model.NewSubtitle() - target.Format = "vtt" - - result := syncVTTTimeline(source, target) - - if len(result.Entries) != 0 { - t.Errorf("Expected 0 entries, got %d", len(result.Entries)) - } - }) - - t.Run("Single entry source, multiple target", func(t *testing.T) { - source := model.NewSubtitle() - source.Format = "vtt" - sourceEntry := model.NewSubtitleEntry() - sourceEntry.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0} - sourceEntry.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0} - sourceEntry.Index = 1 - source.Entries = append(source.Entries, sourceEntry) - - target := model.NewSubtitle() - target.Format = "vtt" - for i := 0; i < 3; i++ { - entry := model.NewSubtitleEntry() - entry.Text = "Target line " + string(rune('A'+i)) - entry.Index = i + 1 - target.Entries = append(target.Entries, entry) - } - - result := syncVTTTimeline(source, target) - - if len(result.Entries) != 3 { - t.Errorf("Expected 3 entries, got %d", len(result.Entries)) - } - - // 检查所有条目是否具有相同的时间戳 - for i, entry := range result.Entries { - if entry.StartTime != sourceEntry.StartTime { - t.Errorf("Entry %d: expected start time %+v, got %+v", i, sourceEntry.StartTime, entry.StartTime) - } - if entry.EndTime != sourceEntry.EndTime { - t.Errorf("Entry %d: expected end time %+v, got %+v", i, sourceEntry.EndTime, entry.EndTime) - } - } - }) -} - -func TestCalculateDuration_SpecialCases(t *testing.T) { - t.Run("Zero duration", func(t *testing.T) { - start := model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0} - end := model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0} - - result := calculateDuration(start, end) - - if result.Hours != 0 || result.Minutes != 0 || result.Seconds != 0 || result.Milliseconds != 0 { - t.Errorf("Expected zero duration, got %+v", result) - } - }) - - t.Run("Negative duration returns zero", func(t *testing.T) { - start := model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0} - end := model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0} - - result := calculateDuration(start, end) - - // 应该返回零而不是3秒 - if result.Hours != 0 || result.Minutes != 0 || result.Seconds != 0 || result.Milliseconds != 0 { - t.Errorf("Expected zero duration for negative case, got %+v", result) - } - }) - - t.Run("Large duration", func(t *testing.T) { - start := model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0} - end := model.Timestamp{Hours: 2, Minutes: 30, Seconds: 45, Milliseconds: 500} - - expected := model.Timestamp{ - Hours: 2, - Minutes: 30, - Seconds: 45, - Milliseconds: 500, - } - - result := calculateDuration(start, end) - - if result != expected { - t.Errorf("Expected duration %+v, got %+v", expected, result) - } - }) -} - -func TestSyncLRCTimeline(t *testing.T) { - // Setup test case - sourceLyrics := model.Lyrics{ - Metadata: map[string]string{"ti": "Source Title"}, - Timeline: []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - {Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}, - }, - Content: []string{ - "Source line one.", - "Source line two.", - }, - } - - targetLyrics := model.Lyrics{ - Metadata: map[string]string{"ti": "Target Title", "ar": "Target Artist"}, - Timeline: []model.Timestamp{ - {Hours: 0, Minutes: 1, Seconds: 0, Milliseconds: 0}, - {Hours: 0, Minutes: 1, Seconds: 5, Milliseconds: 0}, - }, - Content: []string{ - "Target line one.", - "Target line two.", - }, - } - - // Test with matching entry counts - t.Run("Matching entry counts", func(t *testing.T) { - result := syncLRCTimeline(sourceLyrics, targetLyrics) - - // Check that result preserves target metadata - if result.Metadata["ti"] != "Target Title" { - t.Errorf("Expected title 'Target Title', got '%s'", result.Metadata["ti"]) - } - - if result.Metadata["ar"] != "Target Artist" { - t.Errorf("Expected artist 'Target Artist', got '%s'", result.Metadata["ar"]) - } - - if len(result.Timeline) != 2 { - t.Errorf("Expected 2 timeline entries, got %d", len(result.Timeline)) - } - - // Check first entry - if result.Timeline[0] != sourceLyrics.Timeline[0] { - t.Errorf("Expected timeline entry %+v, got %+v", sourceLyrics.Timeline[0], result.Timeline[0]) - } - - if result.Content[0] != "Target line one." { - t.Errorf("Expected content 'Target line one.', got '%s'", result.Content[0]) - } - }) - - // Test with mismatched entry counts - t.Run("Mismatched entry counts", func(t *testing.T) { - // Create target with more entries - targetWithMoreEntries := model.Lyrics{ - Metadata: targetLyrics.Metadata, - Timeline: append(targetLyrics.Timeline, model.Timestamp{Hours: 0, Minutes: 1, Seconds: 10, Milliseconds: 0}), - Content: append(targetLyrics.Content, "Target line three."), - } - - result := syncLRCTimeline(sourceLyrics, targetWithMoreEntries) - - if len(result.Timeline) != 3 { - t.Errorf("Expected 3 timeline entries, got %d", len(result.Timeline)) - } - - // Check scaling - if result.Timeline[0] != sourceLyrics.Timeline[0] { - t.Errorf("First timeline entry should match source, got %+v", result.Timeline[0]) - } - - // Last entry should end at source's last entry end time - if result.Timeline[2].Hours != 0 || result.Timeline[2].Minutes != 0 || - result.Timeline[2].Seconds < 5 || result.Timeline[2].Seconds > 9 { - t.Errorf("Last timeline entry should be interpolated between 5-9 seconds, got %+v", result.Timeline[2]) - } - - // Verify the content is preserved - if result.Content[2] != "Target line three." { - t.Errorf("Expected content 'Target line three.', got '%s'", result.Content[2]) - } - }) -} - -func TestScaleTimeline(t *testing.T) { - testCases := []struct { - name string - timeline []model.Timestamp - targetCount int - expectedLen int - validateFunc func(t *testing.T, result []model.Timestamp) - }{ - { - name: "Empty timeline", - timeline: []model.Timestamp{}, - targetCount: 5, - expectedLen: 0, - validateFunc: func(t *testing.T, result []model.Timestamp) { - if len(result) != 0 { - t.Errorf("Expected empty result, got %d items", len(result)) - } - }, - }, - { - name: "Single timestamp", - timeline: []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - }, - targetCount: 3, - expectedLen: 3, - validateFunc: func(t *testing.T, result []model.Timestamp) { - expectedTime := model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0} - for i, ts := range result { - if ts != expectedTime { - t.Errorf("Entry %d: expected %+v, got %+v", i, expectedTime, ts) - } - } - }, - }, - { - name: "Same count", - timeline: []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - {Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}, - }, - targetCount: 2, - expectedLen: 2, - validateFunc: func(t *testing.T, result []model.Timestamp) { - expected := []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - {Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}, - } - for i, ts := range result { - if ts != expected[i] { - t.Errorf("Entry %d: expected %+v, got %+v", i, expected[i], ts) - } - } - }, - }, - { - name: "Source greater than target", - timeline: []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - {Hours: 0, Minutes: 0, Seconds: 3, Milliseconds: 0}, - {Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}, - }, - targetCount: 2, - expectedLen: 2, - validateFunc: func(t *testing.T, result []model.Timestamp) { - expected := []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - {Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}, - } - for i, ts := range result { - if ts != expected[i] { - t.Errorf("Entry %d: expected %+v, got %+v", i, expected[i], ts) - } - } - }, - }, - { - name: "Target greater than source (linear interpolation)", - timeline: []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - {Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}, - }, - targetCount: 3, - expectedLen: 3, - validateFunc: func(t *testing.T, result []model.Timestamp) { - expected := []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - {Hours: 0, Minutes: 0, Seconds: 3, Milliseconds: 0}, // 中间点插值 - {Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}, - } - for i, ts := range result { - if ts != expected[i] { - t.Errorf("Entry %d: expected %+v, got %+v", i, expected[i], ts) - } - } - }, - }, - { - name: "Negative target count", - timeline: []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - }, - targetCount: -1, - expectedLen: 0, - validateFunc: func(t *testing.T, result []model.Timestamp) { - if len(result) != 0 { - t.Errorf("Expected empty result for negative target count, got %d items", len(result)) - } - }, - }, - { - name: "Zero target count", - timeline: []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - }, - targetCount: 0, - expectedLen: 0, - validateFunc: func(t *testing.T, result []model.Timestamp) { - if len(result) != 0 { - t.Errorf("Expected empty result for zero target count, got %d items", len(result)) - } - }, - }, - { - name: "Complex interpolation", - timeline: []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0}, - {Hours: 0, Minutes: 0, Seconds: 10, Milliseconds: 0}, - }, - targetCount: 6, - expectedLen: 6, - validateFunc: func(t *testing.T, result []model.Timestamp) { - // 预期均匀分布:0s, 2s, 4s, 6s, 8s, 10s - for i := 0; i < 6; i++ { - expectedSeconds := i * 2 - if result[i].Seconds != expectedSeconds { - t.Errorf("Entry %d: expected %d seconds, got %d", i, expectedSeconds, result[i].Seconds) - } - } - }, - }, - { - name: "Target count of 1", - timeline: []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - {Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}, - {Hours: 0, Minutes: 0, Seconds: 10, Milliseconds: 0}, - }, - targetCount: 1, - expectedLen: 1, - validateFunc: func(t *testing.T, result []model.Timestamp) { - expected := model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0} - if result[0] != expected { - t.Errorf("Expected first timestamp only, got %+v", result[0]) - } - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result := scaleTimeline(tc.timeline, tc.targetCount) - - if len(result) != tc.expectedLen { - t.Errorf("Expected length %d, got %d", tc.expectedLen, len(result)) - } - - if tc.validateFunc != nil { - tc.validateFunc(t, result) - } - }) - } -} - -func TestSync_ErrorHandling(t *testing.T) { - tempDir := t.TempDir() - - // 测试文件不存在的情况 - t.Run("Non-existent source file", func(t *testing.T) { - sourceFile := filepath.Join(tempDir, "nonexistent.srt") - targetFile := filepath.Join(tempDir, "target.srt") - - // 创建一个简单的目标文件 - targetContent := "1\n00:00:01,000 --> 00:00:04,000\nTarget content.\n" - if err := os.WriteFile(targetFile, []byte(targetContent), 0644); err != nil { - t.Fatalf("Failed to create target file: %v", err) - } - - err := SyncLyrics(sourceFile, targetFile) - if err == nil { - t.Error("Expected error for non-existent source file, got nil") - } - }) - - t.Run("Non-existent target file", func(t *testing.T) { - sourceFile := filepath.Join(tempDir, "source.srt") - targetFile := filepath.Join(tempDir, "nonexistent.srt") - - // 创建一个简单的源文件 - sourceContent := "1\n00:00:01,000 --> 00:00:04,000\nSource content.\n" - if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil { - t.Fatalf("Failed to create source file: %v", err) - } - - err := SyncLyrics(sourceFile, targetFile) - if err == nil { - t.Error("Expected error for non-existent target file, got nil") - } - }) - - t.Run("Different formats", func(t *testing.T) { - sourceFile := filepath.Join(tempDir, "source.srt") - targetFile := filepath.Join(tempDir, "target.vtt") // 不同格式 - - // 创建源和目标文件 - sourceContent := "1\n00:00:01,000 --> 00:00:04,000\nSource content.\n" - if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil { - t.Fatalf("Failed to create source file: %v", err) - } - - targetContent := "WEBVTT\n\n1\n00:00:01.000 --> 00:00:04.000\nTarget content.\n" - if err := os.WriteFile(targetFile, []byte(targetContent), 0644); err != nil { - t.Fatalf("Failed to create target file: %v", err) - } - - err := SyncLyrics(sourceFile, targetFile) - if err == nil { - t.Error("Expected error for different formats, got nil") - } - }) - - t.Run("Unsupported format", func(t *testing.T) { - sourceFile := filepath.Join(tempDir, "source.unknown") - targetFile := filepath.Join(tempDir, "target.unknown") - - // 创建源和目标文件 - sourceContent := "Some content in unknown format" - if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil { - t.Fatalf("Failed to create source file: %v", err) - } - - targetContent := "Some target content in unknown format" - if err := os.WriteFile(targetFile, []byte(targetContent), 0644); err != nil { - t.Fatalf("Failed to create target file: %v", err) - } - - err := SyncLyrics(sourceFile, targetFile) - if err == nil { - t.Error("Expected error for unsupported format, got nil") - } - }) -} - -func TestSyncLRCTimeline_EdgeCases(t *testing.T) { - t.Run("Empty source timeline", func(t *testing.T) { - source := model.Lyrics{ - Metadata: map[string]string{"ti": "Source Title"}, - Timeline: []model.Timestamp{}, - Content: []string{}, - } - - target := model.Lyrics{ - Metadata: map[string]string{"ti": "Target Title"}, - Timeline: []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - }, - Content: []string{ - "Target line.", - }, - } - - result := syncLRCTimeline(source, target) - - if len(result.Timeline) != 1 { - t.Errorf("Expected 1 timeline entry, got %d", len(result.Timeline)) - } - - // 检查时间戳是否被设置为零值 - if result.Timeline[0] != (model.Timestamp{}) { - t.Errorf("Expected zero timestamp, got %+v", result.Timeline[0]) - } - }) - - t.Run("Empty target content", func(t *testing.T) { - source := model.Lyrics{ - Metadata: map[string]string{"ti": "Source Title"}, - Timeline: []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - }, - Content: []string{ - "Source line.", - }, - } - - target := model.Lyrics{ - Metadata: map[string]string{"ti": "Target Title"}, - Timeline: []model.Timestamp{}, - Content: []string{}, - } - - result := syncLRCTimeline(source, target) - - 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)) - } - }) - - t.Run("Target content longer than timeline", func(t *testing.T) { - source := model.Lyrics{ - Metadata: map[string]string{"ti": "Source Title"}, - Timeline: []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, - {Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}, - }, - Content: []string{ - "Source line 1.", - "Source line 2.", - }, - } - - target := model.Lyrics{ - Metadata: map[string]string{"ti": "Target Title"}, - Timeline: []model.Timestamp{ - {Hours: 0, Minutes: 0, Seconds: 10, Milliseconds: 0}, - }, - Content: []string{ - "Target line 1.", - "Target line 2.", // 比Timeline多一个条目 - }, - } - - result := syncLRCTimeline(source, target) - - if len(result.Timeline) != 2 { - t.Errorf("Expected 2 timeline entries, got %d", len(result.Timeline)) - } - if len(result.Content) != 2 { - t.Errorf("Expected 2 content entries, got %d", len(result.Content)) - } - - // 检查第一个时间戳是否正确设置 - if result.Timeline[0] != source.Timeline[0] { - t.Errorf("Expected first timestamp %+v, got %+v", source.Timeline[0], result.Timeline[0]) - } - - // 检查内容是否被保留 - if result.Content[0] != "Target line 1." { - t.Errorf("Expected content 'Target line 1.', got '%s'", result.Content[0]) - } - if result.Content[1] != "Target line 2." { - t.Errorf("Expected content 'Target line 2.', got '%s'", result.Content[1]) - } - }) -} diff --git a/internal/testdata/test.ass b/internal/testdata/test.ass new file mode 100644 index 0000000..bb871ab --- /dev/null +++ b/internal/testdata/test.ass @@ -0,0 +1,15 @@ +[Script Info] +ScriptType: v4.00+ +PlayResX: 640 +PlayResY: 480 +Title: ASS Test 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,,First line +Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,Second line +Dialogue: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,Third line