clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / cc / script.go
script.go 4.6K
1package cc23import (4 "cmp"5 "fmt"6 "slices"7 "strings"8 "sync"9 "time"10)1112// Script is a stream's captions as text, timed from the stream's start,13// for players that show WebVTT themselves rather than decoding CEA-608.14// The stream fills it in as each video starts; a player's packager reads15// it as the stream's segments are made. Its zero value is empty and ready.16type Script struct {17 mu sync.Mutex18 cues []Cue // by start19}2021// Set puts cues in the script for the time from from until until, in22// place of what was there. Cues are clipped to that time; those that were23// showing at from end there.24func (s *Script) Set(from, until time.Duration, cues []Cue) {25 s.mu.Lock()26 defer s.mu.Unlock()27 kept := s.cues[:0]28 for _, c := range s.cues {29 switch {30 case c.Start >= from && c.Start < until:31 continue32 case c.Start < from && c.End > from:33 c.End = from34 }35 kept = append(kept, c)36 }37 s.cues = kept38 for _, c := range cues {39 if c.Start < from || c.Start >= until {40 continue41 }42 c.End = min(c.End, until)43 s.cues = append(s.cues, c)44 }45 slices.SortStableFunc(s.cues, func(a, b Cue) int { return cmp.Compare(a.Start, b.Start) })46}4748// Between returns the cues showing at some time from from until until.49func (s *Script) Between(from, until time.Duration) []Cue {50 s.mu.Lock()51 defer s.mu.Unlock()52 var out []Cue53 for _, c := range s.cues {54 if c.Start >= until {55 break56 }57 if c.End > from {58 out = append(out, c)59 }60 }61 return out62}6364// Forget drops cues that ended before t, which nothing will show again.65func (s *Script) Forget(t time.Duration) {66 s.mu.Lock()67 defer s.mu.Unlock()68 s.cues = slices.DeleteFunc(s.cues, func(c Cue) bool { return c.End < t })69}7071// VTTTime formats a time as WebVTT does: "01:02:03.456".72func VTTTime(d time.Duration) string {73 d = max(d, 0)74 ms := d.Milliseconds()75 return fmt.Sprintf("%02d:%02d:%02d.%03d", ms/3600000, ms/60000%60, ms/1000%60, ms%1000)76}7778var vttEscape = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")7980// Screen turns a video's cues into ones for players that show text: one81// at a time, never overlapping. Cues that start together join, a cue82// ends when the next starts, and a cue the same as the one before runs83// on. Captions timed word by word come as lines, each showing until the84// line after next begins (YouTube's speech recognition): they roll up as85// on TV, two lines a screen, the last line moving up as the next begins,86// each line once, and only the bottom one's words timed.87func Screen(cues []Cue) []Cue {88 if slices.ContainsFunc(cues, func(c Cue) bool { return len(c.Words) > 0 }) {89 return rollUpScreens(cues)90 }91 var out []Cue92 cues = merge(cues)93 for i, c := range cues {94 if i+1 < len(cues) {95 c.End = min(c.End, cues[i+1].Start)96 }97 if c.End <= c.Start {98 continue99 }100 if n := len(out); n > 0 && out[n-1].Text == c.Text && out[n-1].End >= c.Start-time.Millisecond {101 out[n-1].End = c.End102 continue103 }104 out = append(out, c)105 }106 return out107}108109// rollUpScreens composes the screens of lines timed word by word.110func rollUpScreens(cues []Cue) []Cue {111 cues = slices.Clone(cues)112 slices.SortStableFunc(cues, func(a, b Cue) int { return cmp.Compare(a.Start, b.Start) })113 var out []Cue114 var above Cue // the line before, while it shows115 for i, c := range cues {116 line := strings.Join(strings.Fields(strings.ReplaceAll(c.Text, "\n", " ")), " ")117 end := c.End118 if i+1 < len(cues) {119 end = min(end, cues[i+1].Start)120 }121 if line == "" || end <= c.Start {122 continue123 }124 s := Cue{Start: c.Start, End: end, Text: line, Words: c.Words}125 if above.End > c.Start && above.Text != line {126 s.Text = above.Text + "\n" + line127 }128 out = append(out, s)129 above = Cue{End: c.End, Text: line}130 }131 return out132}133134// AppendVTT appends a cue in WebVTT to b. A cue timed word by word has135// each word of its last line after the first marked with when it's said136// ("<00:00:01.500>word"), for players that reveal words as they come.137func AppendVTT(b []byte, c Cue) []byte {138 b = fmt.Appendf(b, "%s --> %s\n", VTTTime(c.Start), VTTTime(c.End))139 if len(c.Words) > 0 {140 lines := strings.Split(c.Text, "\n")141 for _, l := range lines[:len(lines)-1] {142 if l = strings.TrimSpace(l); l != "" {143 b = append(append(b, vttEscape.Replace(l)...), '\n')144 }145 }146 last := c.Start147 for i, w := range c.Words {148 if i > 0 {149 b = append(b, ' ')150 if w.Start > last && w.Start < c.End {151 b = fmt.Appendf(b, "<%s>", VTTTime(w.Start))152 last = w.Start153 }154 }155 b = append(b, vttEscape.Replace(w.Text)...)156 }157 return append(b, "\n\n"...)158 }159 for l := range strings.SplitSeq(c.Text, "\n") {160 if l = strings.TrimSpace(l); l != "" {161 b = append(append(b, vttEscape.Replace(l)...), '\n')162 }163 }164 return append(b, '\n')165}