Linux versino and Gif output

This commit is contained in:
2026-10-17 06:21:19 +00:00
parent 396a676146
commit f6ac9c2f34
12 changed files with 728 additions and 122 deletions

View File

@@ -1,12 +1,14 @@
# VibEdit
A small video cutter for Windows 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.
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.
## Using it
Download or build `dist/VibEdit-windows-x64.zip`, extract it, and double-click `vibedit.exe`. The editor opens in a chromeless Microsoft Edge window. The end-user instructions are in [package-readme.txt](package-readme.txt), which ships inside the zip as `README.txt`.
**Windows:** download or build `dist/VibEdit-windows-x64.zip`, extract it, and double-click `vibedit.exe`. The editor opens in a chromeless Microsoft Edge window. The end-user instructions are in [package-readme.txt](package-readme.txt), which ships inside the zip as `README.txt`.
`vibedit.exe` looks for `ffmpeg.exe` and `ffprobe.exe` in four places, in this order:
**Linux:** download or build `dist/VibEdit-linux-x64.tar.xz`, extract it, and run `./vibedit`, or run `./install.sh` once to add it to the applications menu. The editor opens in a Chrome, Chromium, Edge or Brave app window, or else in the default browser. File dialogs use zenity or kdialog when either is installed. The end-user instructions are in [linux/README.txt](linux/README.txt), which ships inside the archive as `README.txt`.
VibEdit looks for `ffmpeg` and `ffprobe` (`.exe` on Windows) in four places, in this order:
1. next to itself
2. in `ffmpeg\`
@@ -20,11 +22,16 @@ You can also point to them with `-ffmpeg` and `-ffprobe`.
The project is pure Go with no cgo, so you can build the Windows exe on any OS:
```sh
./build.sh # dist/vibedit.exe
./build.sh --package # also downloads ffmpeg (gyan.dev "release essentials") into dist/VibEdit-windows-x64.zip
./build.sh # dist/vibedit.exe
./build.sh --package # also downloads ffmpeg (gyan.dev "release essentials") into dist/VibEdit-windows-x64.zip
./build.sh linux # dist/vibedit
./build.sh linux --package # also downloads ffmpeg (BtbN static release build) into dist/VibEdit-linux-x64.tar.xz
./build.sh all [--package] # both
```
On Windows itself, run `go build -o vibedit.exe .`. You need Go 1.22 or later.
The Linux ffmpeg URL in `build.sh` names a release branch (currently n9.0). BtbN only keeps the newest few branches, so bump the version there when the download starts failing.
On Windows itself, run `go build -o vibedit.exe .`; on Linux, `go build .`. You need Go 1.22 or later.
`go build` links the app icon and version info into Windows builds automatically from `rsrc_windows_*.syso`. To regenerate those files after editing `winres/`, run:
@@ -34,16 +41,19 @@ go run github.com/tc-hib/go-winres@v0.3.3 make --in winres/winres.json --arch am
## How it works
- `main.go` starts an HTTP server on a random 127.0.0.1 port and opens the UI. It prefers `msedge --app=…` and falls back to the default browser.
- `main.go` starts an HTTP server on a random 127.0.0.1 port and opens the UI. It prefers an Edge or Chrome `--app=…` window (on Linux also Chromium and Brave) and falls back to the default browser.
- **Access:** every request must carry a cookie, which the browser gets in exchange for a random token in the startup URL. Only loopback `Host` headers are accepted.
- **Shutdown:** the program quits about 10 seconds after the last editor window disconnects, unless an export is still running.
- `web/` holds the UI: plain HTML, CSS and JS embedded into the binary.
- **Playback:** the browser plays sources directly from the server, which supports HTTP range requests.
- **Unplayable formats** (AVI, WMV, some codecs): the server makes a small H.264 preview copy and caches it in `%LocalAppData%\vibedit\proxies`. Until it is ready, the viewer shows still frames.
- **Unplayable formats** (AVI, WMV, some codecs): the server makes a small H.264 preview copy and caches it in `%LocalAppData%\vibedit\proxies` (`~/.cache/vibedit/proxies` on Linux). Until it is ready, the viewer shows still frames.
- `media.go` probes sources with ffprobe, renders thumbnails and scans keyframe positions.
- `export.go` builds the ffmpeg commands:
- **Lossless:** cuts each clip with `-c copy`, starting exactly at the keyframe at or before In. When joining, it writes temporary segments and concatenates them with the concat demuxer. This only works when all sources share the same format; the server rejects joins where they differ.
- **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.
- `platform_windows.go` provides native Open/Save dialogs (PowerShell + WinForms, no cgo), drive listing and "Show in folder". Other platforms use the built-in file browser.
- The project is autosaved to `%AppData%\vibedit\session.json`.
- `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.
- The project is autosaved to `%AppData%\vibedit\session.json` (`~/.config/vibedit/session.json` on Linux).

125
build.sh
View File

@@ -1,14 +1,32 @@
#!/usr/bin/env bash
# Builds VibEdit for Windows from any OS (pure Go, no C toolchain needed).
# Builds VibEdit for Windows and/or Linux from any OS (pure Go, no C toolchain
# needed).
#
# ./build.sh -> dist/vibedit.exe
# ./build.sh --package -> also downloads a Windows ffmpeg build and creates
# dist/VibEdit-windows-x64.zip (exe + ffmpeg, ready to run)
# ./build.sh -> dist/vibedit.exe (Windows x64)
# ./build.sh linux -> dist/vibedit (Linux x64)
# ./build.sh all -> both
# ./build.sh [target] --package -> also downloads ffmpeg for that OS and creates
# dist/VibEdit-windows-x64.zip and/or
# dist/VibEdit-linux-x64.tar.xz (ready to run)
set -euo pipefail
cd "$(dirname "$0")"
VERSION=1.0.0
FFMPEG_URL=https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip
# Static release build (needs glibc 2.28+) with the same GPU encoders as the
# Windows one. BtbN keeps only the newest release branches, so bump the
# version here when this URL stops working.
FFMPEG_LINUX_URL=https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n9.0-latest-linux64-gpl-9.0.tar.xz
TARGET=windows
PACKAGE=0
for arg in "$@"; do
case "$arg" in
windows|linux|all) TARGET=$arg ;;
--package) PACKAGE=1 ;;
*) echo "usage: $0 [windows|linux|all] [--package]" >&2; exit 2 ;;
esac
done
GO=${GO:-go}
if ! command -v "$GO" >/dev/null 2>&1 && [ -x "$HOME/.local/go/bin/go" ]; then
@@ -16,27 +34,30 @@ if ! command -v "$GO" >/dev/null 2>&1 && [ -x "$HOME/.local/go/bin/go" ]; then
fi
mkdir -p dist
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 "$GO" build -trimpath \
-ldflags "-s -w -X main.version=$VERSION" -o dist/vibedit.exe .
echo "Built dist/vibedit.exe"
if [ "${1:-}" != "--package" ]; then
exit 0
fi
# download URL NAME: fetches URL to dist/cache/NAME unless it is already there.
download() {
mkdir -p dist/cache
if [ ! -f "dist/cache/$2" ]; then
echo "Downloading $1 ..."
wget -q --show-progress -O "dist/cache/$2.tmp" "$1"
mv "dist/cache/$2.tmp" "dist/cache/$2"
fi
}
mkdir -p dist/cache
if [ ! -f dist/cache/ffmpeg-essentials.zip ]; then
echo "Downloading ffmpeg for Windows from gyan.dev ..."
wget -q --show-progress -O dist/cache/ffmpeg-essentials.zip.tmp "$FFMPEG_URL"
mv dist/cache/ffmpeg-essentials.zip.tmp dist/cache/ffmpeg-essentials.zip
fi
build_windows() {
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 "$GO" build -trimpath \
-ldflags "-s -w -X main.version=$VERSION" -o dist/vibedit.exe .
echo "Built dist/vibedit.exe"
[ "$PACKAGE" = 1 ] || return 0
rm -rf dist/VibEdit dist/VibEdit-windows-x64.zip
mkdir -p dist/VibEdit
cp dist/vibedit.exe dist/VibEdit/
cp package-readme.txt dist/VibEdit/README.txt
download "$FFMPEG_URL" ffmpeg-essentials.zip
rm -rf dist/VibEdit dist/VibEdit-windows-x64.zip
mkdir -p dist/VibEdit
cp dist/vibedit.exe dist/VibEdit/
cp package-readme.txt dist/VibEdit/README.txt
python3 - <<'EOF'
python3 - <<'EOF'
import os, zipfile
src = zipfile.ZipFile("dist/cache/ffmpeg-essentials.zip")
@@ -56,4 +77,64 @@ with zipfile.ZipFile("dist/VibEdit-windows-x64.zip", "w", zipfile.ZIP_DEFLATED)
for name in sorted(os.listdir("dist/VibEdit")):
out.write(os.path.join("dist/VibEdit", name), "VibEdit/" + name)
EOF
echo "Packaged dist/VibEdit-windows-x64.zip"
echo "Packaged dist/VibEdit-windows-x64.zip"
}
build_linux() {
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 "$GO" build -trimpath \
-ldflags "-s -w -X main.version=$VERSION" -o dist/vibedit .
echo "Built dist/vibedit"
[ "$PACKAGE" = 1 ] || return 0
local cache
cache=$(basename "$FFMPEG_LINUX_URL")
download "$FFMPEG_LINUX_URL" "$cache"
rm -rf dist/linux-x64 dist/VibEdit-linux-x64.tar.xz
mkdir -p dist/linux-x64/VibEdit
cp dist/vibedit linux/install.sh dist/linux-x64/VibEdit/
cp linux/README.txt dist/linux-x64/VibEdit/README.txt
cp winres/icon.png dist/linux-x64/VibEdit/vibedit.png
echo "Compressing (takes a minute or two) ..."
python3 - "dist/cache/$cache" <<'EOF'
import os, sys, tarfile
dst = "dist/linux-x64/VibEdit"
wanted = {"ffmpeg": "ffmpeg", "ffprobe": "ffprobe", "LICENSE.txt": "ffmpeg-LICENSE.txt"}
executables = {"vibedit", "ffmpeg", "ffprobe", "install.sh"}
found = set()
with tarfile.open(sys.argv[1]) as src:
for m in src.getmembers():
base = os.path.basename(m.name)
if m.isfile() and base in wanted and base not in found:
path = os.path.join(dst, wanted[base])
with open(path, "wb") as f:
f.write(src.extractfile(m).read())
os.chmod(path, 0o755 if wanted[base] in executables else 0o644)
found.add(base)
missing = set(wanted) - found
if missing:
raise SystemExit("ffmpeg archive is missing: " + ", ".join(sorted(missing)))
def normalize(ti):
ti.uid = ti.gid = 0
ti.uname = ti.gname = ""
if ti.isdir() or os.path.basename(ti.name) in executables:
ti.mode = 0o755
else:
ti.mode = 0o644
return ti
with tarfile.open("dist/VibEdit-linux-x64.tar.xz", "w:xz") as out:
out.add(dst, "VibEdit", recursive=False, filter=normalize)
for name in sorted(os.listdir(dst)):
out.add(os.path.join(dst, name), "VibEdit/" + name, filter=normalize)
EOF
echo "Packaged dist/VibEdit-linux-x64.tar.xz"
}
case "$TARGET" in
windows) build_windows ;;
linux) build_linux ;;
all) build_windows; build_linux ;;
esac

266
export.go
View File

@@ -23,7 +23,7 @@ type ExportClip struct {
type ExportRequest struct {
Clips []ExportClip `json:"clips"`
Mode string `json:"mode"` // copy | encode
Mode string `json:"mode"` // copy | encode | gif
Join bool `json:"join"` // one file, or one file per clip
Output string `json:"output"`
Overwrite bool `json:"overwrite"`
@@ -38,8 +38,14 @@ type ExportRequest struct {
FPS string `json:"fps"` // "" = keep
ACodec string `json:"acodec"`
ABitrate int `json:"abitrate"` // kbps
// animated GIF (also uses Height and FPS)
Dither string `json:"dither"` // one of gifDithers
Loop bool `json:"loop"`
}
// 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
@@ -94,6 +100,11 @@ func (a *App) handleExport(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
case "gif":
if err := validateGIF(&req, ext); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
default:
writeError(w, http.StatusBadRequest, "unknown export mode")
return
@@ -253,6 +264,25 @@ func (a *App) validateEncode(req *ExportRequest, ext string) error {
return nil
}
func validateGIF(req *ExportRequest, ext string) error {
if ext != "gif" {
return fmt.Errorf("animated GIF export needs a .gif output, not %q", "."+ext)
}
if req.Height < 0 || req.Height > 8640 {
return fmt.Errorf("invalid output height %d", req.Height)
}
// GIF frame delays are whole centiseconds, and browsers slow down
// anything faster than 50 fps.
if fps := parseRatio(req.FPS, "/"); fps <= 0 || fps > 50 {
return fmt.Errorf("invalid GIF frame rate %q (up to 50 fps)", req.FPS)
}
if !slices.Contains(gifDithers, req.Dither) {
req.Dither = gifDithers[0]
}
req.NoAudio = true
return nil
}
func (a *App) runExport(ctx context.Context, c *JobCtl, req *ExportRequest, clips []clipJob, outputs []string) error {
var total float64
for _, cl := range clips {
@@ -348,6 +378,46 @@ func (a *App) runExport(ctx context.Context, c *JobCtl, req *ExportRequest, clip
c.AddOutput(outputs[i])
}
case req.Mode == "gif":
tmpDir, err := os.MkdirTemp("", "vibedit-gif-")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
palette := filepath.Join(tmpDir, "palette.png")
groups := [][]clipJob{clips}
if !req.Join {
groups = nil
for _, cl := range clips {
groups = append(groups, []clipJob{cl})
}
}
for i, g := range groups {
of := ""
if len(groups) > 1 {
of = fmt.Sprintf(" (clip %d of %d)", i+1, len(groups))
}
paletteRun, gifRun, err := gifArgs(g, req, palette)
if err != nil {
return err
}
// ffmpeg reports no progress while it builds a palette, so like
// the keyframe scan this step does not move the progress bar.
c.Step("Building the color palette" + of)
if err := a.tools.runFFmpeg(ctx, append(paletteRun, palette), nil); err != nil {
return clipErr(err, i, len(groups))
}
c.Step("Encoding GIF" + of)
var dur float64
for _, cl := range g {
dur += cl.dur()
}
if err := run(gifRun, outputs[i], dur); err != nil {
return clipErr(err, i, len(groups))
}
c.AddOutput(outputs[i])
}
case req.Join:
c.Step("Encoding")
args, err := encodeArgs(clips, req, filepath.Ext(outputs[0]))
@@ -445,88 +515,28 @@ func copyOutputArgs(ext string, final bool) []string {
// own seeked input), normalizes size/frame rate/audio format and joins them
// with the concat filter.
func encodeArgs(clips []clipJob, req *ExportRequest, ext string) ([]string, error) {
var first *Media
f, err := frameFormat(clips, req.Height, req.FPS)
if err != nil {
return nil, err
}
anyAudio := false
for _, cl := range clips {
if first == nil && cl.m.Video != nil {
first = cl.m
}
if len(cl.m.Audio) > 0 {
anyAudio = true
}
}
if first == nil {
return nil, fmt.Errorf("none of the clips contain video")
}
anyAudio = anyAudio && !req.NoAudio
// Output frame size: the first clip's display size, optionally scaled.
dw, dh := float64(first.Video.DisplayWidth), float64(first.Video.DisplayHeight)
if dw <= 0 || dh <= 0 {
dw, dh = 1280, 720
}
if req.Height > 0 && float64(req.Height) != dh {
dw, dh = dw*float64(req.Height)/dh, float64(req.Height)
}
W, H := even(dw), even(dh)
fps := req.FPS
if fps == "" {
fps = first.Video.FrameRate
}
vc, _ := codecByName(req.VCodec)
venc, pixFmt := videoEncoderArgs(vc, req.Quality, req.Speed)
sampleRate, layout := 48000, "stereo"
var venc []string
venc, f.PixFmt = videoEncoderArgs(vc, req.Quality, req.Speed)
if anyAudio {
sampleRate, layout = audioFormat(clips, req.ACodec)
f.SampleRate, f.Layout = audioFormat(clips, req.ACodec)
}
args := ffmpegBaseArgs()
var filters []string
var concatIn strings.Builder
input := 0
for i, cl := range clips {
// Start half a millisecond early so rounding never drops the In frame;
// the end (start + duration) then falls just before the Out frame.
d := ffTime(cl.dur())
args = append(args, "-ss", ffTime(math.Max(0, cl.in-0.0005)), "-t", d, "-i", cl.m.Path)
src := input
input++
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]",
W, H, W, H, fps, 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", W, H, fps))
filters = append(filters, fmt.Sprintf("[%d:v]setsar=1,format=%s[v%d]", input, pixFmt, i))
input++
}
fmt.Fprintf(&concatIn, "[v%d]", i)
if anyAudio {
aformat := fmt.Sprintf("aresample=%d,aformat=sample_fmts=fltp:sample_rates=%d:channel_layouts=%s", sampleRate, sampleRate, layout)
if s := cl.m.FirstAudio(); s != nil {
filters = append(filters, fmt.Sprintf("[%d:%d]%s[a%d]", src, s.Index, aformat, i))
} else {
args = append(args, "-f", "lavfi", "-t", d, "-i", fmt.Sprintf("anullsrc=r=%d:cl=%s", sampleRate, layout))
filters = append(filters, fmt.Sprintf("[%d:a]%s[a%d]", input, aformat, i))
input++
}
fmt.Fprintf(&concatIn, "[a%d]", i)
}
}
concat := fmt.Sprintf("%sconcat=n=%d:v=1:a=0[outv]", concatIn.String(), len(clips))
if anyAudio {
concat = fmt.Sprintf("%sconcat=n=%d:v=1:a=1[outv][outa]", concatIn.String(), len(clips))
}
filters = append(filters, concat)
args = append(args, "-filter_complex", strings.Join(filters, ";"), "-map", "[outv]")
inputs, graph, _ := joinGraph(clips, f)
args := append(ffmpegBaseArgs(), inputs...)
args = append(args, "-filter_complex", graph, "-map", "[outv]")
if anyAudio {
args = append(args, "-map", "[outa]")
}
@@ -548,6 +558,120 @@ func encodeArgs(clips []clipJob, req *ExportRequest, ext string) ([]string, erro
return args, nil
}
// gifArgs builds the two ffmpeg runs of a GIF export, each still missing its
// output path. The first picks the 256 colors that suit the whole animation
// best and writes them to palette; the second maps every frame onto them.
func gifArgs(clips []clipJob, req *ExportRequest, palette string) (paletteRun, gifRun []string, err error) {
f, err := frameFormat(clips, req.Height, req.FPS)
if err != nil {
return nil, nil, err
}
f.PixFmt = "bgra" // what palettegen and paletteuse take
inputs, graph, n := joinGraph(clips, f)
paletteRun = append(ffmpegBaseArgs(), inputs...)
paletteRun = append(paletteRun, "-filter_complex", graph+";[outv]palettegen[pal]", "-map", "[pal]", "-frames:v", "1", "-update", "1")
// diff_mode=rectangle re-dithers only the part of a frame that changed,
// which keeps the dither noise still and the file smaller.
loop := "0" // forever
if !req.Loop {
loop = "-1"
}
gifRun = append(ffmpegBaseArgs(), inputs...)
gifRun = append(gifRun, "-i", palette,
"-filter_complex", fmt.Sprintf("%s;[outv][%d:v]paletteuse=dither=%s:diff_mode=rectangle[gif]", graph, n, req.Dither),
"-map", "[gif]", "-loop", loop)
return paletteRun, gifRun, nil
}
// outFormat is what joinGraph converts every clip to. SampleRate 0 means no
// audio.
type outFormat struct {
W, H int
FPS string
PixFmt string
SampleRate int
Layout string
}
// frameFormat picks the output frame size and rate: the first video clip's
// display size, scaled to height when set, and its frame rate unless fps is
// set.
func frameFormat(clips []clipJob, height int, fps string) (outFormat, error) {
var first *Media
for _, cl := range clips {
if cl.m.Video != nil {
first = cl.m
break
}
}
if first == nil {
return outFormat{}, fmt.Errorf("none of the clips contain video")
}
dw, dh := float64(first.Video.DisplayWidth), float64(first.Video.DisplayHeight)
if dw <= 0 || dh <= 0 {
dw, dh = 1280, 720
}
if height > 0 && float64(height) != dh {
dw, dh = dw*float64(height)/dh, float64(height)
}
if fps == "" {
fps = first.Video.FrameRate
}
return outFormat{W: even(dw), H: even(dh), FPS: fps}, nil
}
// 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]).
func joinGraph(clips []clipJob, f outFormat) (args []string, graph string, inputs int) {
var filters []string
var concatIn strings.Builder
for i, cl := range clips {
// Start half a millisecond early so rounding never drops the In frame;
// the end (start + duration) then falls just before the Out frame.
d := ffTime(cl.dur())
args = append(args, "-ss", ffTime(math.Max(0, cl.in-0.0005)), "-t", d, "-i", cl.m.Path)
src := inputs
inputs++
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)
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))
inputs++
}
fmt.Fprintf(&concatIn, "[v%d]", i)
if f.SampleRate > 0 {
aformat := fmt.Sprintf("aresample=%d,aformat=sample_fmts=fltp:sample_rates=%d:channel_layouts=%s", f.SampleRate, f.SampleRate, f.Layout)
if s := cl.m.FirstAudio(); s != nil {
filters = append(filters, fmt.Sprintf("[%d:%d]%s[a%d]", src, s.Index, aformat, i))
} else {
args = append(args, "-f", "lavfi", "-t", d, "-i", fmt.Sprintf("anullsrc=r=%d:cl=%s", f.SampleRate, f.Layout))
filters = append(filters, fmt.Sprintf("[%d:a]%s[a%d]", inputs, aformat, i))
inputs++
}
fmt.Fprintf(&concatIn, "[a%d]", i)
}
}
concat := fmt.Sprintf("%sconcat=n=%d:v=1:a=0[outv]", concatIn.String(), len(clips))
if f.SampleRate > 0 {
concat = fmt.Sprintf("%sconcat=n=%d:v=1:a=1[outv][outa]", concatIn.String(), len(clips))
}
filters = append(filters, concat)
return args, strings.Join(filters, ";"), inputs
}
func even(f float64) int {
n := int(math.Round(f))
if n%2 != 0 {

56
linux/README.txt Normal file
View File

@@ -0,0 +1,56 @@
VibEdit - simple video cutter
=============================
Cut clips out of one or more videos and join them together (or save each
clip as its own file). Uses ffmpeg, which is included in this folder.
Start
-----
Open a terminal in this folder and run ./vibedit. To start it from your
applications menu instead, run ./install.sh once (./install.sh --uninstall
removes the entry again).
The editor opens in its own window when Google Chrome, Chromium, Microsoft
Edge or Brave is installed, otherwise in a tab of your default browser.
Closing the editor window quits VibEdit after a few seconds; in a terminal,
Ctrl+C quits it right away.
To add videos VibEdit uses the zenity (GNOME and most desktops) or kdialog
(KDE) file dialog. If neither is installed it shows its own file browser;
install one for the usual dialog, for example: sudo apt install zenity
How to cut
----------
1. Click "Add videos" and pick one or more source files.
2. Move the playhead (click the timeline, arrow keys = one frame,
Shift+arrows = one second, Up/Down = keyframes).
3. Press I to set the In point and O to set the Out point, then Enter to
add the clip. Repeat for every part you want - from any source video.
Or click "+ Whole" on a source and press S to split it at the playhead,
then delete the pieces you don't want.
4. Drag the clips into order, click Export and choose:
- Lossless: copies the original video/audio. No quality loss and very
fast, but cuts can only start on keyframes (shown as ticks under the
timeline), so a clip may start a little early.
- Re-encode: frame-exact cuts; choose codec (H.264, H.265, AV1, VP9 and
NVIDIA/Intel/AMD GPU encoders when your drivers support them),
quality, resolution, frame rate and audio.
- Animated GIF: a looping animation without sound, with frame-exact
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.
Press the ? button in the editor for all keyboard shortcuts. Your project is
saved automatically (in ~/.config/vibedit) and restored the next time you
start VibEdit.
Options
-------
./vibedit -default-browser open in the default browser instead
./vibedit -no-browser just print the address
./vibedit -ffmpeg /path/to/ffmpeg -ffprobe /path/to/ffprobe
If you delete ffmpeg and ffprobe from this folder, VibEdit uses the ones
installed on your system instead.
ffmpeg is licensed separately, see ffmpeg-LICENSE.txt.

37
linux/install.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/sh
# Adds VibEdit to your applications menu. Run it again after moving this
# folder; "./install.sh --uninstall" removes the menu entry.
set -eu
dir=$(cd "$(dirname "$0")" && pwd)
apps=${XDG_DATA_HOME:-$HOME/.local/share}/applications
entry=$apps/vibedit.desktop
if [ "${1:-}" = "--uninstall" ]; then
rm -f "$entry"
echo "Removed $entry"
exit 0
fi
# These would need escaping in the desktop entry's Exec line.
case $dir in
*[\"\`\$\\]*)
echo "Move this folder to a path without \" \` \$ or \\ in it, then run install.sh again." >&2
exit 1 ;;
esac
mkdir -p "$apps"
cat > "$entry" <<EOF
[Desktop Entry]
Type=Application
Name=VibEdit
GenericName=Video Cutter
Comment=Cut clips out of videos and join them with ffmpeg
Exec="$(printf '%s' "$dir" | sed 's/%/%%/g')/vibedit"
Icon=$dir/vibedit.png
Terminal=false
Categories=AudioVideo;Video;AudioVideoEditing;
EOF
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q "$apps" || true
fi
echo "Added VibEdit to your applications menu ($entry)"

View File

@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"os/signal"
"runtime"
"syscall"
"time"
)
@@ -35,7 +36,11 @@ func main() {
if !app.tools.OK() {
fmt.Println("WARNING: ffmpeg/ffprobe not found.")
fmt.Println(" Put ffmpeg" + exeSuffix + " and ffprobe" + exeSuffix + " in the same folder as this program")
fmt.Println(" (or on your PATH). Windows builds: https://www.gyan.dev/ffmpeg/builds/")
if runtime.GOOS == "windows" {
fmt.Println(" (or on your PATH). Windows builds: https://www.gyan.dev/ffmpeg/builds/")
} else {
fmt.Println(" (or on your PATH, e.g. by installing your distribution's ffmpeg package).")
}
fmt.Println()
} else {
fmt.Printf("ffmpeg: %s\nffprobe: %s\n\n", app.tools.FFmpeg, app.tools.FFprobe)
@@ -69,7 +74,7 @@ func main() {
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
go func() {
<-sig
app.requestQuit("interrupted")

View File

@@ -30,6 +30,9 @@ How to cut
- Re-encode: frame-exact cuts; choose codec (H.264, H.265, AV1, VP9 and
NVIDIA/Intel/AMD GPU encoders when available), quality, resolution,
frame rate and audio.
- Animated GIF: a looping animation without sound, with frame-exact
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.
Press the ? button in the editor for all keyboard shortcuts. Your project is

210
platform_linux.go Normal file
View File

@@ -0,0 +1,210 @@
//go:build linux
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"syscall"
"time"
)
// dialogTool is the zenity or kdialog binary used for native file dialogs, or
// "" when neither is installed (or there is no display) and the UI uses its
// built-in file browser.
var dialogTool = findDialogTool()
var nativeDialogs = dialogTool != ""
func findDialogTool() string {
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
return ""
}
order := []string{"zenity", "kdialog"}
if strings.Contains(strings.ToUpper(os.Getenv("XDG_CURRENT_DESKTOP")), "KDE") {
order = []string{"kdialog", "zenity"}
}
for _, name := range order {
if p, err := exec.LookPath(name); err == nil {
return p
}
}
return ""
}
// hideConsole puts child programs (ffmpeg, dialogs) in their own process group
// so Ctrl+C in the terminal reaches only VibEdit, which then cancels them and
// cleans up. They are killed if VibEdit itself dies.
func hideConsole(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL}
}
// listRoots offers / plus mounted drives for the built-in file browser.
func listRoots() []string {
roots := []string{"/"}
var dirs []string
if u, err := user.Current(); err == nil {
dirs = append(dirs, "/run/media/"+u.Username, "/media/"+u.Username)
}
dirs = append(dirs, "/mnt")
for _, d := range dirs {
des, _ := os.ReadDir(d)
for _, de := range des {
if de.IsDir() && !strings.HasPrefix(de.Name(), ".") {
roots = append(roots, filepath.Join(d, de.Name()))
}
}
}
return roots
}
// openBrowser prefers a chromeless Chrome/Chromium/Edge/Brave "app" window,
// which feels like a normal desktop program, and falls back to the default
// browser.
func openBrowser(url string, preferDefault bool) error {
if !preferDefault {
for _, name := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
"microsoft-edge", "microsoft-edge-stable", "brave-browser"} {
p, err := exec.LookPath(name)
if err != nil {
continue
}
if err := startDetached(p, "--app="+url, "--window-size=1440,920"); err == nil {
return nil
}
}
}
return startDetached("xdg-open", url)
}
// startDetached starts a desktop program in its own session, so it neither
// gets the terminal's Ctrl+C nor is tied to VibEdit's lifetime.
func startDetached(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return err
}
go cmd.Wait()
return nil
}
// revealInFileManager asks the file manager to show the file selected (the
// FileManager1 D-Bus interface of Nautilus, Dolphin, Nemo, Caja and Thunar)
// and falls back to opening its folder.
func revealInFileManager(path string) error {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := exec.CommandContext(ctx, "dbus-send", "--session", "--print-reply", "--reply-timeout=4000",
"--type=method_call", "--dest=org.freedesktop.FileManager1", "/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1.ShowItems", "array:string:"+fileURI(path), "string:").Run()
if err == nil {
return nil
}
return startDetached("xdg-open", filepath.Dir(path))
}
// fileURI percent-encodes everything except unreserved characters and slashes
// (dbus-send would otherwise split the array argument at commas).
func fileURI(path string) string {
var b strings.Builder
b.WriteString("file://")
for i := 0; i < len(path); i++ {
c := path[i]
if 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || strings.IndexByte("/-._~", c) >= 0 {
b.WriteByte(c)
} else {
fmt.Fprintf(&b, "%%%02X", c)
}
}
return b.String()
}
func waitBeforeExit() {}
func isKDialog() bool { return filepath.Base(dialogTool) == "kdialog" }
// runDialog runs zenity/kdialog and returns the chosen paths, one per output
// line. Both exit with status 1 when the user cancels.
func runDialog(args ...string) ([]string, error) {
if dialogTool == "" {
return nil, errors.New("no file dialog program (zenity or kdialog) found")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
out, err := command(ctx, dialogTool, args...).Output()
var exit *exec.ExitError
if errors.As(err, &exit) && exit.ExitCode() == 1 {
return nil, nil
}
if err != nil {
return nil, err
}
var lines []string
for _, l := range strings.Split(string(out), "\n") {
if l != "" {
lines = append(lines, l)
}
}
return lines, nil
}
// videoPatterns lists every media extension in lower and upper case, since
// GTK file filters are case-sensitive.
func videoPatterns() string {
var pats []string
for _, e := range mediaExts {
pats = append(pats, "*."+e, "*."+strings.ToUpper(e))
}
return strings.Join(pats, " ")
}
func nativeOpenDialog(title, dir string) ([]string, error) {
if !isDir(dir) {
dir, _ = os.UserHomeDir()
}
if isKDialog() {
return runDialog("--title", title, "--multiple", "--separate-output",
"--getopenfilename", dir, "Video files ("+videoPatterns()+")\nAll files (*)")
}
return runDialog("--file-selection", "--multiple", "--separator=\n", "--title="+title,
"--filename="+strings.TrimSuffix(dir, "/")+"/",
"--file-filter=Video files | "+videoPatterns(), "--file-filter=All files | *")
}
func nativeSaveDialog(title, path string) (string, error) {
ext := strings.TrimPrefix(filepath.Ext(path), ".")
var lines []string
var err error
if isKDialog() {
filter := "All files (*)"
if ext != "" {
filter = strings.ToUpper(ext) + " video (*." + ext + ")\n" + filter
}
lines, err = runDialog("--title", title, "--getsavefilename", path, filter)
} else {
args := []string{"--file-selection", "--save", "--title=" + title, "--filename=" + path}
if ext != "" {
args = append(args, "--file-filter="+strings.ToUpper(ext)+" video | *."+ext)
}
lines, err = runDialog(append(args, "--file-filter=All files | *")...)
}
if err != nil || len(lines) == 0 {
return "", err
}
// Like the Windows dialog, add the default extension when none was typed.
p := lines[0]
if ext != "" && filepath.Ext(p) == "" {
p += "." + ext
}
return p, nil
}

View File

@@ -1,4 +1,4 @@
//go:build !windows
//go:build !windows && !linux
package main

View File

@@ -120,6 +120,7 @@ const tcCurEl = $('#tcCur'), markInEl = $('#markIn'), markOutEl = $('#markOut');
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,
lastDir: '', volume: 1, muted: false,
};
const PALETTE = ['#4c8dff', '#3ccf91', '#ff8a4c', '#c77dff', '#ffd23f', '#ff5d8f', '#2ec4d6', '#a3d14b'];
@@ -248,7 +249,7 @@ async function addFiles(builtin = false) {
}
return;
}
if (r.error) toast('The Windows 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) {
if (e.status === 409) return;
}
@@ -1197,6 +1198,12 @@ function copyContainerChoices() {
return exts;
}
function exportExts(mode) {
if (mode === 'copy') return copyContainerChoices();
if (mode === 'gif') return ['gif'];
return S.info.containers.map(c => c.ext);
}
function firstClipSource() { return srcById(S.clips[0] && S.clips[0].src) || cur(); }
function defaultOutput() {
@@ -1207,6 +1214,7 @@ function defaultOutput() {
}
function defaultExt() {
if (S.settings.mode === 'gif') return 'gif';
if (S.settings.mode !== 'copy') return 'mp4';
const first = firstClipSource();
const e = first ? extOf(first.path) : '';
@@ -1240,7 +1248,7 @@ function fillExport() {
$$('input[name=mode]').forEach(r => { r.checked = r.value === st.mode; });
$$('input[name=join]').forEach(r => { r.checked = (r.value === '1') === st.join; });
const exts = st.mode === 'copy' ? copyContainerChoices() : info.containers.map(c => c.ext);
const exts = exportExts(st.mode);
if (st.output && !exts.includes(extOf(st.output))) st.output = replaceExt(st.output, exts.includes(defaultExt()) ? defaultExt() : exts[0]);
setOptions($('#exContainer'), exts.map(e => [e, '.' + e]), extOf(st.output));
if (document.activeElement !== $('#exOutput')) $('#exOutput').value = st.output;
@@ -1269,6 +1277,11 @@ function fillExport() {
setOptions($('#exACodec'), [...acs.map(c => [c.name, c.label]), ['none', 'No audio']], st.acodec);
$('#exABitrate').value = String(st.abitrate);
$('#exABitrate').disabled = st.acodec === 'none' || st.acodec === 'flac';
$('#exGifHeight').value = String(st.gifHeight);
$('#exGifFPS').value = st.gifFps;
$('#exGifDither').value = st.gifDither;
$('#exGifLoop').value = st.gifLoop ? '1' : '0';
updateExportView();
}
@@ -1297,12 +1310,14 @@ function formatSig(m, allAudio) {
function updateExportView() {
const st = S.settings;
const copy = st.mode === 'copy';
const copy = st.mode === 'copy', gif = st.mode === 'gif';
const n = S.clips.length;
const total = S.clips.reduce((s, c) => s + c.out - c.in, 0);
$('#exCopyOpts').classList.toggle('hidden', !copy);
$('#exEncOpts').classList.toggle('hidden', copy);
$('#exSummary').textContent = `${n} clip${n !== 1 ? 's' : ''} · ${fmtLen(total)} total → ${st.join || n === 1 ? 'one video file' : n + ' separate files'}`;
$('#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}`;
let outHint = '';
if (!st.join && n > 1 && st.output) {
@@ -1319,14 +1334,16 @@ function updateExportView() {
let encHint = '';
const firstV = S.clips.map(c => srcById(c.src)).find(m => m && m.video);
if (!copy && firstV) {
const [w, hh] = outSize(firstV, +st.height);
encHint = `Output: ${w}×${hh}, ${st.fps ? $('#exFPS').selectedOptions[0].textContent + ' fps' : fmtFps(firstV.video.fps)}`;
const [w, hh] = outSize(firstV, gif ? +st.gifHeight : +st.height);
const fps = gif ? st.gifFps + ' fps, no sound' : st.fps ? $('#exFPS').selectedOptions[0].textContent + ' fps' : fmtFps(firstV.video.fps);
encHint = `Output: ${w}×${hh}, ${fps}`;
if (st.join) {
const shapes = new Set(S.clips.map(c => srcById(c.src)).filter(m => m.video).map(m => (m.video.displayWidth / m.video.displayHeight).toFixed(3)));
if (shapes.size > 1) encHint += ' · clips with a different shape get black bars';
}
}
$('#exEncHint').textContent = encHint;
$('#exEncHint').textContent = gif ? '' : encHint;
$('#exGifHint').textContent = gif ? encHint : '';
const warns = [];
if (copy) {
@@ -1346,6 +1363,11 @@ function updateExportView() {
}
}
if (S.clips.some(c => S.kf[c.src] && S.kf[c.src].status === 'pending')) warns.push('Still reading keyframe positions…');
} else if (gif) {
const longest = st.join ? total : Math.max(...S.clips.map(c => c.out - c.in));
if (longest > 30) {
warns.push(`${st.join || n === 1 ? 'The GIF' : 'The longest GIF'} will be ${fmtLen(longest)} long. GIF files get large quickly; a lower resolution or frame rate keeps them smaller.`);
}
} else if (!S.info.codecsReady) {
warns.push('Checking which encoders (including GPU encoders) are available…');
}
@@ -1391,12 +1413,15 @@ async function startExport(overwrite = false) {
const st = S.settings;
st.output = $('#exOutput').value.trim();
if (!st.output) return toast('Choose where to save the export.', 'error');
const gif = st.mode === 'gif';
const body = {
clips: S.clips.map(c => ({ src: c.src, in: c.in, out: c.out })),
mode: st.mode, join: st.join, output: st.output, overwrite,
allAudio: st.allAudio, noAudio: st.mode === 'copy' ? st.noAudio : st.acodec === 'none',
vcodec: st.vcodec, quality: +st.quality, speed: st.speed, height: +st.height, fps: st.fps,
vcodec: st.vcodec, quality: +st.quality, speed: st.speed,
height: gif ? +st.gifHeight : +st.height, fps: gif ? st.gifFps : st.fps,
acodec: st.acodec, abitrate: +st.abitrate,
dither: st.gifDither, loop: st.gifLoop,
};
saveSoon();
try {
@@ -1466,6 +1491,10 @@ function bindExport() {
$('#exFPS').addEventListener('change', e => { st().fps = e.target.value; refill(); });
$('#exACodec').addEventListener('change', e => { st().acodec = e.target.value; refill(); });
$('#exABitrate').addEventListener('change', e => { st().abitrate = +e.target.value; refill(); });
$('#exGifHeight').addEventListener('change', e => { st().gifHeight = +e.target.value; refill(); });
$('#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(); });
$('#exGo').addEventListener('click', () => startExport(false));
$('#exCancel').addEventListener('click', () => {
if (S.exportJob) api('/api/job/cancel', { id: S.exportJob.id }).catch(() => {});
@@ -1480,7 +1509,7 @@ function setMode(mode) {
const st = S.settings;
if (st.mode === mode) return;
st.mode = mode;
const exts = mode === 'copy' ? copyContainerChoices() : S.info.containers.map(c => c.ext);
const exts = exportExts(mode);
if (st.output && !exts.includes(extOf(st.output))) st.output = replaceExt(st.output, defaultExt());
if (dlgEx.open) fillExport();
changed();
@@ -1519,7 +1548,10 @@ async function fbLoad(path) {
Object.assign(fb, { path: r.path, entries: r.entries, parent: r.parent });
fb.sel.clear();
$('#fbPath').value = r.path;
const root = r.roots.find(x => r.path.toUpperCase().startsWith(x.toUpperCase())) || r.roots[0];
// The deepest root containing the folder (on Linux, / contains the mounted drives too).
const P = r.path.toUpperCase(), sep = S.info.sep || '/';
const inRoot = x => { const X = x.toUpperCase(); return P === X || P.startsWith(X.replace(/[\\/]$/, '') + sep); };
const root = r.roots.filter(inRoot).sort((a, b) => b.length - a.length)[0] || r.roots[0];
setOptions($('#fbRoots'), r.roots.map(x => [x, x]), root);
$('#fbUp').disabled = !r.parent;
fbRender();
@@ -1861,10 +1893,11 @@ async function init() {
const win = S.info.os === 'windows';
const b = $('#banner');
b.replaceChildren(h('b', {}, 'ffmpeg was not found. '),
win ? 'Download a Windows build (for example "ffmpeg-release-essentials.zip" from ' : 'Install ffmpeg (for example from ',
h('a', { href: 'https://www.gyan.dev/ffmpeg/builds/', target: '_blank', rel: 'noopener' }, 'gyan.dev/ffmpeg/builds'),
win ? 'Download a Windows build (for example "ffmpeg-release-essentials.zip" from ' : 'Install ffmpeg with your package manager (or put ffmpeg and ffprobe from ',
win ? h('a', { href: 'https://www.gyan.dev/ffmpeg/builds/', target: '_blank', rel: 'noopener' }, 'gyan.dev/ffmpeg/builds')
: h('a', { href: 'https://ffmpeg.org/download.html', target: '_blank', rel: 'noopener' }, 'ffmpeg.org/download.html'),
win ? '), copy ffmpeg.exe and ffprobe.exe from its bin folder next to vibedit.exe, then restart VibEdit.'
: ' or your package manager) and restart VibEdit.');
: ' next to vibedit) and restart VibEdit.');
b.classList.remove('hidden');
}
$('#btnBrowse').classList.toggle('hidden', !S.info.nativeDialogs);

View File

@@ -141,6 +141,7 @@
<div class="seg" id="modeSeg">
<button data-mode="copy" title="Copy the original streams: no quality loss, very fast. Cuts land on keyframes.">Lossless</button>
<button data-mode="encode" title="Re-encode: frame-exact cuts and a choice of format, size and quality. Slower.">Re-encode</button>
<button data-mode="gif" title="Animated GIF: a looping animation without sound, with frame-exact cuts.">GIF</button>
</div>
</div>
<div id="clipList" class="clip-list"></div>
@@ -159,11 +160,13 @@
<div class="field">
<div class="label">Method</div>
<div class="cards2">
<div class="cards3">
<label class="rcard"><input type="radio" name="mode" value="copy">
<div><b>Lossless</b><small>Copies the original video and audio: no quality loss and very fast. Cuts can only start on keyframes, so a clip may begin a little early.</small></div></label>
<label class="rcard"><input type="radio" name="mode" value="encode">
<div><b>Re-encode</b><small>Frame-exact cuts. Choose the format, resolution and quality. Clips of different sizes are fitted together. Slower.</small></div></label>
<label class="rcard"><input type="radio" name="mode" value="gif">
<div><b>Animated GIF</b><small>A looping animation without sound that plays almost anywhere. Frame-exact cuts. Best for short clips: GIF files get large quickly.</small></div></label>
</div>
</div>
@@ -260,6 +263,50 @@
<div class="hint" id="exEncHint"></div>
</div>
<div id="exGifOpts">
<div class="grid2">
<div class="field">
<div class="label">Resolution</div>
<select id="exGifHeight">
<option value="0">Same as source</option>
<option value="720">720p</option>
<option value="480">480p</option>
<option value="360">360p</option>
<option value="240">240p</option>
</select>
</div>
<div class="field">
<div class="label">Frame rate</div>
<select id="exGifFPS">
<option value="10">10</option>
<option value="12">12</option>
<option value="15">15</option>
<option value="20">20</option>
<option value="25">25</option>
<option value="30">30</option>
</select>
</div>
</div>
<div class="grid2">
<div class="field">
<div class="label">Dithering</div>
<select id="exGifDither">
<option value="sierra2_4a">Smooth (best looking)</option>
<option value="bayer">Pattern (smaller file)</option>
<option value="none">None (smallest, may show bands)</option>
</select>
</div>
<div class="field">
<div class="label">Playback</div>
<select id="exGifLoop">
<option value="1">Loop forever</option>
<option value="0">Play once</option>
</select>
</div>
</div>
<div class="hint" id="exGifHint"></div>
</div>
<div id="exWarn" class="notice warn hidden"></div>
<div id="exProgress" class="progress hidden">
<div class="bar"><div id="exBar"></div></div>

View File

@@ -269,7 +269,7 @@ kbd {
.radios { display: flex; gap: 18px; flex-wrap: wrap; }
.radios label, .rcard { cursor: pointer; }
input[type=radio], input[type=checkbox] { accent-color: var(--accent); }
.cards2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.cards3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
.rcard { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--line2); border-radius: 8px; background: var(--panel2); }
.rcard:has(input:checked) { border-color: var(--accent); background: #18233a; }
.rcard input { margin-top: 3px; }