IDK man, changes I guess

This commit is contained in:
2026-11-06 17:57:01 +00:00
parent 9fb9395f99
commit 4c8cafd733
15 changed files with 785 additions and 61 deletions

291
captions.go Normal file
View File

@@ -0,0 +1,291 @@
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()
}