package main import ( "bytes" "encoding/binary" "errors" "fmt" "math" "net/http" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "unicode/utf16" "unicode/utf8" ) // Cue is one caption of an .srt file, timed on its video's timeline. type Cue struct { Start float64 `json:"start"` End float64 `json:"end"` Text string `json:"text"` // may contain SRT markup such as } // maxCaptionFile keeps a video picked by mistake from being read as captions. const maxCaptionFile = 20 << 20 // captionSizes are the caption sizes offered for export, in 1/288ths of the // frame's shorter side (288 lines is the height ffmpeg's SRT conversion is // laid out for). web/app.js previews captions with the same numbers. var captionSizes = map[string]float64{"small": 12, "medium": 16, "large": 21} // captionStyles are ASS style overrides: white text with a black outline, or // on black boxes like TV captions. var captionStyles = map[string]string{ "outline": "BorderStyle=1,Outline=1.3,Shadow=0", "box": "BorderStyle=3,Outline=1.5,Shadow=0,OutlineColour=&H00000000", } func (a *App) handleCaptions(w http.ResponseWriter, r *http.Request) { var req struct { Path string `json:"path"` } if err := readJSON(r, &req); err != nil || strings.TrimSpace(req.Path) == "" { writeError(w, http.StatusBadRequest, "missing path") return } path, err := filepath.Abs(strings.TrimSpace(req.Path)) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } cues, err := loadSRT(path) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, map[string]any{"path": path, "name": filepath.Base(path), "cues": cues}) } // loadSRT reads the captions of an .srt file, sorted by start time. func loadSRT(path string) ([]Cue, error) { name := filepath.Base(path) st, err := os.Stat(path) if err != nil { return nil, fmt.Errorf("caption file not found: %s", path) } if st.IsDir() { return nil, fmt.Errorf("%s is a folder", path) } if st.Size() > maxCaptionFile { return nil, fmt.Errorf("%s is too large to be an .srt caption file", name) } b, err := os.ReadFile(path) if err != nil { return nil, err } cues := parseSRT(decodeText(b)) if len(cues) == 0 { return nil, fmt.Errorf("no captions found in %s - is it an .srt file?", name) } return cues, nil } // decodeText returns the text of a caption file as UTF-8. Files with a UTF-16 // byte order mark are converted, and files that are not valid UTF-8 are taken // to be Windows-1252, which most older Western .srt files use. func decodeText(b []byte) string { switch { case bytes.HasPrefix(b, []byte{0xEF, 0xBB, 0xBF}): b = b[3:] case bytes.HasPrefix(b, []byte{0xFF, 0xFE}): return decodeUTF16(b[2:], binary.LittleEndian) case bytes.HasPrefix(b, []byte{0xFE, 0xFF}): return decodeUTF16(b[2:], binary.BigEndian) } if utf8.Valid(b) { return string(b) } r := make([]rune, len(b)) for i, c := range b { r[i] = rune(c) if c >= 0x80 && c < 0xA0 { r[i] = cp1252[c-0x80] } } return string(r) } func decodeUTF16(b []byte, order binary.ByteOrder) string { u := make([]uint16, len(b)/2) for i := range u { u[i] = order.Uint16(b[2*i:]) } return string(utf16.Decode(u)) } // cp1252 maps the bytes 0x80-0x9F of Windows-1252, where it differs from // Latin-1. Unassigned bytes keep their Latin-1 meaning. var cp1252 = [32]rune{ '€', '\u0081', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u008D', 'Ž', '\u008F', '\u0090', '‘', '’', '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', '\u009D', 'ž', 'Ÿ', } // srtTiming matches a cue's timing line, e.g. "00:01:02,500 --> 00:01:04,000". // Hours may be missing and the decimal separator may be a dot, as in WebVTT. var srtTiming = regexp.MustCompile(`^((?:\d+:)?\d{1,2}:\d{1,2}[,.]\d{1,3})\s*-->\s*((?:\d+:)?\d{1,2}:\d{1,2}[,.]\d{1,3})`) // parseSRT splits SRT text into cues: a timing line, then text lines up to // the next blank line. Cue numbers and anything that is not a cue are // ignored, and so is a missing blank line between cues. func parseSRT(s string) []Cue { s = strings.ReplaceAll(s, "\r\n", "\n") s = strings.ReplaceAll(s, "\r", "\n") var cues []Cue var cur *Cue var text []string flush := func() { if cur != nil && len(text) > 0 && cur.End > cur.Start { cur.Text = strings.Join(text, "\n") cues = append(cues, *cur) } cur, text = nil, nil } for _, line := range strings.Split(s, "\n") { line = strings.TrimSpace(line) if m := srtTiming.FindStringSubmatch(line); m != nil { if n := len(text); n > 0 && isDigits(text[n-1]) { text = text[:n-1] // the next cue's number } flush() cur = &Cue{Start: parseSRTTime(m[1]), End: parseSRTTime(m[2])} continue } if line == "" { flush() } else if cur != nil { text = append(text, line) } } flush() sort.SliceStable(cues, func(i, j int) bool { return cues[i].Start < cues[j].Start }) return cues } func isDigits(s string) bool { for _, c := range s { if c < '0' || c > '9' { return false } } return s != "" } // parseSRTTime reads "[hh:]mm:ss,fff" (srtTiming has checked the format). func parseSRTTime(s string) float64 { hms, frac, _ := strings.Cut(strings.Replace(s, ",", ".", 1), ".") var sec float64 for _, p := range strings.Split(hms, ":") { n, _ := strconv.Atoi(p) sec = sec*60 + float64(n) } f, _ := strconv.ParseFloat("0."+frac, 64) return sec + f } // srtTime formats seconds as an SRT timestamp. It rounds down to the // millisecond like the subtitles filter does with frame times, so a caption // that starts on a frame is drawn on that frame. func srtTime(sec float64) string { ms := int64(math.Floor(sec*1000 + 1e-6)) return fmt.Sprintf("%02d:%02d:%02d,%03d", ms/3600000, ms/60000%60, ms/1000%60, ms%1000) } // attachCaptions loads the .srt files the request names (by source id) and // gives each clip the cues of its source. func (a *App) attachCaptions(req *ExportRequest, clips []clipJob) error { if len(req.Captions) == 0 { return nil } if req.Mode == "copy" { return errors.New("lossless export cannot add captions; choose Re-encode or Animated GIF to burn them in") } if !a.tools.HasFilter("subtitles") { return errors.New("this ffmpeg cannot draw captions: it was built without the subtitles filter (libass)") } if _, ok := captionSizes[req.CaptionSize]; !ok { req.CaptionSize = "medium" } if _, ok := captionStyles[req.CaptionStyle]; !ok { req.CaptionStyle = "outline" } bySource := map[string][]Cue{} for id, path := range req.Captions { cues, err := loadSRT(path) if err != nil { return err } bySource[id] = cues } for i := range clips { clips[i].cues = bySource[clips[i].m.ID] } return nil } // writeClipCaptions gives every clip with captions its own .srt file, timed // from the clip's In point, in a new temporary folder. It returns the folder // for the caller to remove, or "" when no clip shows a caption. func writeClipCaptions(clips []clipJob) (string, error) { dir := "" for i, cl := range clips { var b strings.Builder n := 0 for _, c := range cl.cues { start, end := math.Max(c.Start, cl.in)-cl.in, math.Min(c.End, cl.out)-cl.in if end-start < 0.001 { continue } n++ fmt.Fprintf(&b, "%d\n%s --> %s\n%s\n\n", n, srtTime(start), srtTime(end), c.Text) } if n == 0 { continue } if dir == "" { var err error if dir, err = os.MkdirTemp("", "vibedit-captions-"); err != nil { return "", err } } clips[i].captions = filepath.Join(dir, fmt.Sprintf("clip_%04d.srt", i+1)) if err := os.WriteFile(clips[i].captions, []byte(b.String()), 0o644); err != nil { return dir, err } } return dir, nil } // captionFilter draws the captions of an .srt file onto frames of format f. // Sizes are relative to the shorter side of the frame, so portrait videos // get captions the size of landscape ones instead of ones scaled to their // height. func captionFilter(srt string, f outFormat) string { unit := float64(min(f.W, f.H)) / 288 style := fmt.Sprintf("PlayResX=%d,PlayResY=%d,ScaledBorderAndShadow=yes,Fontsize=%g,%s", int(math.Round(float64(f.W)/unit)), int(math.Round(float64(f.H)/unit)), captionSizes[f.CaptionSize], captionStyles[f.CaptionStyle]) return "subtitles=filename=" + filterArg(srt) + ":force_style=" + filterArg(style) } // filterArg escapes an option value for a -filter_complex graph: once for the // filter's option parser, then again for the graph parser. func filterArg(s string) string { return escapeChars(escapeChars(s, `\':`), `\'[],;`) } func escapeChars(s, special string) string { var b strings.Builder for _, r := range s { if strings.ContainsRune(special, r) { b.WriteByte('\\') } b.WriteRune(r) } return b.String() }