Linux versino and Gif output
This commit is contained in:
30
README.md
30
README.md
@@ -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
125
build.sh
@@ -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
266
export.go
@@ -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
56
linux/README.txt
Normal 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
37
linux/install.sh
Executable 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)"
|
||||
9
main.go
9
main.go
@@ -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")
|
||||
|
||||
@@ -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
210
platform_linux.go
Normal 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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build !windows && !linux
|
||||
|
||||
package main
|
||||
|
||||
|
||||
61
web/app.js
61
web/app.js
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user