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

View File

@@ -1,6 +1,6 @@
# VibEdit # 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 ## 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. - **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. - **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. - **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_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. - `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. - Without a dialog program (and on other platforms) the UI uses its built-in file browser.

5
app.go
View File

@@ -98,6 +98,7 @@ func (a *App) routes() {
m.HandleFunc("POST /api/dialog/save", a.handleDialogSave) m.HandleFunc("POST /api/dialog/save", a.handleDialogSave)
m.HandleFunc("POST /api/reveal", a.handleReveal) m.HandleFunc("POST /api/reveal", a.handleReveal)
m.HandleFunc("POST /api/probe", a.handleProbe) 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/media/{id}", a.handleMedia)
m.HandleFunc("GET /api/thumb", a.handleThumb) m.HandleFunc("GET /api/thumb", a.handleThumb)
m.HandleFunc("GET /api/keyframes", a.handleKeyframes) m.HandleFunc("GET /api/keyframes", a.handleKeyframes)
@@ -197,6 +198,7 @@ func (a *App) handleInfo(w http.ResponseWriter, r *http.Request) {
"nativeDialogs": nativeDialogs, "nativeDialogs": nativeDialogs,
"codecs": a.tools.CodecList(), "codecs": a.tools.CodecList(),
"codecsReady": a.tools.EncodersReady(), "codecsReady": a.tools.EncodersReady(),
"captionsOK": a.tools.HasFilter("subtitles"),
"containers": containers, "containers": containers,
"copyExts": copyExts, "copyExts": copyExts,
"mediaExts": mediaExts, "mediaExts": mediaExts,
@@ -282,6 +284,7 @@ func (a *App) handleSessionPut(w http.ResponseWriter, r *http.Request) {
func (a *App) handleDialogOpen(w http.ResponseWriter, r *http.Request) { func (a *App) handleDialogOpen(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Dir string `json:"dir"` Dir string `json:"dir"`
Kind string `json:"kind"` // a key of fileKinds
} }
readJSON(r, &req) readJSON(r, &req)
if !nativeDialogs { if !nativeDialogs {
@@ -293,7 +296,7 @@ func (a *App) handleDialogOpen(w http.ResponseWriter, r *http.Request) {
return return
} }
defer a.dialogMu.Unlock() defer a.dialogMu.Unlock()
paths, err := nativeOpenDialog("Add videos", req.Dir) paths, err := nativeOpenDialog(fileKindOf(req.Kind), req.Dir)
if err != nil { if err != nil {
log.Printf("native open dialog failed: %v", err) log.Printf("native open dialog failed: %v", err)
writeJSON(w, map[string]any{"supported": false, "error": err.Error()}) writeJSON(w, map[string]any{"supported": false, "error": err.Error()})

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()
}

150
captions_test.go Normal file
View File

@@ -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\n<i>world</i>\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\n<i>world</i>"},
{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)
}
}
}

View File

@@ -41,6 +41,10 @@ type ExportRequest struct {
// animated GIF (also uses Height and FPS) // animated GIF (also uses Height and FPS)
Dither string `json:"dither"` // one of gifDithers Dither string `json:"dither"` // one of gifDithers
Loop bool `json:"loop"` 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. // gifDithers are the paletteuse dither modes offered for GIF export.
@@ -49,6 +53,8 @@ var gifDithers = []string{"sierra2_4a", "bayer", "none"}
type clipJob struct { type clipJob struct {
m *Media m *Media
in, out float64 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 } 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") writeError(w, http.StatusBadRequest, "unknown export mode")
return return
} }
if err := a.attachCaptions(&req, clips); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
outputs := []string{out} outputs := []string{out}
if !req.Join { 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 { switch {
case copyJoin: case copyJoin:
out := outputs[0] out := outputs[0]
@@ -527,6 +545,7 @@ func encodeArgs(clips []clipJob, req *ExportRequest, ext string) ([]string, erro
} }
anyAudio = anyAudio && !req.NoAudio anyAudio = anyAudio && !req.NoAudio
f.CaptionSize, f.CaptionStyle = req.CaptionSize, req.CaptionStyle
vc, _ := codecByName(req.VCodec) vc, _ := codecByName(req.VCodec)
var venc []string var venc []string
venc, f.PixFmt = videoEncoderArgs(vc, req.Quality, req.Speed) 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 return nil, nil, err
} }
f.PixFmt = "bgra" // what palettegen and paletteuse take f.PixFmt = "bgra" // what palettegen and paletteuse take
f.CaptionSize, f.CaptionStyle = req.CaptionSize, req.CaptionStyle
inputs, graph, n := joinGraph(clips, f) inputs, graph, n := joinGraph(clips, f)
paletteRun = append(ffmpegBaseArgs(), inputs...) paletteRun = append(ffmpegBaseArgs(), inputs...)
@@ -593,6 +613,8 @@ type outFormat struct {
PixFmt string PixFmt string
SampleRate int SampleRate int
Layout string 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 // 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 // joinGraph returns the input arguments (one seeked input per clip, plus
// generated black video or silence for clips that lack it), how many inputs // 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 // 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) { func joinGraph(clips []clipJob, f outFormat) (args []string, graph string, inputs int) {
var filters []string var filters []string
var concatIn strings.Builder var concatIn strings.Builder
@@ -637,17 +660,24 @@ func joinGraph(clips []clipJob, f outFormat) (args []string, graph string, input
src := inputs src := inputs
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 { if v := cl.m.Video; v != nil {
chain := fmt.Sprintf("[%d:%d]", src, v.Index) chain := fmt.Sprintf("[%d:%d]", src, v.Index)
if !v.SquarePixels { if !v.SquarePixels {
chain += "scale='trunc(iw*sar/2)*2':ih,setsar=1," 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]", 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, f.PixFmt, i) f.W, f.H, f.W, f.H, f.FPS, captions, f.PixFmt, i)
filters = append(filters, chain) filters = append(filters, chain)
} else { } 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)) 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++ inputs++
} }
fmt.Fprintf(&concatIn, "[v%d]", i) fmt.Fprintf(&concatIn, "[v%d]", i)

View File

@@ -100,6 +100,7 @@ type Tools struct {
mu sync.Mutex mu sync.Mutex
encoders map[string]bool // usable encoders (hardware ones are test-encoded) encoders map[string]bool // usable encoders (hardware ones are test-encoded)
filters map[string]bool // filters compiled into ffmpeg
ready bool ready bool
} }
@@ -162,6 +163,12 @@ func (t *Tools) HasEncoder(name string) bool {
return t.encoders[name] 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. // CodecList returns the catalog entries this ffmpeg build can use.
func (t *Tools) CodecList() []Codec { func (t *Tools) CodecList() []Codec {
t.mu.Lock() t.mu.Lock()
@@ -175,9 +182,9 @@ func (t *Tools) CodecList() []Codec {
return out return out
} }
// detectEncoders lists the encoders compiled into ffmpeg and test-encodes a // detectEncoders lists the encoders and filters compiled into ffmpeg and
// few frames with each hardware encoder, since being compiled in does not mean // test-encodes a few frames with each hardware encoder, since being compiled
// the GPU is present. // in does not mean the GPU is present.
func (t *Tools) detectEncoders() { func (t *Tools) detectEncoders() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel() 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{} usable := map[string]bool{}
var wg sync.WaitGroup var wg sync.WaitGroup
var mu sync.Mutex var mu sync.Mutex
@@ -220,6 +235,7 @@ func (t *Tools) detectEncoders() {
wg.Wait() wg.Wait()
t.mu.Lock() t.mu.Lock()
t.encoders = usable t.encoders = usable
t.filters = filters
t.ready = true t.ready = true
t.mu.Unlock() t.mu.Unlock()
} }

28
fs.go
View File

@@ -9,6 +9,31 @@ import (
"strings" "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 { type fsEntry struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
@@ -23,6 +48,7 @@ func (a *App) handleFSList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query() q := r.URL.Query()
dir := strings.TrimSpace(q.Get("path")) dir := strings.TrimSpace(q.Get("path"))
showAll := q.Get("all") == "1" showAll := q.Get("all") == "1"
exts := fileKindOf(q.Get("kind")).Exts
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
if dir == "" { if dir == "" {
dir = home dir = home
@@ -57,7 +83,7 @@ func (a *App) handleFSList(w http.ResponseWriter, r *http.Request) {
} }
if !isD && !showAll { if !isD && !showAll {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")) ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
if !slices.Contains(mediaExts, ext) { if !slices.Contains(exts, ext) {
continue continue
} }
} }

View File

@@ -39,6 +39,10 @@ How to cut
cuts; choose resolution, frame rate, dithering and whether it loops. cuts; choose resolution, frame rate, dithering and whether it loops.
Keep GIFs short - the files get large quickly. Keep GIFs short - the files get large quickly.
- Join everything into one file, or save each clip separately. - 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 Press the ? button in the editor for all keyboard shortcuts. Your project is
saved automatically (in ~/.config/vibedit) and restored the next time you saved automatically (in ~/.config/vibedit) and restored the next time you

View File

@@ -34,6 +34,10 @@ How to cut
cuts; choose resolution, frame rate, dithering and whether it loops. cuts; choose resolution, frame rate, dithering and whether it loops.
Keep GIFs short - the files get large quickly. Keep GIFs short - the files get large quickly.
- Join everything into one file, or save each clip separately. - 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 Press the ? button in the editor for all keyboard shortcuts. Your project is
saved automatically and restored the next time you start VibEdit. saved automatically and restored the next time you start VibEdit.

View File

@@ -158,27 +158,34 @@ func runDialog(args ...string) ([]string, error) {
return lines, nil return lines, nil
} }
// videoPatterns lists every media extension in lower and upper case, since // filePatterns lists every extension in lower and upper case, since GTK file
// GTK file filters are case-sensitive. // filters are case-sensitive.
func videoPatterns() string { func filePatterns(exts []string) string {
var pats []string var pats []string
for _, e := range mediaExts { for _, e := range exts {
pats = append(pats, "*."+e, "*."+strings.ToUpper(e)) pats = append(pats, "*."+e, "*."+strings.ToUpper(e))
} }
return strings.Join(pats, " ") return strings.Join(pats, " ")
} }
func nativeOpenDialog(title, dir string) ([]string, error) { func nativeOpenDialog(k fileKind, dir string) ([]string, error) {
if !isDir(dir) { if !isDir(dir) {
dir, _ = os.UserHomeDir() dir, _ = os.UserHomeDir()
} }
pats := filePatterns(k.Exts)
if isKDialog() { if isKDialog() {
return runDialog("--title", title, "--multiple", "--separate-output", args := []string{"--title", k.Title}
"--getopenfilename", dir, "Video files ("+videoPatterns()+")\nAll files (*)") if k.Multiple {
args = append(args, "--multiple", "--separate-output")
} }
return runDialog("--file-selection", "--multiple", "--separator=\n", "--title="+title, return runDialog(append(args, "--getopenfilename", dir, k.Label+" ("+pats+")\nAll files (*)")...)
"--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) { func nativeSaveDialog(title, path string) (string, error) {

View File

@@ -33,6 +33,6 @@ func waitBeforeExit() {}
var errNoDialogs = errors.New("native file dialogs are not supported on this platform") 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 } func nativeSaveDialog(title, path string) (string, error) { return "", errNoDialogs }

View File

@@ -98,7 +98,7 @@ $owner.ShowInTaskbar = $false
const psOpenDialog = psDialogPrelude + ` const psOpenDialog = psDialogPrelude + `
$d = New-Object System.Windows.Forms.OpenFileDialog $d = New-Object System.Windows.Forms.OpenFileDialog
$d.Title = $env:VIBEDIT_TITLE $d.Title = $env:VIBEDIT_TITLE
$d.Multiselect = $true $d.Multiselect = ($env:VIBEDIT_MULTI -eq '1')
$d.Filter = $env:VIBEDIT_FILTER $d.Filter = $env:VIBEDIT_FILTER
if ($env:VIBEDIT_DIR -and (Test-Path -LiteralPath $env:VIBEDIT_DIR -PathType Container)) { $d.InitialDirectory = $env:VIBEDIT_DIR } 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) { 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 return lines, nil
} }
func videoFilter() string { func fileFilter(k fileKind) string {
var pats []string var pats []string
for _, e := range mediaExts { for _, e := range k.Exts {
pats = append(pats, "*."+e) 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{ return runPowerShell(psOpenDialog, map[string]string{
"VIBEDIT_TITLE": title, "VIBEDIT_TITLE": k.Title,
"VIBEDIT_FILTER": videoFilter(), "VIBEDIT_FILTER": fileFilter(k),
"VIBEDIT_MULTI": multi,
"VIBEDIT_DIR": dir, "VIBEDIT_DIR": dir,
}) })
} }

View File

@@ -121,6 +121,7 @@ const DEFAULTS = {
mode: 'copy', join: true, output: '', allAudio: true, noAudio: false, mode: 'copy', join: true, output: '', allAudio: true, noAudio: false,
vcodec: 'libx264', quality: 20, speed: 'balanced', height: 0, fps: '', acodec: 'aac', abitrate: 192, vcodec: 'libx264', quality: 20, speed: 'balanced', height: 0, fps: '', acodec: 'aac', abitrate: 192,
gifHeight: 480, gifFps: '15', gifDither: 'sierra2_4a', gifLoop: true, gifHeight: 480, gifFps: '15', gifDither: 'sierra2_4a', gifLoop: true,
burnCaptions: true, captionSize: 'medium', captionStyle: 'outline',
lastDir: '', volume: 1, muted: false, lastDir: '', volume: 1, muted: false,
}; };
const PALETTE = ['#4c8dff', '#3ccf91', '#ff8a4c', '#c77dff', '#ffd23f', '#ff5d8f', '#2ec4d6', '#a3d14b']; const PALETTE = ['#4c8dff', '#3ccf91', '#ff8a4c', '#c77dff', '#ffd23f', '#ff5d8f', '#2ec4d6', '#a3d14b'];
@@ -129,6 +130,7 @@ const S = {
info: null, info: null,
sources: [], // probed media (see media.go Media) sources: [], // probed media (see media.go Media)
clips: [], // {id, src, in, out} 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 cur: null, // source id shown in the viewer
marks: {}, // source id -> {in, out} (scratch selection) marks: {}, // source id -> {in, out} (scratch selection)
sel: null, // selected clip id (being edited) 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 // ---------------------------------------------------------------- undo
function snapshot() { 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) { function commit(fn) {
S.undo.push(snapshot()); S.undo.push(snapshot());
@@ -197,6 +199,7 @@ function commit(fn) {
function restoreSnap(s) { function restoreSnap(s) {
S.clips = s.clips; S.clips = s.clips;
S.sources = s.sources; S.sources = s.sources;
S.captions = s.captions;
S.sel = s.sel && S.clips.some(c => c.id === s.sel) ? s.sel : null; S.sel = s.sel && S.clips.some(c => c.id === s.sel) ? s.sel : null;
if (!srcById(S.cur)) { if (!srcById(S.cur)) {
S.cur = null; S.cur = null;
@@ -236,26 +239,27 @@ function confirmDlg(text, okLabel = 'OK') {
} }
// ---------------------------------------------------------------- sources // ---------------------------------------------------------------- sources
async function addFiles(builtin = false) { // pickFiles asks for files to open, with the native dialog when there is one
if (!S.info.ffmpegOK) return toast('ffmpeg was not found — see the message at the top of the window.', 'error'); // 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) { if (!builtin && S.info.nativeDialogs) {
try { try {
const dir = S.settings.lastDir || (cur() ? splitPath(cur().path)[0] : ''); const r = await api('/api/dialog/open', { dir, kind });
const r = await api('/api/dialog/open', { dir }); if (r.supported) return r.paths || [];
if (r.supported) {
if (r.paths && r.paths.length) {
S.settings.lastDir = splitPath(r.paths[0])[0];
await addPaths(r.paths);
}
return;
}
if (r.error) toast('The file dialog could not be opened, using the built-in browser instead.', 'error'); if (r.error) toast('The file dialog could not be opened, using the built-in browser instead.', 'error');
} catch (e) { } catch (e) {
if (e.status === 409) return; if (e.status === 409) return [];
} }
} }
const paths = await fileBrowser({ mode: 'open' }); return await fileBrowser({ mode: 'open', kind, dir }) || [];
if (paths && paths.length) await addPaths(paths); }
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) { 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) { function addWhole(id) {
const m = srcById(id); const m = srcById(id);
if (!m) return; if (!m) return;
@@ -303,7 +327,7 @@ function addWhole(id) {
async function newProject() { async function newProject() {
if ((S.sources.length || S.clips.length) && 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; !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.marks = {};
S.settings.output = ''; S.settings.output = '';
S.cur = null; S.cur = null;
@@ -1014,6 +1038,7 @@ function renderSources() {
const meta = [fmtLen(m.duration), v ? `${v.displayWidth}×${v.displayHeight}` : 'audio only', 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(' · '); v ? v.codec : (m.audio[0] && m.audio[0].codec), v ? fmtFps(v.fps) : ''].filter(Boolean).join(' · ');
const k = S.kf[m.id]; 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}` }, 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' }, 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' }) 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-meta' }, meta),
h('div', { class: 'src-actions' }, h('div', { class: 'src-actions' },
h('button', { class: 'chip-btn', 'data-sact': 'whole', title: 'Add the whole video as one clip' }, icon('plus'), 'Whole'), 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, n ? h('span', { class: 'src-status' }, `${n} clip${n > 1 ? 's' : ''}`) : null,
k && k.status === 'pending' ? h('span', { class: 'src-status' }, 'reading keyframes…') : 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')))); h('button', { class: 'icon-btn src-x', 'data-sact': 'remove', title: 'Remove from project' }, icon('x'))));
@@ -1097,6 +1125,7 @@ function renderMarks() {
} }
function renderTime() { function renderTime() {
renderCaptions();
const m = cur(); const m = cur();
tcCurEl.textContent = fmtT(m ? S.time : 0); tcCurEl.textContent = fmtT(m ? S.time : 0);
$('#tcDur').textContent = fmtT(m ? m.duration : 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)` 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.'; : 'Preview not available — showing still frames.';
} }
layoutCaptions();
const el = $('#screenMsg'); const el = $('#screenMsg');
el.textContent = msg; el.textContent = msg;
el.style.whiteSpace = 'pre-line'; 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 <i>, <b> and <u> 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 // ---------------------------------------------------------------- editable time fields
function makeEditable(el, getValue, onCommit) { function makeEditable(el, getValue, onCommit) {
el.addEventListener('click', () => { el.addEventListener('click', () => {
@@ -1206,6 +1294,10 @@ function exportExts(mode) {
function firstClipSource() { return srcById(S.clips[0] && S.clips[0].src) || cur(); } 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() { function defaultOutput() {
const first = firstClipSource(); const first = firstClipSource();
if (!first) return ''; if (!first) return '';
@@ -1282,6 +1374,8 @@ function fillExport() {
$('#exGifFPS').value = st.gifFps; $('#exGifFPS').value = st.gifFps;
$('#exGifDither').value = st.gifDither; $('#exGifDither').value = st.gifDither;
$('#exGifLoop').value = st.gifLoop ? '1' : '0'; $('#exGifLoop').value = st.gifLoop ? '1' : '0';
$('#exCapSize').value = st.captionSize;
$('#exCapStyle').value = st.captionStyle;
updateExportView(); updateExportView();
} }
@@ -1317,7 +1411,23 @@ function updateExportView() {
$('#exEncOpts').classList.toggle('hidden', st.mode !== 'encode'); $('#exEncOpts').classList.toggle('hidden', st.mode !== 'encode');
$('#exGifOpts').classList.toggle('hidden', !gif); $('#exGifOpts').classList.toggle('hidden', !gif);
const result = st.join || n === 1 ? (gif ? 'one animated GIF' : 'one video file') : n + (gif ? ' separate GIFs' : ' separate files'); 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 = ''; let outHint = '';
if (!st.join && n > 1 && st.output) { if (!st.join && n > 1 && st.output) {
@@ -1378,7 +1488,10 @@ function updateExportView() {
$('#exGo').disabled = running || !n; $('#exGo').disabled = running || !n;
$('#exCancel').classList.toggle('hidden', !running); $('#exCancel').classList.toggle('hidden', !running);
$$('#exportForm input, #exportForm select, #exBrowse').forEach(el => { el.disabled = 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() { function renderExportProgress() {
@@ -1422,7 +1535,11 @@ async function startExport(overwrite = false) {
height: gif ? +st.gifHeight : +st.height, fps: gif ? st.gifFps : st.fps, height: gif ? +st.gifHeight : +st.height, fps: gif ? st.gifFps : st.fps,
acodec: st.acodec, abitrate: +st.abitrate, acodec: st.acodec, abitrate: +st.abitrate,
dither: st.gifDither, loop: st.gifLoop, 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(); saveSoon();
try { try {
const r = await api('/api/export', body); const r = await api('/api/export', body);
@@ -1495,6 +1612,9 @@ function bindExport() {
$('#exGifFPS').addEventListener('change', e => { st().gifFps = e.target.value; refill(); }); $('#exGifFPS').addEventListener('change', e => { st().gifFps = e.target.value; refill(); });
$('#exGifDither').addEventListener('change', e => { st().gifDither = 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(); }); $('#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)); $('#exGo').addEventListener('click', () => startExport(false));
$('#exCancel').addEventListener('click', () => { $('#exCancel').addEventListener('click', () => {
if (S.exportJob) api('/api/job/cancel', { id: S.exportJob.id }).catch(() => {}); if (S.exportJob) api('/api/job/cancel', { id: S.exportJob.id }).catch(() => {});
@@ -1519,13 +1639,16 @@ function setMode(mode) {
const dlgF = $('#dlgFiles'); const dlgF = $('#dlgFiles');
let fb = null; 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 => { return new Promise(resolve => {
fb = { mode, resolve, sel: new Set(), path: '', entries: [], parent: '' }; const caps = kind === 'captions';
$('#fbTitle').textContent = mode === 'open' ? 'Add videos' : 'Export as'; fb = { mode, kind, resolve, sel: new Set(), path: '', entries: [], parent: '' };
$('#fbOk').textContent = mode === 'open' ? 'Add' : 'Save here'; $('#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'); $('#fbSaveRow').classList.toggle('hidden', mode !== 'save');
let dir = S.settings.lastDir || ''; dir = dir || S.settings.lastDir || '';
if (mode === 'save' && path) { if (mode === 'save' && path) {
const [d, n] = splitPath(path); const [d, n] = splitPath(path);
dir = d; dir = d;
@@ -1543,7 +1666,7 @@ async function fbLoad(path) {
if (!fb) return; if (!fb) return;
const seq = ++fbSeq; const seq = ++fbSeq;
try { 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 if (!fb || seq !== fbSeq) return; // closed, or a newer listing was requested
Object.assign(fb, { path: r.path, entries: r.entries, parent: r.parent }); Object.assign(fb, { path: r.path, entries: r.entries, parent: r.parent });
fb.sel.clear(); fb.sel.clear();
@@ -1563,7 +1686,8 @@ async function fbLoad(path) {
function fbRender() { function fbRender() {
const ul = $('#fbList'); const ul = $('#fbList');
ul.replaceChildren(); 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) { 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 }, 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), 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.'); if (!name) return toast('Type a file name.');
result = joinPath(fb.path, name); result = joinPath(fb.path, name);
} }
S.settings.lastDir = fb.path; if (fb.kind !== 'captions') S.settings.lastDir = fb.path;
const f = fb; const f = fb;
fb = null; fb = null;
dlgF.close(); dlgF.close();
@@ -1598,7 +1722,9 @@ function bindFileBrowser() {
const p = li.dataset.path; const p = li.dataset.path;
if (li.dataset.dir) return fbLoad(p); if (li.dataset.dir) return fbLoad(p);
if (fb.mode === 'open') { 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 { } else {
$('#fbName').value = splitPath(p)[1]; $('#fbName').value = splitPath(p)[1];
} }
@@ -1607,8 +1733,10 @@ function bindFileBrowser() {
$('#fbList').addEventListener('dblclick', e => { $('#fbList').addEventListener('dblclick', e => {
const li = e.target.closest('li[data-path]'); const li = e.target.closest('li[data-path]');
if (!li || li.dataset.dir || !fb) return; if (!li || li.dataset.dir || !fb) return;
if (fb.mode === 'open') fb.sel.add(li.dataset.path); if (fb.mode === 'open') {
else $('#fbName').value = splitPath(li.dataset.path)[1]; if (fb.kind === 'captions') fb.sel.clear();
fb.sel.add(li.dataset.path);
} else $('#fbName').value = splitPath(li.dataset.path)[1];
fbFinish(); fbFinish();
}); });
$('#fbOk').addEventListener('click', fbFinish); $('#fbOk').addEventListener('click', fbFinish);
@@ -1683,6 +1811,7 @@ function saveNow() {
current: cur() ? cur().path : null, current: cur() ? cur().path : null,
time: S.time, time: S.time,
marks, marks,
captions: Object.fromEntries(S.sources.filter(m => S.captions[m.id]).map(m => [m.path, S.captions[m.id].path])),
settings: S.settings, settings: S.settings,
}; };
fetch('/api/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), keepalive: true }).catch(() => {}); 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 || {})) { for (const [p, mk] of Object.entries(s.marks || {})) {
if (byPath[p]) S.marks[byPath[p]] = { in: mk.in ?? null, out: mk.out ?? null }; 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) { if (missing.length) {
toast(`${missing.length} video${missing.length > 1 ? 's' : ''} from your last session could not be found:\n` + 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'); 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)); S.sources.forEach(m => ensureKeyframes(m.id));
const curId = byPath[s.current] || (S.sources[0] && S.sources[0].id); const curId = byPath[s.current] || (S.sources[0] && S.sources[0].id);
if (curId) loadSource(curId, { time: s.time || 0 }); if (curId) loadSource(curId, { time: s.time || 0 });
@@ -1770,6 +1906,8 @@ function bindUI() {
const act = e.target.closest('[data-sact]'); const act = e.target.closest('[data-sact]');
if (act && act.dataset.sact === 'remove') return removeSource(li.dataset.id); 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 === '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); if (li.dataset.id !== S.cur) loadSource(li.dataset.id);
}); });

View File

@@ -78,6 +78,7 @@
<div id="screen" class="screen"> <div id="screen" class="screen">
<video id="video" preload="auto" playsinline></video> <video id="video" preload="auto" playsinline></video>
<img id="still" class="still hidden" alt=""> <img id="still" class="still hidden" alt="">
<div id="captions" class="captions hidden"></div>
<div id="screenMsg" class="screen-msg hidden"></div> <div id="screenMsg" class="screen-msg hidden"></div>
<div id="noSource" class="screen-empty">Add a video, then click it in the list to preview it here.</div> <div id="noSource" class="screen-empty">Add a video, then click it in the list to preview it here.</div>
</div> </div>
@@ -307,6 +308,31 @@
<div class="hint" id="exGifHint"></div> <div class="hint" id="exGifHint"></div>
</div> </div>
<div id="exCapOpts">
<div class="field">
<div class="label">Captions</div>
<label><input type="checkbox" id="exCaptions"> Burn the captions into the picture</label>
<div class="hint" id="exCapHint"></div>
</div>
<div class="grid2" id="exCapLook">
<div class="field">
<div class="label">Caption size</div>
<select id="exCapSize">
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
</select>
</div>
<div class="field">
<div class="label">Caption style</div>
<select id="exCapStyle">
<option value="outline">White with a black outline</option>
<option value="box">White on black boxes</option>
</select>
</div>
</div>
</div>
<div id="exWarn" class="notice warn hidden"></div> <div id="exWarn" class="notice warn hidden"></div>
<div id="exProgress" class="progress hidden"> <div id="exProgress" class="progress hidden">
<div class="bar"><div id="exBar"></div></div> <div class="bar"><div id="exBar"></div></div>
@@ -356,6 +382,7 @@
<li>Move the playhead and press <kbd>I</kbd> / <kbd>O</kbd> to mark the start and end of a part you want, then <kbd>Enter</kbd> to add it as a clip. Repeat for every part (from any source).</li> <li>Move the playhead and press <kbd>I</kbd> / <kbd>O</kbd> to mark the start and end of a part you want, then <kbd>Enter</kbd> to add it as a clip. Repeat for every part (from any source).</li>
<li>Or add a whole video with <b>+ Whole</b>, put the playhead where you want to cut and press <kbd>S</kbd> to split; delete the pieces you don't want.</li> <li>Or add a whole video with <b>+ Whole</b>, put the playhead where you want to cut and press <kbd>S</kbd> to split; delete the pieces you don't want.</li>
<li>Drag clips to put them in order, then <b>Export</b> — joined into one video or as separate files.</li> <li>Drag clips to put them in order, then <b>Export</b> — joined into one video or as separate files.</li>
<li>To add captions, click <b>CC</b> on a source and pick its <b>.srt</b> file. They show in the viewer, and Re-encode and GIF exports burn them into the picture, timed to each clip.</li>
</ol> </ol>
<p class="dim">The In point includes the frame you see; the Out point cuts just before the frame you see. Your project is saved automatically.</p> <p class="dim">The In point includes the frame you see; the Out point cuts just before the frame you see. Your project is saved automatically.</p>
<table class="keys"> <table class="keys">

View File

@@ -129,7 +129,7 @@ kbd {
.src-thumb .audio-only { position: absolute; inset: 0; display: grid; place-items: center; color: var(--dim); font-size: 22px; } .src-thumb .audio-only { position: absolute; inset: 0; display: grid; place-items: center; color: var(--dim); font-size: 22px; }
.src-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 18px; } .src-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 18px; }
.src-meta { color: var(--dim); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .src-meta { color: var(--dim); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.src-actions { display: flex; gap: 4px; margin-top: 3px; align-items: center; } .src-actions { display: flex; flex-wrap: wrap; gap: 3px 4px; margin-top: 3px; align-items: center; }
.chip-btn { .chip-btn {
font-size: 11.5px; height: 22px; padding: 0 7px; border-radius: 5px; font-size: 11.5px; height: 22px; padding: 0 7px; border-radius: 5px;
border: 1px solid var(--line2); background: var(--panel3); color: var(--dim); cursor: pointer; border: 1px solid var(--line2); background: var(--panel3); color: var(--dim); cursor: pointer;
@@ -137,6 +137,10 @@ kbd {
} }
.chip-btn:hover { color: var(--text); border-color: #4a5364; } .chip-btn:hover { color: var(--text); border-color: #4a5364; }
.chip-btn .ic { width: 13px; height: 13px; } .chip-btn .ic { width: 13px; height: 13px; }
.chip-btn.on { background: #1c2b48; border-color: #3a62ad; color: #cfe0ff; }
.chip-btn .chip-x { display: inline-flex; margin-right: -3px; padding: 1px; border-radius: 3px; opacity: .7; }
.chip-btn .chip-x:hover { opacity: 1; background: rgba(255,255,255,.12); }
.chip-btn .chip-x .ic { width: 11px; height: 11px; }
.src-x { position: absolute; top: 5px; right: 5px; opacity: 0; } .src-x { position: absolute; top: 5px; right: 5px; opacity: 0; }
.src:hover .src-x, .src.cur .src-x { opacity: 1; } .src:hover .src-x, .src.cur .src-x { opacity: 1; }
.src-status { font-size: 11px; color: var(--dim2); } .src-status { font-size: 11px; color: var(--dim2); }
@@ -145,6 +149,22 @@ kbd {
.viewer { grid-row: 1; grid-column: 2; overflow: hidden; } .viewer { grid-row: 1; grid-column: 2; overflow: hidden; }
.screen { flex: 1; min-height: 120px; position: relative; background: #000; border-radius: var(--radius) var(--radius) 0 0; overflow: hidden; } .screen { flex: 1; min-height: 120px; position: relative; background: #000; border-radius: var(--radius) var(--radius) 0 0; overflow: hidden; }
.screen video, .screen .still { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; } .screen video, .screen .still { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
/* Positioned and sized by layoutCaptions() in app.js to match the export. */
.captions {
position: absolute; display: flex; flex-direction: column-reverse; align-items: center;
font-family: Arial, "Liberation Sans", Helvetica, sans-serif; color: #fff; text-align: center;
white-space: pre-line; text-wrap: balance; overflow-wrap: anywhere; pointer-events: none;
--o: calc(var(--u, 1px) * 1.3);
--d: calc(var(--o) * .71);
}
.captions > div > span {
text-shadow: var(--o) 0 #000, calc(-1 * var(--o)) 0 #000, 0 var(--o) #000, 0 calc(-1 * var(--o)) #000,
var(--d) var(--d) #000, calc(-1 * var(--d)) var(--d) #000, var(--d) calc(-1 * var(--d)) #000, calc(-1 * var(--d)) calc(-1 * var(--d)) #000;
}
.captions.box > div > span {
text-shadow: none; background: #000; padding: 0 calc(var(--u, 1px) * 1.5);
-webkit-box-decoration-break: clone; box-decoration-break: clone;
}
.screen-empty, .screen-msg { .screen-empty, .screen-msg {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: var(--dim); text-align: center; padding: 20px; pointer-events: none; color: var(--dim); text-align: center; padding: 20px; pointer-events: none;