diff --git a/README.md b/README.md index d0fd63f..2213936 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # VibEdit -A small video cutter for Windows and Linux built on ffmpeg. Load one or more source videos, mark the parts you want, and export them joined into one file or as separate files. Lossless export copies the original streams. Re-encode export makes frame-exact cuts and lets you change codec, size and quality. Animated GIF export makes a looping GIF without sound. +A small video cutter for Windows and Linux built on ffmpeg. Load one or more source videos, mark the parts you want, and export them joined into one file or as separate files. Lossless export copies the original streams. Re-encode export makes frame-exact cuts and lets you change codec, size and quality. Animated GIF export makes a looping GIF without sound. Load an .srt file with a source video to see its captions while you edit and burn them into Re-encode and GIF exports. ## Using it @@ -53,6 +53,9 @@ go run github.com/tc-hib/go-winres@v0.3.3 make --in winres/winres.json --arch am - **Re-encode:** one ffmpeg run. Each clip is a seeked input; the filter graph normalizes size (letterboxing as needed), frame rate and audio format, then joins with the `concat` filter. Cuts are frame-exact: In includes the frame shown, Out cuts just before the frame shown. - **Animated GIF:** two ffmpeg runs over the same filter graph as Re-encode. The first picks a 256-color palette for the whole animation (`palettegen`), the second maps every frame onto it (`paletteuse`). ffmpeg reports no progress while it builds a palette, so the progress bar only covers the second run. - **Output safety:** outputs are written to `*.vibedit-part.*` and renamed when finished, so a failed or canceled export never leaves a broken file. +- `captions.go` handles captions: + - **Loading:** it parses .srt files in UTF-8, UTF-16 (with a byte order mark) or Windows-1252. The editor draws the current source's captions over the viewer itself, at the size and in the style the export will use. + - **Burning in:** Re-encode and GIF exports write each clip's captions to a temporary .srt timed from its In point. The `subtitles` filter (libass) draws them after scaling, so they get the same size in every clip. Sizes are relative to the shorter side of the frame, so portrait videos get the same size as landscape ones. Lossless export cannot change the picture and refuses captions. - `platform_windows.go` provides native Open/Save dialogs (PowerShell + WinForms, no cgo), drive listing and "Show in folder". - `platform_linux.go` does the same with zenity or kdialog, mounted drives, and the FileManager1 D-Bus call (falling back to `xdg-open` on the folder). It runs ffmpeg in its own process group, so Ctrl+C in the terminal reaches only VibEdit, which then cancels exports cleanly. - Without a dialog program (and on other platforms) the UI uses its built-in file browser. diff --git a/app.go b/app.go index d907381..06fe21c 100644 --- a/app.go +++ b/app.go @@ -98,6 +98,7 @@ func (a *App) routes() { m.HandleFunc("POST /api/dialog/save", a.handleDialogSave) m.HandleFunc("POST /api/reveal", a.handleReveal) m.HandleFunc("POST /api/probe", a.handleProbe) + m.HandleFunc("POST /api/captions", a.handleCaptions) m.HandleFunc("GET /api/media/{id}", a.handleMedia) m.HandleFunc("GET /api/thumb", a.handleThumb) m.HandleFunc("GET /api/keyframes", a.handleKeyframes) @@ -197,6 +198,7 @@ func (a *App) handleInfo(w http.ResponseWriter, r *http.Request) { "nativeDialogs": nativeDialogs, "codecs": a.tools.CodecList(), "codecsReady": a.tools.EncodersReady(), + "captionsOK": a.tools.HasFilter("subtitles"), "containers": containers, "copyExts": copyExts, "mediaExts": mediaExts, @@ -281,7 +283,8 @@ func (a *App) handleSessionPut(w http.ResponseWriter, r *http.Request) { func (a *App) handleDialogOpen(w http.ResponseWriter, r *http.Request) { var req struct { - Dir string `json:"dir"` + Dir string `json:"dir"` + Kind string `json:"kind"` // a key of fileKinds } readJSON(r, &req) if !nativeDialogs { @@ -293,7 +296,7 @@ func (a *App) handleDialogOpen(w http.ResponseWriter, r *http.Request) { return } defer a.dialogMu.Unlock() - paths, err := nativeOpenDialog("Add videos", req.Dir) + paths, err := nativeOpenDialog(fileKindOf(req.Kind), req.Dir) if err != nil { log.Printf("native open dialog failed: %v", err) writeJSON(w, map[string]any{"supported": false, "error": err.Error()}) diff --git a/captions.go b/captions.go new file mode 100644 index 0000000..5cf495d --- /dev/null +++ b/captions.go @@ -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 +} + +// 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() +} diff --git a/captions_test.go b/captions_test.go new file mode 100644 index 0000000..5bb2810 --- /dev/null +++ b/captions_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestParseSRT(t *testing.T) { + src := "\uFEFF1\r\n00:00:01,000 --> 00:00:02,500\r\nHello\r\nworld\r\n\r\n" + + "3\r\n00:00:05,5 --> 00:00:06.25 X1:10 X2:20\r\nSecond\r\n" + // short ms, dot, position + "2\r\n00:00:03,000 --> 00:00:04,000\r\n Missing blank line before \r\n\r\n\r\n" + + "4\n00:00:07,000 --> 00:00:07,000\nzero length\n\n" + + "5\n01:02,000 --> 01:03,000\nno hours\n\n" + + "not a cue\n" + got := parseSRT(decodeText([]byte(src))) + want := []Cue{ + {1, 2.5, "Hello\nworld"}, + {3, 4, "Missing blank line before"}, + {5.5, 6.25, "Second"}, + {62, 63, "no hours"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseSRT:\n got %+v\nwant %+v", got, want) + } +} + +func TestDecodeText(t *testing.T) { + for name, tc := range map[string]struct { + in []byte + want string + }{ + "utf8": {[]byte("caf\u00e9"), "caf\u00e9"}, + "utf8 bom": {[]byte("\xEF\xBB\xBFcaf\u00e9"), "caf\u00e9"}, + "utf16le": {[]byte{0xFF, 0xFE, 'h', 0, 0xE9, 0}, "h\u00e9"}, + "utf16be": {[]byte{0xFE, 0xFF, 0, 'h', 0, 0xE9}, "h\u00e9"}, + "cp1252": {[]byte("\x93caf\xe9\x94 \x80"), "\u201ccaf\u00e9\u201d \u20ac"}, + } { + if got := decodeText(tc.in); got != tc.want { + t.Errorf("%s: got %q, want %q", name, got, tc.want) + } + } +} + +func TestWriteClipCaptions(t *testing.T) { + cues := []Cue{ + {0.5, 1.5, "before"}, + {9, 11, "spans In"}, + {12.0333, 13, "inside"}, + {19, 25, "spans Out"}, + {30, 31, "after"}, + } + clips := []clipJob{{in: 10, out: 20, cues: cues}, {in: 40, out: 50, cues: cues}, {in: 0, out: 5}} + dir, err := writeClipCaptions(clips) + if dir != "" { + defer os.RemoveAll(dir) + } + if err != nil { + t.Fatal(err) + } + if clips[1].captions != "" || clips[2].captions != "" { + t.Errorf("clips without captions in range got files: %q, %q", clips[1].captions, clips[2].captions) + } + b, err := os.ReadFile(clips[0].captions) + if err != nil { + t.Fatal(err) + } + want := "1\n00:00:00,000 --> 00:00:01,000\nspans In\n\n" + + "2\n00:00:02,033 --> 00:00:03,000\ninside\n\n" + + "3\n00:00:09,000 --> 00:00:10,000\nspans Out\n\n" + if string(b) != want { + t.Errorf("clip captions:\n got %q\nwant %q", b, want) + } + if again := parseSRT(string(b)); len(again) != 3 { + t.Errorf("written file parses into %d cues, want 3", len(again)) + } +} + +func TestSRTTime(t *testing.T) { + for sec, want := range map[float64]string{ + 0: "00:00:00,000", + 1.0333333: "00:00:01,033", + 2.9999: "00:00:02,999", // rounds down, like the subtitles filter + 0.1 + 0.2: "00:00:00,300", // but not below float noise + 3723.5: "01:02:03,500", + 36000.0005: "10:00:00,000", + } { + if got := srtTime(sec); got != want { + t.Errorf("srtTime(%v) = %s, want %s", sec, got, want) + } + } +} + +func TestFilterArg(t *testing.T) { + // Both parsers turn a backslash followed by any character into that + // character, so undoing the two levels must give back the original. + unescape := func(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) { + i++ + } + b.WriteByte(s[i]) + } + return b.String() + } + for _, s := range []string{ + `/tmp/vibedit-captions-1/clip_0001.srt`, + `C:\Users\Zoë O'Brien\AppData\Local\Temp\vibedit-captions-1\clip_0001.srt`, + `/weird/[dir];a,b:c'd\e/clip.srt`, + `Fontsize=16,BorderStyle=3,OutlineColour=&H00000000`, + } { + esc := filterArg(s) + if got := unescape(unescape(esc)); got != s { + t.Errorf("filterArg(%q) = %q, which unescapes to %q", s, esc, got) + } + // After the graph level, no character that ends the option value may + // be left unescaped. + lvl1 := unescape(esc) + for i := 0; i < len(lvl1); i++ { + if lvl1[i] == '\\' { + i++ + } else if strings.IndexByte(`':`, lvl1[i]) >= 0 { + t.Errorf("filterArg(%q): unescaped %q at option level: %q", s, lvl1[i], lvl1) + } + } + } + if got := filterArg(`C:\a,b`); got != `C\\:\\\\a\,b` { + t.Errorf("filterArg(`C:\\a,b`) = %s", got) + } +} + +func TestCaptionFilter(t *testing.T) { + dir := t.TempDir() + srt := filepath.Join(dir, "clip_0001.srt") + for _, tc := range []struct { + w, h int + want string + }{ + {1920, 1080, "PlayResX=512\\,PlayResY=288\\,ScaledBorderAndShadow=yes\\,Fontsize=16\\,"}, + {1080, 1920, "PlayResX=288\\,PlayResY=512\\,ScaledBorderAndShadow=yes\\,Fontsize=16\\,"}, + } { + got := captionFilter(srt, outFormat{W: tc.w, H: tc.h, CaptionSize: "medium", CaptionStyle: "box"}) + if !strings.HasPrefix(got, "subtitles=filename=") || !strings.Contains(got, ":force_style="+tc.want+"BorderStyle=3") { + t.Errorf("%dx%d: %s", tc.w, tc.h, got) + } + } +} diff --git a/export.go b/export.go index 88ce012..1a45077 100644 --- a/export.go +++ b/export.go @@ -41,14 +41,20 @@ type ExportRequest struct { // animated GIF (also uses Height and FPS) Dither string `json:"dither"` // one of gifDithers Loop bool `json:"loop"` + // captions burned into the picture (re-encode and GIF only) + Captions map[string]string `json:"captions"` // source id -> .srt path + CaptionSize string `json:"captionSize"` // key of captionSizes + CaptionStyle string `json:"captionStyle"` // key of captionStyles } // gifDithers are the paletteuse dither modes offered for GIF export. var gifDithers = []string{"sierra2_4a", "bayer", "none"} type clipJob struct { - m *Media - in, out float64 + m *Media + in, out float64 + cues []Cue // captions to burn in, on the source's timeline + captions string // .srt file with the clip's cues, timed from In (see writeClipCaptions) } func (c clipJob) dur() float64 { return c.out - c.in } @@ -109,6 +115,10 @@ func (a *App) handleExport(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "unknown export mode") return } + if err := a.attachCaptions(&req, clips); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } outputs := []string{out} if !req.Join { @@ -334,6 +344,14 @@ func (a *App) runExport(ctx context.Context, c *JobCtl, req *ExportRequest, clip } } + captionDir, err := writeClipCaptions(clips) + if captionDir != "" { + defer os.RemoveAll(captionDir) + } + if err != nil { + return fmt.Errorf("could not prepare the captions: %w", err) + } + switch { case copyJoin: out := outputs[0] @@ -527,6 +545,7 @@ func encodeArgs(clips []clipJob, req *ExportRequest, ext string) ([]string, erro } anyAudio = anyAudio && !req.NoAudio + f.CaptionSize, f.CaptionStyle = req.CaptionSize, req.CaptionStyle vc, _ := codecByName(req.VCodec) var venc []string venc, f.PixFmt = videoEncoderArgs(vc, req.Quality, req.Speed) @@ -567,6 +586,7 @@ func gifArgs(clips []clipJob, req *ExportRequest, palette string) (paletteRun, g return nil, nil, err } f.PixFmt = "bgra" // what palettegen and paletteuse take + f.CaptionSize, f.CaptionStyle = req.CaptionSize, req.CaptionStyle inputs, graph, n := joinGraph(clips, f) paletteRun = append(ffmpegBaseArgs(), inputs...) @@ -593,6 +613,8 @@ type outFormat struct { PixFmt string SampleRate int Layout string + // how burned-in captions look (keys of captionSizes and captionStyles) + CaptionSize, CaptionStyle string } // frameFormat picks the output frame size and rate: the first video clip's @@ -625,7 +647,8 @@ func frameFormat(clips []clipJob, height int, fps string) (outFormat, error) { // joinGraph returns the input arguments (one seeked input per clip, plus // generated black video or silence for clips that lack it), how many inputs // that is, and a filter graph that converts every clip to f, letterboxing as -// needed, and joins them with the concat filter into [outv] (and [outa]). +// needed and drawing its captions, and joins them with the concat filter into +// [outv] (and [outa]). func joinGraph(clips []clipJob, f outFormat) (args []string, graph string, inputs int) { var filters []string var concatIn strings.Builder @@ -637,17 +660,24 @@ func joinGraph(clips []clipJob, f outFormat) (args []string, graph string, input src := inputs inputs++ + // Captions are drawn on the output-sized frame, so they get the same + // size in every clip. After fps the frames are timed from the In point + // on the output frame grid, which is how writeClipCaptions times them. + captions := "" + if cl.captions != "" { + captions = "," + captionFilter(cl.captions, f) + } if v := cl.m.Video; v != nil { chain := fmt.Sprintf("[%d:%d]", src, v.Index) if !v.SquarePixels { chain += "scale='trunc(iw*sar/2)*2':ih,setsar=1," } - chain += fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=%d:%d:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,fps=%s,format=%s[v%d]", - f.W, f.H, f.W, f.H, f.FPS, f.PixFmt, i) + chain += fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=%d:%d:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,fps=%s%s,format=%s[v%d]", + f.W, f.H, f.W, f.H, f.FPS, captions, f.PixFmt, i) filters = append(filters, chain) } else { args = append(args, "-f", "lavfi", "-t", d, "-i", fmt.Sprintf("color=c=black:s=%dx%d:r=%s", f.W, f.H, f.FPS)) - filters = append(filters, fmt.Sprintf("[%d:v]setsar=1,format=%s[v%d]", inputs, f.PixFmt, i)) + filters = append(filters, fmt.Sprintf("[%d:v]setsar=1%s,format=%s[v%d]", inputs, captions, f.PixFmt, i)) inputs++ } fmt.Fprintf(&concatIn, "[v%d]", i) diff --git a/ffmpeg.go b/ffmpeg.go index f856024..16b390e 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -100,6 +100,7 @@ type Tools struct { mu sync.Mutex encoders map[string]bool // usable encoders (hardware ones are test-encoded) + filters map[string]bool // filters compiled into ffmpeg ready bool } @@ -162,6 +163,12 @@ func (t *Tools) HasEncoder(name string) bool { return t.encoders[name] } +func (t *Tools) HasFilter(name string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.filters[name] +} + // CodecList returns the catalog entries this ffmpeg build can use. func (t *Tools) CodecList() []Codec { t.mu.Lock() @@ -175,9 +182,9 @@ func (t *Tools) CodecList() []Codec { return out } -// detectEncoders lists the encoders compiled into ffmpeg and test-encodes a -// few frames with each hardware encoder, since being compiled in does not mean -// the GPU is present. +// detectEncoders lists the encoders and filters compiled into ffmpeg and +// test-encodes a few frames with each hardware encoder, since being compiled +// in does not mean the GPU is present. func (t *Tools) detectEncoders() { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() @@ -191,6 +198,14 @@ func (t *Tools) detectEncoders() { } } } + filters := map[string]bool{} + if out, err := command(ctx, t.FFmpeg, "-hide_banner", "-filters").Output(); err == nil { + for _, line := range strings.Split(string(out), "\n") { + if f := strings.Fields(line); len(f) >= 3 && strings.Contains(f[2], "->") { + filters[f[1]] = true + } + } + } usable := map[string]bool{} var wg sync.WaitGroup var mu sync.Mutex @@ -220,6 +235,7 @@ func (t *Tools) detectEncoders() { wg.Wait() t.mu.Lock() t.encoders = usable + t.filters = filters t.ready = true t.mu.Unlock() } diff --git a/fs.go b/fs.go index ae19c6a..8a55b85 100644 --- a/fs.go +++ b/fs.go @@ -9,6 +9,31 @@ import ( "strings" ) +// fileKind is a type of file the editor opens: what the open dialog and the +// built-in file browser show. +type fileKind struct { + Title string + Label string // file filter name + Exts []string + Multiple bool +} + +var fileKinds = map[string]fileKind{ + "video": {Title: "Add videos", Label: "Video files", Exts: mediaExts, Multiple: true}, + "captions": {Title: "Load captions", Label: "Subtitle files", Exts: captionExts}, +} + +// captionExts are the caption files the editor loads (see captions.go). +var captionExts = []string{"srt"} + +// fileKindOf returns the named file kind, videos by default. +func fileKindOf(name string) fileKind { + if k, ok := fileKinds[name]; ok { + return k + } + return fileKinds["video"] +} + type fsEntry struct { Name string `json:"name"` Path string `json:"path"` @@ -23,6 +48,7 @@ func (a *App) handleFSList(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() dir := strings.TrimSpace(q.Get("path")) showAll := q.Get("all") == "1" + exts := fileKindOf(q.Get("kind")).Exts home, _ := os.UserHomeDir() if dir == "" { dir = home @@ -57,7 +83,7 @@ func (a *App) handleFSList(w http.ResponseWriter, r *http.Request) { } if !isD && !showAll { ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")) - if !slices.Contains(mediaExts, ext) { + if !slices.Contains(exts, ext) { continue } } diff --git a/linux/README.txt b/linux/README.txt index 522c320..49e0d31 100644 --- a/linux/README.txt +++ b/linux/README.txt @@ -39,6 +39,10 @@ How to cut cuts; choose resolution, frame rate, dithering and whether it loops. Keep GIFs short - the files get large quickly. - Join everything into one file, or save each clip separately. +5. Captions: click "+ CC" on a source video and pick its .srt subtitle + file. The captions show in the viewer, and Re-encode and Animated GIF + exports burn them into the picture, timed to each clip (choose their + size and style in the Export window). Lossless export can't add them. Press the ? button in the editor for all keyboard shortcuts. Your project is saved automatically (in ~/.config/vibedit) and restored the next time you diff --git a/package-readme.txt b/package-readme.txt index bfcd594..ada8a4d 100644 --- a/package-readme.txt +++ b/package-readme.txt @@ -34,6 +34,10 @@ How to cut cuts; choose resolution, frame rate, dithering and whether it loops. Keep GIFs short - the files get large quickly. - Join everything into one file, or save each clip separately. +5. Captions: click "+ CC" on a source video and pick its .srt subtitle + file. The captions show in the viewer, and Re-encode and Animated GIF + exports burn them into the picture, timed to each clip (choose their + size and style in the Export window). Lossless export can't add them. Press the ? button in the editor for all keyboard shortcuts. Your project is saved automatically and restored the next time you start VibEdit. diff --git a/platform_linux.go b/platform_linux.go index 433a766..92beb81 100644 --- a/platform_linux.go +++ b/platform_linux.go @@ -158,27 +158,34 @@ func runDialog(args ...string) ([]string, error) { return lines, nil } -// videoPatterns lists every media extension in lower and upper case, since -// GTK file filters are case-sensitive. -func videoPatterns() string { +// filePatterns lists every extension in lower and upper case, since GTK file +// filters are case-sensitive. +func filePatterns(exts []string) string { var pats []string - for _, e := range mediaExts { + for _, e := range exts { pats = append(pats, "*."+e, "*."+strings.ToUpper(e)) } return strings.Join(pats, " ") } -func nativeOpenDialog(title, dir string) ([]string, error) { +func nativeOpenDialog(k fileKind, dir string) ([]string, error) { if !isDir(dir) { dir, _ = os.UserHomeDir() } + pats := filePatterns(k.Exts) if isKDialog() { - return runDialog("--title", title, "--multiple", "--separate-output", - "--getopenfilename", dir, "Video files ("+videoPatterns()+")\nAll files (*)") + args := []string{"--title", k.Title} + if k.Multiple { + args = append(args, "--multiple", "--separate-output") + } + return runDialog(append(args, "--getopenfilename", dir, k.Label+" ("+pats+")\nAll files (*)")...) } - return runDialog("--file-selection", "--multiple", "--separator=\n", "--title="+title, - "--filename="+strings.TrimSuffix(dir, "/")+"/", - "--file-filter=Video files | "+videoPatterns(), "--file-filter=All files | *") + args := []string{"--file-selection", "--title=" + k.Title} + if k.Multiple { + args = append(args, "--multiple", "--separator=\n") + } + return runDialog(append(args, "--filename="+strings.TrimSuffix(dir, "/")+"/", + "--file-filter="+k.Label+" | "+pats, "--file-filter=All files | *")...) } func nativeSaveDialog(title, path string) (string, error) { diff --git a/platform_other.go b/platform_other.go index 12a3046..c5eb818 100644 --- a/platform_other.go +++ b/platform_other.go @@ -33,6 +33,6 @@ func waitBeforeExit() {} var errNoDialogs = errors.New("native file dialogs are not supported on this platform") -func nativeOpenDialog(title, dir string) ([]string, error) { return nil, errNoDialogs } +func nativeOpenDialog(k fileKind, dir string) ([]string, error) { return nil, errNoDialogs } func nativeSaveDialog(title, path string) (string, error) { return "", errNoDialogs } diff --git a/platform_windows.go b/platform_windows.go index 78da3d9..4352245 100644 --- a/platform_windows.go +++ b/platform_windows.go @@ -98,7 +98,7 @@ $owner.ShowInTaskbar = $false const psOpenDialog = psDialogPrelude + ` $d = New-Object System.Windows.Forms.OpenFileDialog $d.Title = $env:VIBEDIT_TITLE -$d.Multiselect = $true +$d.Multiselect = ($env:VIBEDIT_MULTI -eq '1') $d.Filter = $env:VIBEDIT_FILTER if ($env:VIBEDIT_DIR -and (Test-Path -LiteralPath $env:VIBEDIT_DIR -PathType Container)) { $d.InitialDirectory = $env:VIBEDIT_DIR } if ($d.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) { @@ -148,18 +148,23 @@ func runPowerShell(script string, env map[string]string) ([]string, error) { return lines, nil } -func videoFilter() string { +func fileFilter(k fileKind) string { var pats []string - for _, e := range mediaExts { + for _, e := range k.Exts { pats = append(pats, "*."+e) } - return "Video files|" + strings.Join(pats, ";") + "|All files|*.*" + return k.Label + "|" + strings.Join(pats, ";") + "|All files|*.*" } -func nativeOpenDialog(title, dir string) ([]string, error) { +func nativeOpenDialog(k fileKind, dir string) ([]string, error) { + multi := "0" + if k.Multiple { + multi = "1" + } return runPowerShell(psOpenDialog, map[string]string{ - "VIBEDIT_TITLE": title, - "VIBEDIT_FILTER": videoFilter(), + "VIBEDIT_TITLE": k.Title, + "VIBEDIT_FILTER": fileFilter(k), + "VIBEDIT_MULTI": multi, "VIBEDIT_DIR": dir, }) } diff --git a/web/app.js b/web/app.js index a3bff5f..cbd0299 100644 --- a/web/app.js +++ b/web/app.js @@ -121,6 +121,7 @@ const DEFAULTS = { mode: 'copy', join: true, output: '', allAudio: true, noAudio: false, vcodec: 'libx264', quality: 20, speed: 'balanced', height: 0, fps: '', acodec: 'aac', abitrate: 192, gifHeight: 480, gifFps: '15', gifDither: 'sierra2_4a', gifLoop: true, + burnCaptions: true, captionSize: 'medium', captionStyle: 'outline', lastDir: '', volume: 1, muted: false, }; const PALETTE = ['#4c8dff', '#3ccf91', '#ff8a4c', '#c77dff', '#ffd23f', '#ff5d8f', '#2ec4d6', '#a3d14b']; @@ -129,6 +130,7 @@ const S = { info: null, sources: [], // probed media (see media.go Media) clips: [], // {id, src, in, out} + captions: {}, // source id -> {path, name, cues: [{start, end, text}]} (a loaded .srt file) cur: null, // source id shown in the viewer marks: {}, // source id -> {in, out} (scratch selection) sel: null, // selected clip id (being edited) @@ -185,7 +187,7 @@ const thumbURL = (id, t, hgt) => `/api/thumb?id=${id}&t=${Math.max(0, t).toFixed // ---------------------------------------------------------------- undo function snapshot() { - return { clips: S.clips.map(c => ({ ...c })), sources: S.sources.slice(), sel: S.sel }; + return { clips: S.clips.map(c => ({ ...c })), sources: S.sources.slice(), captions: { ...S.captions }, sel: S.sel }; } function commit(fn) { S.undo.push(snapshot()); @@ -197,6 +199,7 @@ function commit(fn) { function restoreSnap(s) { S.clips = s.clips; S.sources = s.sources; + S.captions = s.captions; S.sel = s.sel && S.clips.some(c => c.id === s.sel) ? s.sel : null; if (!srcById(S.cur)) { S.cur = null; @@ -236,26 +239,27 @@ function confirmDlg(text, okLabel = 'OK') { } // ---------------------------------------------------------------- sources -async function addFiles(builtin = false) { - if (!S.info.ffmpegOK) return toast('ffmpeg was not found — see the message at the top of the window.', 'error'); +// pickFiles asks for files to open, with the native dialog when there is one +// and else with the built-in browser. kind is 'video' or 'captions'. +async function pickFiles({ kind = 'video', dir = '', builtin = false } = {}) { if (!builtin && S.info.nativeDialogs) { try { - const dir = S.settings.lastDir || (cur() ? splitPath(cur().path)[0] : ''); - const r = await api('/api/dialog/open', { dir }); - if (r.supported) { - if (r.paths && r.paths.length) { - S.settings.lastDir = splitPath(r.paths[0])[0]; - await addPaths(r.paths); - } - return; - } + const r = await api('/api/dialog/open', { dir, kind }); + if (r.supported) return r.paths || []; if (r.error) toast('The file dialog could not be opened, using the built-in browser instead.', 'error'); } catch (e) { - if (e.status === 409) return; + if (e.status === 409) return []; } } - const paths = await fileBrowser({ mode: 'open' }); - if (paths && paths.length) await addPaths(paths); + return await fileBrowser({ mode: 'open', kind, dir }) || []; +} + +async function addFiles(builtin = false) { + if (!S.info.ffmpegOK) return toast('ffmpeg was not found — see the message at the top of the window.', 'error'); + const paths = await pickFiles({ dir: S.settings.lastDir || (cur() ? splitPath(cur().path)[0] : ''), builtin }); + if (!paths.length) return; + S.settings.lastDir = splitPath(paths[0])[0]; + await addPaths(paths); } async function addPaths(paths) { @@ -292,6 +296,26 @@ async function removeSource(id) { } } +async function loadCaptions(id) { + const m = srcById(id); + if (!m) return; + const old = S.captions[id]; + const [path] = await pickFiles({ kind: 'captions', dir: splitPath(old ? old.path : m.path)[0] }); + if (!path || !srcById(id)) return; + try { + const c = await api('/api/captions', { path }); + commit(() => { S.captions[id] = c; }); + if (S.cur !== id) loadSource(id); + toast(`Loaded ${c.cues.length} caption${c.cues.length !== 1 ? 's' : ''} from "${c.name}"`, 'ok'); + } catch (e) { + toast(e.message, 'error'); + } +} + +function removeCaptions(id) { + commit(() => { delete S.captions[id]; }); +} + function addWhole(id) { const m = srcById(id); if (!m) return; @@ -303,7 +327,7 @@ function addWhole(id) { async function newProject() { if ((S.sources.length || S.clips.length) && !await confirmDlg('Start a new, empty project?\nThis removes all sources and clips from the editor (the video files are not touched). You can undo this.', 'New project')) return; - commit(() => { S.sources = []; S.clips = []; S.sel = null; }); + commit(() => { S.sources = []; S.clips = []; S.captions = {}; S.sel = null; }); S.marks = {}; S.settings.output = ''; S.cur = null; @@ -1014,6 +1038,7 @@ function renderSources() { const meta = [fmtLen(m.duration), v ? `${v.displayWidth}×${v.displayHeight}` : 'audio only', v ? v.codec : (m.audio[0] && m.audio[0].codec), v ? fmtFps(v.fps) : ''].filter(Boolean).join(' · '); const k = S.kf[m.id]; + const cap = S.captions[m.id]; ul.append(h('li', { class: 'src' + (m.id === S.cur ? ' cur' : ''), 'data-id': m.id, title: m.path, style: `--c:${m.color}` }, h('div', { class: 'src-thumb' }, v ? h('img', { src: thumbURL(m.id, Math.min(m.duration * 0.1, 30), 108), alt: '', loading: 'lazy', draggable: 'false' }) @@ -1023,6 +1048,9 @@ function renderSources() { h('div', { class: 'src-meta' }, meta), h('div', { class: 'src-actions' }, h('button', { class: 'chip-btn', 'data-sact': 'whole', title: 'Add the whole video as one clip' }, icon('plus'), 'Whole'), + cap ? h('button', { class: 'chip-btn on', 'data-sact': 'captions', title: `Captions: ${cap.name} (${cap.cues.length})\nClick to load a different .srt file.` }, 'CC', + h('span', { class: 'chip-x', 'data-sact': 'nocaptions', title: 'Remove the captions' }, icon('x'))) + : h('button', { class: 'chip-btn', 'data-sact': 'captions', title: 'Load captions (.srt) for this video' }, icon('plus'), 'CC'), n ? h('span', { class: 'src-status' }, `${n} clip${n > 1 ? 's' : ''}`) : null, k && k.status === 'pending' ? h('span', { class: 'src-status' }, 'reading keyframes…') : null)), h('button', { class: 'icon-btn src-x', 'data-sact': 'remove', title: 'Remove from project' }, icon('x')))); @@ -1097,6 +1125,7 @@ function renderMarks() { } function renderTime() { + renderCaptions(); const m = cur(); tcCurEl.textContent = fmtT(m ? S.time : 0); $('#tcDur').textContent = fmtT(m ? m.duration : 0); @@ -1132,6 +1161,7 @@ function renderScreen() { msg = j ? `This format can't be played by the browser directly.\nCreating a preview copy… ${Math.round((j.progress || 0) * 100)}% (you can already scrub and cut)` : 'Preview not available — showing still frames.'; } + layoutCaptions(); const el = $('#screenMsg'); el.textContent = msg; el.style.whiteSpace = 'pre-line'; @@ -1154,6 +1184,64 @@ function renderTopbar() { } } +// ---------------------------------------------------------------- captions preview +// The viewer shows the current source's captions the way an export burns them +// in (captionFilter in captions.go). Sizes are in 1/288ths of the picture's +// shorter side and match captionSizes there; libass makes each line as tall as +// the font size, which for Arial is 1.117 em. +const CAPTION_SIZES = { small: 12, medium: 16, large: 21 }; +const capEl = $('#captions'); +let capShown = null; + +function renderCaptions() { + const m = cur(); + const c = m && S.captions[m.id]; + const t = S.time + 1e-3; + const cues = c ? c.cues.filter(q => q.start <= t && t < q.end) : []; + const key = cues.map(q => q.text).join('\0'); + if (key === capShown) return; + capShown = key; + capEl.replaceChildren(...cues.map(q => h('div', {}, captionNodes(q.text)))); + capEl.classList.toggle('hidden', !cues.length); +} + +// layoutCaptions places the captions over the picture inside the viewer. +function layoutCaptions() { + const scr = $('#screen'); + const W = scr.clientWidth, H = scr.clientHeight; + let w = W, hh = H; + const v = cur() && cur().video; + if (v && v.displayWidth && v.displayHeight) { + const a = v.displayWidth / v.displayHeight; + if (W / H > a) w = H * a; else hh = W / a; + } + const u = Math.min(w, hh) / 288; + const size = (CAPTION_SIZES[S.settings.captionSize] || CAPTION_SIZES.medium) * u; + const margin = 10 * u; // ffmpeg's default MarginL/R/V + Object.assign(capEl.style, { + left: (W - w) / 2 + margin + 'px', width: Math.max(0, w - 2 * margin) + 'px', bottom: (H - hh) / 2 + margin + 'px', + fontSize: size / 1.117 + 'px', lineHeight: size + 'px', + }); + capEl.style.setProperty('--u', u + 'px'); + capEl.classList.toggle('box', S.settings.captionStyle === 'box'); +} +new ResizeObserver(layoutCaptions).observe($('#screen')); + +// captionNodes keeps the , and tags of SRT caption text and drops +// other tags and {\...} override codes. +function captionNodes(text) { + const root = h('span'); + let el = root; + for (const part of text.replace(/\{\\[^}]*\}/g, '').split(/(<\/?[a-z]+[^>]*>)/i)) { + const tag = /^<(\/?)([a-z]+)[^>]*>$/i.exec(part); + if (!tag) el.append(part); + else if (!['i', 'b', 'u'].includes(tag[2].toLowerCase())) continue; + else if (!tag[1]) el = el.appendChild(h(tag[2].toLowerCase())); + else if (el !== root) el = el.parentNode; + } + return root; +} + // ---------------------------------------------------------------- editable time fields function makeEditable(el, getValue, onCommit) { el.addEventListener('click', () => { @@ -1206,6 +1294,10 @@ function exportExts(mode) { function firstClipSource() { return srcById(S.clips[0] && S.clips[0].src) || cur(); } +// willBurnCaptions tells whether the export draws captions into the picture. +const willBurnCaptions = () => S.settings.mode !== 'copy' && S.settings.burnCaptions && S.info.captionsOK && + S.clips.some(c => S.captions[c.src]); + function defaultOutput() { const first = firstClipSource(); if (!first) return ''; @@ -1282,6 +1374,8 @@ function fillExport() { $('#exGifFPS').value = st.gifFps; $('#exGifDither').value = st.gifDither; $('#exGifLoop').value = st.gifLoop ? '1' : '0'; + $('#exCapSize').value = st.captionSize; + $('#exCapStyle').value = st.captionStyle; updateExportView(); } @@ -1317,7 +1411,23 @@ function updateExportView() { $('#exEncOpts').classList.toggle('hidden', st.mode !== 'encode'); $('#exGifOpts').classList.toggle('hidden', !gif); const result = st.join || n === 1 ? (gif ? 'one animated GIF' : 'one video file') : n + (gif ? ' separate GIFs' : ' separate files'); - $('#exSummary').textContent = `${n} clip${n !== 1 ? 's' : ''} · ${fmtLen(total)} total → ${result}`; + $('#exSummary').textContent = `${n} clip${n !== 1 ? 's' : ''} · ${fmtLen(total)} total → ${result}${willBurnCaptions() ? ' with captions' : ''}`; + + const capFiles = [...new Set(S.clips.map(c => S.captions[c.src]).filter(Boolean))]; + const capOK = !copy && S.info.captionsOK; + let capHint = ''; + if (copy) capHint = 'Lossless export copies the picture unchanged, so it can\'t add captions. Choose Re-encode or GIF to burn them in.'; + else if (!S.info.codecsReady) capHint = 'Checking whether ffmpeg can draw captions…'; + else if (!S.info.captionsOK) capHint = 'This ffmpeg can\'t draw captions: it was built without libass (the subtitles filter).'; + else if (st.burnCaptions) { + const none = S.clips.filter(c => !S.captions[c.src]).length; + capHint = 'From ' + capFiles.map(c => c.name).join(', ') + + (none ? ` · ${none} clip${none > 1 ? 's come' : ' comes'} from a video without captions` : ''); + } + $('#exCapOpts').classList.toggle('hidden', !capFiles.length); + $('#exCaptions').checked = capOK && st.burnCaptions; + $('#exCapHint').textContent = capHint; + $('#exCapLook').classList.toggle('hidden', !capOK || !st.burnCaptions); let outHint = ''; if (!st.join && n > 1 && st.output) { @@ -1378,7 +1488,10 @@ function updateExportView() { $('#exGo').disabled = running || !n; $('#exCancel').classList.toggle('hidden', !running); $$('#exportForm input, #exportForm select, #exBrowse').forEach(el => { el.disabled = running; }); - if (!running) $('#exABitrate').disabled = st.acodec === 'none' || st.acodec === 'flac'; + if (!running) { + $('#exABitrate').disabled = st.acodec === 'none' || st.acodec === 'flac'; + $('#exCaptions').disabled = !capOK; + } } function renderExportProgress() { @@ -1422,7 +1535,11 @@ async function startExport(overwrite = false) { height: gif ? +st.gifHeight : +st.height, fps: gif ? st.gifFps : st.fps, acodec: st.acodec, abitrate: +st.abitrate, dither: st.gifDither, loop: st.gifLoop, + captions: {}, captionSize: st.captionSize, captionStyle: st.captionStyle, }; + if (willBurnCaptions()) { + for (const c of S.clips) if (S.captions[c.src]) body.captions[c.src] = S.captions[c.src].path; + } saveSoon(); try { const r = await api('/api/export', body); @@ -1495,6 +1612,9 @@ function bindExport() { $('#exGifFPS').addEventListener('change', e => { st().gifFps = e.target.value; refill(); }); $('#exGifDither').addEventListener('change', e => { st().gifDither = e.target.value; refill(); }); $('#exGifLoop').addEventListener('change', e => { st().gifLoop = e.target.value === '1'; refill(); }); + $('#exCaptions').addEventListener('change', e => { st().burnCaptions = e.target.checked; refill(); }); + $('#exCapSize').addEventListener('change', e => { st().captionSize = e.target.value; refill(); layoutCaptions(); }); + $('#exCapStyle').addEventListener('change', e => { st().captionStyle = e.target.value; refill(); layoutCaptions(); }); $('#exGo').addEventListener('click', () => startExport(false)); $('#exCancel').addEventListener('click', () => { if (S.exportJob) api('/api/job/cancel', { id: S.exportJob.id }).catch(() => {}); @@ -1519,13 +1639,16 @@ function setMode(mode) { const dlgF = $('#dlgFiles'); let fb = null; -function fileBrowser({ mode = 'open', path = '' } = {}) { +// fileBrowser picks files to open (of a kind: 'video' or 'captions') or the +// path to save an export to. +function fileBrowser({ mode = 'open', kind = 'video', dir = '', path = '' } = {}) { return new Promise(resolve => { - fb = { mode, resolve, sel: new Set(), path: '', entries: [], parent: '' }; - $('#fbTitle').textContent = mode === 'open' ? 'Add videos' : 'Export as'; - $('#fbOk').textContent = mode === 'open' ? 'Add' : 'Save here'; + const caps = kind === 'captions'; + fb = { mode, kind, resolve, sel: new Set(), path: '', entries: [], parent: '' }; + $('#fbTitle').textContent = mode === 'save' ? 'Export as' : caps ? 'Load captions' : 'Add videos'; + $('#fbOk').textContent = mode === 'save' ? 'Save here' : caps ? 'Load' : 'Add'; $('#fbSaveRow').classList.toggle('hidden', mode !== 'save'); - let dir = S.settings.lastDir || ''; + dir = dir || S.settings.lastDir || ''; if (mode === 'save' && path) { const [d, n] = splitPath(path); dir = d; @@ -1543,7 +1666,7 @@ async function fbLoad(path) { if (!fb) return; const seq = ++fbSeq; try { - const r = await api('/api/fs/list?path=' + encodeURIComponent(path) + ($('#fbAll').checked ? '&all=1' : '')); + const r = await api('/api/fs/list?path=' + encodeURIComponent(path) + '&kind=' + fb.kind + ($('#fbAll').checked ? '&all=1' : '')); if (!fb || seq !== fbSeq) return; // closed, or a newer listing was requested Object.assign(fb, { path: r.path, entries: r.entries, parent: r.parent }); fb.sel.clear(); @@ -1563,7 +1686,8 @@ async function fbLoad(path) { function fbRender() { const ul = $('#fbList'); ul.replaceChildren(); - if (!fb.entries.length) ul.append(h('li', { class: 'fb-empty' }, fb.mode === 'open' ? 'No videos in this folder' : 'This folder is empty')); + const none = fb.mode !== 'open' ? 'This folder is empty' : fb.kind === 'captions' ? 'No .srt files in this folder' : 'No videos in this folder'; + if (!fb.entries.length) ul.append(h('li', { class: 'fb-empty' }, none)); for (const en of fb.entries) { ul.append(h('li', { class: (en.dir ? 'dir' : 'file') + (fb.sel.has(en.path) ? ' sel' : ''), 'data-path': en.path, 'data-dir': en.dir ? '1' : null }, icon(en.dir ? 'folder' : 'film'), h('span', { class: 'fb-name' }, en.name), @@ -1584,7 +1708,7 @@ function fbFinish() { if (!name) return toast('Type a file name.'); result = joinPath(fb.path, name); } - S.settings.lastDir = fb.path; + if (fb.kind !== 'captions') S.settings.lastDir = fb.path; const f = fb; fb = null; dlgF.close(); @@ -1598,7 +1722,9 @@ function bindFileBrowser() { const p = li.dataset.path; if (li.dataset.dir) return fbLoad(p); if (fb.mode === 'open') { - if (fb.sel.has(p)) fb.sel.delete(p); else fb.sel.add(p); + const had = fb.sel.has(p); + if (fb.kind === 'captions') fb.sel.clear(); // one caption file at a time + if (had) fb.sel.delete(p); else fb.sel.add(p); } else { $('#fbName').value = splitPath(p)[1]; } @@ -1607,8 +1733,10 @@ function bindFileBrowser() { $('#fbList').addEventListener('dblclick', e => { const li = e.target.closest('li[data-path]'); if (!li || li.dataset.dir || !fb) return; - if (fb.mode === 'open') fb.sel.add(li.dataset.path); - else $('#fbName').value = splitPath(li.dataset.path)[1]; + if (fb.mode === 'open') { + if (fb.kind === 'captions') fb.sel.clear(); + fb.sel.add(li.dataset.path); + } else $('#fbName').value = splitPath(li.dataset.path)[1]; fbFinish(); }); $('#fbOk').addEventListener('click', fbFinish); @@ -1683,6 +1811,7 @@ function saveNow() { current: cur() ? cur().path : null, time: S.time, marks, + captions: Object.fromEntries(S.sources.filter(m => S.captions[m.id]).map(m => [m.path, S.captions[m.id].path])), settings: S.settings, }; fetch('/api/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), keepalive: true }).catch(() => {}); @@ -1706,10 +1835,17 @@ async function restoreSession() { for (const [p, mk] of Object.entries(s.marks || {})) { if (byPath[p]) S.marks[byPath[p]] = { in: mk.in ?? null, out: mk.out ?? null }; } + const caps = Object.entries(s.captions || {}).filter(([p]) => byPath[p]); + const loaded = await Promise.all(caps.map(([, srt]) => api('/api/captions', { path: srt }).catch(() => null))); + const lost = []; + caps.forEach(([p, srt], i) => { if (loaded[i]) S.captions[byPath[p]] = loaded[i]; else lost.push(srt); }); if (missing.length) { toast(`${missing.length} video${missing.length > 1 ? 's' : ''} from your last session could not be found:\n` + missing.map(p => splitPath(p)[1]).join('\n'), 'error'); } + if (lost.length) { + toast('Captions from your last session could not be loaded:\n' + lost.map(p => splitPath(p)[1]).join('\n'), 'error'); + } S.sources.forEach(m => ensureKeyframes(m.id)); const curId = byPath[s.current] || (S.sources[0] && S.sources[0].id); if (curId) loadSource(curId, { time: s.time || 0 }); @@ -1770,6 +1906,8 @@ function bindUI() { const act = e.target.closest('[data-sact]'); if (act && act.dataset.sact === 'remove') return removeSource(li.dataset.id); if (act && act.dataset.sact === 'whole') return addWhole(li.dataset.id); + if (act && act.dataset.sact === 'captions') return loadCaptions(li.dataset.id); + if (act && act.dataset.sact === 'nocaptions') return removeCaptions(li.dataset.id); if (li.dataset.id !== S.cur) loadSource(li.dataset.id); }); diff --git a/web/index.html b/web/index.html index 9791138..43656b8 100644 --- a/web/index.html +++ b/web/index.html @@ -78,6 +78,7 @@
+
Add a video, then click it in the list to preview it here.
@@ -307,6 +308,31 @@
+
+
+
Captions
+ +
+
+
+
+
Caption size
+ +
+
+
Caption style
+ +
+
+
+