292 lines
8.6 KiB
Go
292 lines
8.6 KiB
Go
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()
|
||
}
|