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 / vchan / captions.go
captions.go 8.4K
1package vchan23import (4 "context"5 "fmt"6 "math"7 "os"8 "os/exec"9 "regexp"10 "sort"11 "sync"12 "time"1314 "airwaves/internal/cc"15 "airwaves/internal/phase"16)1718// Videos' English captions reach viewers as CEA-608 closed captions in the19// picture, which players show when the viewer turns captions on, except20// that a video whose sound isn't English gets its subtitles burned into the21// picture for HDHomeRun clients such as Channels DVR, which can't be told to22// turn captions on. The Airwaves app turns them on itself for such23// programs, and gets the captions as text besides, to lay out itself, so24// its streams never have them burned in.2526type appKey struct{}2728// ForApp marks a stream as one for the Airwaves app, whose viewers get29// subtitles as captions, never burned in. The stream puts its captions in30// script as text too, when script isn't nil.31func ForApp(ctx context.Context, script *cc.Script) context.Context {32 return context.WithValue(ctx, appKey{}, appStream{script})33}3435type appStream struct{ script *cc.Script }3637func forApp(ctx context.Context) (app bool, script *cc.Script) {38 a, ok := ctx.Value(appKey{}).(appStream)39 return ok, a.script40}4142// HasCaptions reports whether a channel's streams carry CEA-608 closed43// captions: the channels of videos do, when the video on has some.44func HasCaptions(c Channel) bool {45 switch c.(type) {46 case *Folder, *Jellyfin, *YouTube:47 return true48 }49 return false50}5152// captions are a video's English captions, fetched when first wanted and53// kept with the video.54type captions struct {55 key string // what they are, to keep them across refreshes of the list56 fetch func(ctx context.Context) ([]byte, error)57 once sync.Once58 done chan struct{}59 cues []cc.Cue60 err error61}6263// captionsTimeout bounds fetching captions, which a server may first have64// to extract from a large file.65const captionsTimeout = 2 * time.Minute6667func newCaptions(key string, fetch func(ctx context.Context) ([]byte, error)) *captions {68 return &captions{key: key, fetch: fetch, done: make(chan struct{})}69}7071// start fetches the captions in the background, once.72func (c *captions) start() {73 c.once.Do(func() {74 go func() {75 defer close(c.done)76 ctx, cancel := context.WithTimeout(context.Background(), captionsTimeout)77 defer cancel()78 raw, err := c.fetch(ctx)79 if err == nil {80 c.cues, err = cc.Parse(raw)81 }82 c.err = err83 }()84 })85}8687// get waits for the captions until ctx ends.88func (c *captions) get(ctx context.Context) ([]cc.Cue, error) {89 c.start()90 select {91 case <-c.done:92 return c.cues, c.err93 case <-ctx.Done():94 return nil, ctx.Err()95 }96}9798// ready returns the captions if fetching them has ended, nil if it99// failed.100func (c *captions) ready() ([]cc.Cue, bool) {101 select {102 case <-c.done:103 return c.cues, true104 default:105 return nil, false106 }107}108109// failed reports whether fetching the captions failed.110func (c *captions) failed() bool {111 select {112 case <-c.done:113 return c.err != nil114 default:115 return false116 }117}118119// timeline maps the frames of a stream to the caption track of the video120// playing each, for the cc.Writer adding them to the encoder's output, and121// keeps the stream's script, when it has one, in step.122type timeline struct {123 mu sync.Mutex124 spans []*span125 asked int64 // the last frame asked about126 script *cc.Script // nil without one127}128129// span is a video's frames, from start until end (-1 while it plays).130type span struct {131 start, end int64132 track *cc.Track133}134135// begin starts a video at frame start with no captions yet. Its track's136// first frames clear the screen of the last video's, and the script is137// clear from then on of any of its captions due after it ended.138func (t *timeline) begin(start int64) *span {139 t.mu.Lock()140 defer t.mu.Unlock()141 // Spans wholly before what the writer has reached are done with.142 for len(t.spans) > 1 && t.spans[1].start <= t.asked {143 t.spans = t.spans[1:]144 }145 if n := len(t.spans); n > 0 {146 t.spans[n-1].end = start147 }148 s := &span{start: start, end: -1, track: cc.NewTrack(nil, loopFPS)}149 t.spans = append(t.spans, s)150 if t.script != nil {151 t.script.Set(frameDuration(start), forever, nil)152 }153 return s154}155156// forever is a time no stream reaches.157const forever = time.Duration(math.MaxInt64)158159// set gives a span its captions, timed from its start.160func (t *timeline) set(s *span, cues []cc.Cue) {161 track := cc.NewTrack(cues, loopFPS)162 t.mu.Lock()163 defer t.mu.Unlock()164 s.track = track165 if t.script != nil {166 until := forever167 if s.end >= 0 {168 until = frameDuration(s.end)169 }170 at := frameDuration(s.start)171 t.script.Set(at, until, cc.Screen(cc.Shift(cues, at)))172 }173}174175// pair is the CEA-608 byte pair for frame.176func (t *timeline) pair(frame int64) (byte, byte) {177 t.mu.Lock()178 defer t.mu.Unlock()179 t.asked = max(t.asked, frame)180 i := sort.Search(len(t.spans), func(i int) bool { return t.spans[i].start > frame }) - 1181 if i < 0 {182 return cc.Padding, cc.Padding183 }184 s := t.spans[i]185 return s.track.Pair(frame - s.start)186}187188const (189 // burnWait is how long a video waits to start for subtitles to burn190 // in, beyond which it plays with closed captions instead.191 burnWait = 10 * time.Second192 // captionsWait is how long it waits for closed captions.193 captionsWait = time.Second194 // lateCaptions is how far ahead captions arriving after their video195 // started begin, so none shows without having been loaded.196 lateCaptions = 2 * time.Second197)198199// subtitle sets up a video's captions as it starts at span s, skip frames200// in. It returns a subtitle file to burn into the picture, or "" (the201// file is the caller's to remove), and for closed captions, settled,202// closed once they're in place or will come late: the video's picture203// waits for it on its way to the encoder, so the video opens meanwhile.204// settled is nil when there's nothing to wait for.205func subtitle(ctx context.Context, ffmpeg string, it item, skip int64, s *span, out *output) (burn string, settled <-chan struct{}) {206 if it.Captions == nil {207 return "", nil208 }209 offset := frameDuration(skip)210 if !out.app && cc.Foreign(it.AudioLang) && canBurn(ffmpeg) {211 wait, cancel := context.WithTimeout(ctx, burnWait)212 cues, err := it.Captions.get(wait)213 cancel()214 if cues = cc.Shift(cues, -offset); err == nil && len(cues) > 0 {215 if path, err := writeSubtitles(cc.Screen(cues)); err == nil {216 return path, nil217 }218 }219 }220 // Captions not fetched yet get a moment before the video starts, then221 // come in late.222 done := make(chan struct{})223 go func() {224 defer close(done)225 wait, cancel := context.WithTimeout(ctx, captionsWait)226 _, _ = it.Captions.get(wait)227 cancel()228 phase.Step(ctx, "captions")229 if cues, ok := it.Captions.ready(); ok {230 out.captions.set(s, cc.Shift(cues, -offset))231 return232 }233 go func() {234 cues, err := it.Captions.get(ctx)235 if err != nil {236 return237 }238 // They're late: start from a little after now, so every caption239 // that shows has been loaded.240 in := frameDuration(out.video.fed()-s.start) + lateCaptions241 var keep []cc.Cue242 for _, c := range cc.Shift(cues, -offset) {243 if c.Start >= in {244 keep = append(keep, c)245 }246 }247 out.captions.set(s, keep)248 }()249 }()250 return "", done251}252253// writeSubtitles writes cues to a temporary SRT file for ffmpeg's254// subtitles filter.255func writeSubtitles(cues []cc.Cue) (string, error) {256 f, err := os.CreateTemp("", "airwaves-subtitles-*.srt")257 if err != nil {258 return "", err259 }260 _, err = f.Write(cc.FormatSRT(cues))261 if cerr := f.Close(); err == nil {262 err = cerr263 }264 if err == nil && !plainPath.MatchString(f.Name()) {265 err = fmt.Errorf("subtitle path %q needs escaping", f.Name())266 }267 if err != nil {268 os.Remove(f.Name())269 return "", err270 }271 return f.Name(), nil272}273274// plainPath matches paths that go in an ffmpeg filter graph as they are.275var plainPath = regexp.MustCompile(`^[A-Za-z0-9/_.+-]+$`)276277var burns sync.Map // ffmpeg path: bool278279// canBurn reports whether ffmpeg can draw subtitles on the picture (its280// subtitles filter, with libass).281func canBurn(ffmpeg string) bool {282 if ok, found := burns.Load(ffmpeg); found {283 return ok.(bool)284 }285 ok := false286 if path, err := writeSubtitles([]cc.Cue{{End: time.Second, Text: "A"}}); err == nil {287 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)288 err = exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin",289 "-f", "lavfi", "-i", "color=s=64x64:d=0.1", "-vf", "subtitles="+path, "-f", "null", "-").Run()290 cancel()291 os.Remove(path)292 ok = err == nil293 }294 burns.Store(ffmpeg, ok)295 return ok296}