@@ -3,12 +3,17 @@ package tools
33import (
44 "fmt"
55 "github.com/spf13/cobra"
6+ "github.com/warmans/tvgif/pkg/model"
7+ "github.com/warmans/tvgif/pkg/srt"
68 "github.com/warmans/tvgif/pkg/util"
79 "io"
810 "log/slog"
11+ "math"
912 "os"
1013 "regexp"
1114 "strings"
15+ "time"
16+ "unicode"
1217)
1318
1419var (
@@ -25,10 +30,131 @@ func NewToolsCommand(logger *slog.Logger) *cobra.Command {
2530 cmd .Flags ().StringVar (& metadataPath , "o" , "./var/metadata" , "output metadata to this path" )
2631
2732 cmd .AddCommand (NewFixNameCommand ())
33+ cmd .AddCommand (NewRepairSRTCommand ())
2834
2935 return cmd
3036}
3137
38+ func NewRepairSRTCommand () * cobra.Command {
39+ cmd := & cobra.Command {
40+ Use : "repair-srt [file]" ,
41+ Short : "repair an .srt file in place (e.g. fix incorrect text case such as all upper-case dialog)" ,
42+ Args : cobra .ExactArgs (1 ),
43+ RunE : func (cmd * cobra.Command , args []string ) error {
44+ fileName := args [0 ]
45+
46+ f , err := os .Open (fileName )
47+ if err != nil {
48+ return fmt .Errorf ("failed to open file '%s': %w" , fileName , err )
49+ }
50+
51+ // Parse with no duration limit and without eliminating gaps so that
52+ // the original timestamps are preserved as closely as possible.
53+ dialog , err := srt .Read (f , false , time .Duration (math .MaxInt64 ))
54+ if err != nil {
55+ _ = f .Close ()
56+ return fmt .Errorf ("failed to parse srt: %w" , err )
57+ }
58+ if err := f .Close (); err != nil {
59+ return err
60+ }
61+
62+ for i := range dialog {
63+ dialog [i ].Content = fixCase (dialog [i ].Content )
64+ }
65+
66+ if err := os .WriteFile (fileName , []byte (renderSRT (dialog )), 0644 ); err != nil {
67+ return fmt .Errorf ("failed to write repaired srt: %w" , err )
68+ }
69+
70+ _ , err = fmt .Fprintf (os .Stdout , "repaired %d lines of dialog in %s\n " , len (dialog ), fileName )
71+ return err
72+ },
73+ }
74+
75+ return cmd
76+ }
77+
78+ // renderSRT serializes dialog back into the SRT file format.
79+ func renderSRT (dialog []model.Dialog ) string {
80+ sb := & strings.Builder {}
81+ for _ , d := range dialog {
82+ fmt .Fprintf (sb , "%d\n " , d .Pos )
83+ fmt .Fprintf (sb , "%s --> %s\n " , formatTimestamp (d .StartTimestamp ), formatTimestamp (d .EndTimestamp ))
84+ sb .WriteString (d .Content )
85+ sb .WriteString ("\n \n " )
86+ }
87+ return sb .String ()
88+ }
89+
90+ // formatTimestamp renders a duration as an SRT timestamp (HH:MM:SS,mmm).
91+ func formatTimestamp (d time.Duration ) string {
92+ hours := d / time .Hour
93+ d -= hours * time .Hour
94+ minutes := d / time .Minute
95+ d -= minutes * time .Minute
96+ seconds := d / time .Second
97+ d -= seconds * time .Second
98+ millis := d / time .Millisecond
99+ return fmt .Sprintf ("%02d:%02d:%02d,%03d" , hours , minutes , seconds , millis )
100+ }
101+
102+ var standaloneI = regexp .MustCompile (`\bi(?:('[a-z]+)|\b)` )
103+
104+ // fixCase corrects the case of a line of dialog. If the text is "shouty"
105+ // (i.e. contains letters but none of them are lower-case) it is converted
106+ // to sentence case. Otherwise it is left untouched.
107+ func fixCase (s string ) string {
108+ if ! isShouty (s ) {
109+ return s
110+ }
111+ return toSentenceCase (s )
112+ }
113+
114+ // isShouty reports whether a string is predominantly upper-case, allowing for
115+ // a few stray lower-case letters. A minimum number of letters is required to
116+ // avoid mis-classifying very short strings (e.g. a lone "A" or "I").
117+ func isShouty (s string ) bool {
118+ var upper , lower int
119+ for _ , r := range s {
120+ switch {
121+ case unicode .IsUpper (r ):
122+ upper ++
123+ case unicode .IsLower (r ):
124+ lower ++
125+ }
126+ }
127+ total := upper + lower
128+ if total < 3 {
129+ return false
130+ }
131+ return float64 (upper )/ float64 (total ) >= 0.6
132+ }
133+
134+ // toSentenceCase lower-cases the input and then capitalizes the first letter
135+ // of each sentence, as well as the standalone pronoun "i".
136+ func toSentenceCase (s string ) string {
137+ runes := []rune (strings .ToLower (s ))
138+ capitalizeNext := true
139+ for i , r := range runes {
140+ if unicode .IsLetter (r ) {
141+ if capitalizeNext {
142+ runes [i ] = unicode .ToUpper (r )
143+ capitalizeNext = false
144+ }
145+ continue
146+ }
147+ switch r {
148+ case '.' , '!' , '?' , '\n' :
149+ capitalizeNext = true
150+ }
151+ }
152+ result := string (runes )
153+ return standaloneI .ReplaceAllStringFunc (result , func (match string ) string {
154+ return "I" + match [1 :]
155+ })
156+ }
157+
32158func NewFixNameCommand () * cobra.Command {
33159 cmd := & cobra.Command {
34160 Use : "fix-name" ,
0 commit comments