Files
vibedit/captions.go
2026-11-06 17:57:01 +00:00

292 lines
8.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <i>
}
// 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()
}