Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 02:28:47 +00:00
commit 396a676146
21 changed files with 5386 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Build output (dist/ also holds the ~100 MB downloaded ffmpeg zip and the packaged release)
dist/
/vibedit
/vibedit.exe
*.exe
*.test
*.out
# ffmpeg binaries dropped next to the program for local testing
ffmpeg
ffprobe
ffmpeg/
bin/
# Leftovers from an interrupted export into this folder
*.vibedit-part.*
.vibedit-tmp-*/
# Editors and OS clutter
.vscode/
.idea/
*.swp
*~
.DS_Store
Thumbs.db
desktop.ini
# Note: rsrc_windows_*.syso are generated (icon + version info) but are kept
# in git on purpose, so `go build` works without running go-winres.

49
README.md Normal file
View File

@@ -0,0 +1,49 @@
# 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.
## 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`.
`vibedit.exe` looks for `ffmpeg.exe` and `ffprobe.exe` in four places, in this order:
1. next to itself
2. in `ffmpeg\`
3. in `bin\`
4. on `PATH`
You can also point to them with `-ffmpeg` and `-ffprobe`.
## Building
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
```
On Windows itself, run `go build -o vibedit.exe .`. 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:
```sh
go run github.com/tc-hib/go-winres@v0.3.3 make --in winres/winres.json --arch amd64,arm64 --out rsrc
```
## 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.
- **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.
- `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.
- **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`.

397
app.go Normal file
View File

@@ -0,0 +1,397 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
//go:embed web
var webFiles embed.FS
// idleExitDelay is how long the program waits after the last editor window
// disconnects before quitting (long enough to survive a page reload).
const idleExitDelay = 10 * time.Second
type App struct {
tools *Tools
media *MediaStore
jobs *JobManager
hub *Hub
mux *http.ServeMux
token string
cookie string
hostOK map[string]bool // allowed Host headers; nil allows any
configDir string
keepAlive bool
quit chan struct{}
quitOnce sync.Once
mu sync.Mutex
clients int
everConnected bool
dialogMu sync.Mutex
}
func newApp(ffmpegPath, ffprobePath string, keepAlive bool) *App {
a := &App{
tools: findTools(ffmpegPath, ffprobePath),
hub: newHub(),
token: randomHex(16),
keepAlive: keepAlive,
quit: make(chan struct{}),
}
a.jobs = newJobManager(a.hub, a.scheduleIdleCheck)
a.media = newMediaStore(a.tools, a.jobs)
if dir, err := os.UserConfigDir(); err == nil {
a.configDir = filepath.Join(dir, "vibedit")
}
if a.tools.OK() {
go a.tools.detectEncoders()
}
a.routes()
return a
}
func (a *App) configureHost(addr *net.TCPAddr) {
port := fmt.Sprint(addr.Port)
a.cookie = "vibedit_" + port
if addr.IP.IsLoopback() {
a.hostOK = map[string]bool{
"127.0.0.1:" + port: true,
"localhost:" + port: true,
"[::1]:" + port: true,
}
}
}
func (a *App) routes() {
m := http.NewServeMux()
static, _ := fs.Sub(webFiles, "web")
files := http.FileServerFS(static)
m.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
files.ServeHTTP(w, r)
})
m.HandleFunc("GET /api/info", a.handleInfo)
m.HandleFunc("GET /api/events", a.handleEvents)
m.HandleFunc("GET /api/session", a.handleSessionGet)
m.HandleFunc("POST /api/session", a.handleSessionPut)
m.HandleFunc("GET /api/fs/list", a.handleFSList)
m.HandleFunc("POST /api/dialog/open", a.handleDialogOpen)
m.HandleFunc("POST /api/dialog/save", a.handleDialogSave)
m.HandleFunc("POST /api/reveal", a.handleReveal)
m.HandleFunc("POST /api/probe", a.handleProbe)
m.HandleFunc("GET /api/media/{id}", a.handleMedia)
m.HandleFunc("GET /api/thumb", a.handleThumb)
m.HandleFunc("GET /api/keyframes", a.handleKeyframes)
m.HandleFunc("POST /api/proxy", a.handleProxy)
m.HandleFunc("POST /api/export", a.handleExport)
m.HandleFunc("POST /api/job/cancel", a.handleCancel)
a.mux = m
}
// ServeHTTP guards every request: only loopback Host headers (DNS rebinding)
// and only browsers holding the session cookie, which is handed out in
// exchange for the random token in the URL printed at startup.
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if a.hostOK != nil && !a.hostOK[r.Host] {
http.Error(w, "forbidden host", http.StatusForbidden)
return
}
if t := r.URL.Query().Get("t"); r.URL.Path == "/" && t != "" {
if subtle.ConstantTimeCompare([]byte(t), []byte(a.token)) == 1 {
http.SetCookie(w, &http.Cookie{Name: a.cookie, Value: a.token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode})
http.Redirect(w, r, "/", http.StatusFound)
return
}
}
c, err := r.Cookie(a.cookie)
if err != nil || subtle.ConstantTimeCompare([]byte(c.Value), []byte(a.token)) != 1 {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
io.WriteString(w, `<!doctype html><title>VibEdit</title><body style="font:16px system-ui;padding:40px;background:#16181d;color:#ddd">`+
`<h2>This VibEdit link has expired</h2><p>Use the address shown in the VibEdit console window, or restart VibEdit.</p></body>`)
return
}
if r.Method == http.MethodPost {
if o := r.Header.Get("Origin"); o != "" && !strings.HasSuffix(o, "//"+r.Host) {
http.Error(w, "forbidden origin", http.StatusForbidden)
return
}
}
a.mux.ServeHTTP(w, r)
}
func (a *App) requestQuit(reason string) {
a.quitOnce.Do(func() {
log.Printf("shutting down (%s)", reason)
close(a.quit)
})
}
func (a *App) shutdown() {
a.jobs.CancelAll()
a.jobs.Wait(5 * time.Second)
}
func (a *App) clientConnected() {
a.mu.Lock()
a.clients++
a.everConnected = true
a.mu.Unlock()
}
func (a *App) clientDisconnected() {
a.mu.Lock()
a.clients--
a.mu.Unlock()
a.scheduleIdleCheck()
}
// scheduleIdleCheck quits the program once no editor window has been
// connected for a while and no export is running.
func (a *App) scheduleIdleCheck() {
if a.keepAlive {
return
}
time.AfterFunc(idleExitDelay, func() {
a.mu.Lock()
idle := a.everConnected && a.clients == 0
a.mu.Unlock()
if idle && !a.jobs.AnyRunning("export") {
a.requestQuit("editor window closed")
}
})
}
// --- handlers ---
func (a *App) handleInfo(w http.ResponseWriter, r *http.Request) {
home, _ := os.UserHomeDir()
writeJSON(w, map[string]any{
"version": version,
"os": runtime.GOOS,
"sep": string(filepath.Separator),
"home": home,
"ffmpeg": a.tools.FFmpeg,
"ffprobe": a.tools.FFprobe,
"ffmpegOK": a.tools.OK(),
"ffmpegVersion": a.tools.Version,
"nativeDialogs": nativeDialogs,
"codecs": a.tools.CodecList(),
"codecsReady": a.tools.EncodersReady(),
"containers": containers,
"copyExts": copyExts,
"mediaExts": mediaExts,
})
}
func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) {
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
ch := a.hub.Subscribe()
defer a.hub.Unsubscribe(ch)
a.clientConnected()
defer a.clientDisconnected()
for _, j := range a.jobs.Snapshot() {
w.Write(sseMessage("job", j))
}
w.Write([]byte(": hello\n\n"))
fl.Flush()
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
for {
var err error
select {
case <-r.Context().Done():
return
case <-a.quit:
return
case msg := <-ch:
_, err = w.Write(msg)
case <-ping.C:
_, err = w.Write([]byte(": ping\n\n"))
}
if err != nil {
return
}
fl.Flush()
}
}
func (a *App) sessionPath() string {
if a.configDir == "" {
return ""
}
return filepath.Join(a.configDir, "session.json")
}
func (a *App) handleSessionGet(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if p := a.sessionPath(); p != "" {
if b, err := os.ReadFile(p); err == nil && json.Valid(b) {
w.Write(b)
return
}
}
io.WriteString(w, "{}")
}
func (a *App) handleSessionPut(w http.ResponseWriter, r *http.Request) {
p := a.sessionPath()
if p == "" {
writeError(w, http.StatusInternalServerError, "no config directory available")
return
}
b, err := io.ReadAll(io.LimitReader(r.Body, 20<<20))
if err != nil || !json.Valid(b) {
writeError(w, http.StatusBadRequest, "invalid session data")
return
}
if err := writeFileAtomic(p, b); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, map[string]any{"ok": true})
}
func (a *App) handleDialogOpen(w http.ResponseWriter, r *http.Request) {
var req struct {
Dir string `json:"dir"`
}
readJSON(r, &req)
if !nativeDialogs {
writeJSON(w, map[string]any{"supported": false})
return
}
if !a.dialogMu.TryLock() {
writeError(w, http.StatusConflict, "a file dialog is already open")
return
}
defer a.dialogMu.Unlock()
paths, err := nativeOpenDialog("Add videos", req.Dir)
if err != nil {
log.Printf("native open dialog failed: %v", err)
writeJSON(w, map[string]any{"supported": false, "error": err.Error()})
return
}
writeJSON(w, map[string]any{"supported": true, "paths": paths})
}
func (a *App) handleDialogSave(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
readJSON(r, &req)
if !nativeDialogs {
writeJSON(w, map[string]any{"supported": false})
return
}
if !a.dialogMu.TryLock() {
writeError(w, http.StatusConflict, "a file dialog is already open")
return
}
defer a.dialogMu.Unlock()
path, err := nativeSaveDialog("Export as", req.Path)
if err != nil {
log.Printf("native save dialog failed: %v", err)
writeJSON(w, map[string]any{"supported": false, "error": err.Error()})
return
}
writeJSON(w, map[string]any{"supported": true, "path": path})
}
func (a *App) handleReveal(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
if err := readJSON(r, &req); err != nil || req.Path == "" {
writeError(w, http.StatusBadRequest, "missing path")
return
}
if _, err := os.Stat(req.Path); err != nil {
writeError(w, http.StatusNotFound, "file not found")
return
}
if err := revealInFileManager(req.Path); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, map[string]any{"ok": true})
}
func (a *App) handleCancel(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
}
readJSON(r, &req)
writeJSON(w, map[string]any{"ok": a.jobs.Cancel(req.ID)})
}
// --- helpers ---
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeErrorData(w, code, msg, nil)
}
func writeErrorData(w http.ResponseWriter, code int, msg string, extra map[string]any) {
body := map[string]any{"error": msg}
for k, v := range extra {
body[k] = v
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(body)
}
func readJSON(r *http.Request, v any) error {
return json.NewDecoder(io.LimitReader(r.Body, 20<<20)).Decode(v)
}
func writeFileAtomic(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}

59
build.sh Executable file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Builds VibEdit for Windows 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)
set -euo pipefail
cd "$(dirname "$0")"
VERSION=1.0.0
FFMPEG_URL=https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip
GO=${GO:-go}
if ! command -v "$GO" >/dev/null 2>&1 && [ -x "$HOME/.local/go/bin/go" ]; then
GO="$HOME/.local/go/bin/go"
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
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
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'
import os, zipfile
src = zipfile.ZipFile("dist/cache/ffmpeg-essentials.zip")
wanted = {"ffmpeg.exe": "ffmpeg.exe", "ffprobe.exe": "ffprobe.exe", "LICENSE": "ffmpeg-LICENSE.txt"}
found = set()
for info in src.infolist():
base = os.path.basename(info.filename)
if base in wanted and base not in found:
with open(os.path.join("dist/VibEdit", wanted[base]), "wb") as f:
f.write(src.read(info))
found.add(base)
missing = set(wanted) - found
if missing:
raise SystemExit("ffmpeg zip is missing: " + ", ".join(sorted(missing)))
with zipfile.ZipFile("dist/VibEdit-windows-x64.zip", "w", zipfile.ZIP_DEFLATED) as out:
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"

634
export.go Normal file
View File

@@ -0,0 +1,634 @@
package main
import (
"context"
"fmt"
"log"
"math"
"net/http"
"os"
"path/filepath"
"runtime"
"slices"
"sort"
"strconv"
"strings"
)
type ExportClip struct {
Src string `json:"src"`
In float64 `json:"in"`
Out float64 `json:"out"`
}
type ExportRequest struct {
Clips []ExportClip `json:"clips"`
Mode string `json:"mode"` // copy | encode
Join bool `json:"join"` // one file, or one file per clip
Output string `json:"output"`
Overwrite bool `json:"overwrite"`
NoAudio bool `json:"noAudio"`
// stream copy
AllAudio bool `json:"allAudio"`
// re-encode
VCodec string `json:"vcodec"`
Quality int `json:"quality"`
Speed string `json:"speed"` // fast | balanced | slow
Height int `json:"height"` // 0 = keep
FPS string `json:"fps"` // "" = keep
ACodec string `json:"acodec"`
ABitrate int `json:"abitrate"` // kbps
}
type clipJob struct {
m *Media
in, out float64
}
func (c clipJob) dur() float64 { return c.out - c.in }
func (a *App) handleExport(w http.ResponseWriter, r *http.Request) {
if !a.tools.OK() {
writeError(w, http.StatusServiceUnavailable, "ffmpeg/ffprobe not found")
return
}
var req ExportRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request: "+err.Error())
return
}
if a.jobs.AnyRunning("export") {
writeError(w, http.StatusConflict, "an export is already running")
return
}
clips, err := a.resolveClips(req.Clips)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
out, err := filepath.Abs(strings.TrimSpace(req.Output))
if err != nil || strings.TrimSpace(req.Output) == "" {
writeError(w, http.StatusBadRequest, "choose where to save the export")
return
}
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(out), "."))
if st, err := os.Stat(filepath.Dir(out)); err != nil || !st.IsDir() {
writeError(w, http.StatusBadRequest, "the output folder does not exist: "+filepath.Dir(out))
return
}
switch req.Mode {
case "copy":
if !slices.Contains(copyExts, ext) {
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported output type %q for lossless export", "."+ext))
return
}
if req.Join && len(clips) > 1 {
if msg := copyCompatProblem(clips, req.AllAudio && !req.NoAudio); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
}
case "encode":
if err := a.validateEncode(&req, ext); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
default:
writeError(w, http.StatusBadRequest, "unknown export mode")
return
}
outputs := []string{out}
if !req.Join {
outputs = numberedOutputs(out, len(clips))
}
for _, o := range outputs {
for _, c := range clips {
if samePath(o, c.m.Path) {
writeError(w, http.StatusBadRequest, "the output would overwrite a source video: "+o)
return
}
}
}
if !req.Overwrite {
var exists []string
for _, o := range outputs {
if _, err := os.Stat(o); err == nil {
exists = append(exists, o)
}
}
if len(exists) > 0 {
writeErrorData(w, http.StatusConflict, "output file already exists", map[string]any{"exists": exists})
return
}
}
title := filepath.Base(out)
if !req.Join {
title = fmt.Sprintf("%d files", len(outputs))
}
log.Printf("export started: %d clip(s), mode=%s, join=%v -> %s", len(clips), req.Mode, req.Join, out)
j := a.jobs.Start("export", title, "", func(ctx context.Context, c *JobCtl) error {
return a.runExport(ctx, c, &req, clips, outputs)
})
writeJSON(w, map[string]any{"jobId": j.ID, "outputs": outputs})
}
func (a *App) resolveClips(in []ExportClip) ([]clipJob, error) {
if len(in) == 0 {
return nil, fmt.Errorf("there are no clips to export")
}
var out []clipJob
for i, c := range in {
m := a.media.Get(c.Src)
if m == nil {
return nil, fmt.Errorf("clip %d: source video is not loaded", i+1)
}
cin := math.Max(0, c.In)
cout := math.Min(c.Out, m.Duration)
if cout-cin < 0.01 {
return nil, fmt.Errorf("clip %d is empty (in %.3f, out %.3f)", i+1, c.In, c.Out)
}
out = append(out, clipJob{m: m, in: cin, out: cout})
}
return out, nil
}
// numberedOutputs turns "C:\v\name.mp4" into name_01.mp4, name_02.mp4, ...
func numberedOutputs(out string, n int) []string {
ext := filepath.Ext(out)
base := strings.TrimSuffix(out, ext)
width := len(strconv.Itoa(n))
if width < 2 {
width = 2
}
paths := make([]string, n)
for i := range paths {
paths[i] = fmt.Sprintf("%s_%0*d%s", base, width, i+1, ext)
}
return paths
}
func samePath(a, b string) bool {
a, b = filepath.Clean(a), filepath.Clean(b)
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return strings.EqualFold(a, b)
}
return a == b
}
// copyCompatProblem explains why clips from different sources cannot be
// joined without re-encoding, or returns "".
func copyCompatProblem(clips []clipJob, allAudio bool) string {
sig := func(m *Media) string {
var parts []string
if v := m.Video; v != nil {
parts = append(parts, fmt.Sprintf("video %s %s %dx%d %s", v.Codec, v.Profile, v.Width, v.Height, v.PixFmt))
} else {
parts = append(parts, "no video")
}
audio := m.Audio
if !allAudio && len(audio) > 1 {
audio = audio[:1]
}
for _, s := range audio {
parts = append(parts, fmt.Sprintf("audio %s %dHz %dch", s.Codec, s.SampleRate, s.Channels))
}
if len(audio) == 0 {
parts = append(parts, "no audio")
}
return strings.Join(parts, ", ")
}
first := clips[0].m
want := sig(first)
for _, c := range clips[1:] {
if c.m == first {
continue
}
if got := sig(c.m); got != want {
return fmt.Sprintf("Lossless join needs all sources to have the same format, but they differ:\n• %s: %s\n• %s: %s\nUse Re-encode, or export each clip separately.",
first.Name, want, c.m.Name, got)
}
}
return ""
}
func (a *App) validateEncode(req *ExportRequest, ext string) error {
ct, ok := containerByExt(ext)
if !ok {
return fmt.Errorf("re-encoding supports .mp4, .mkv, .mov and .webm outputs, not %q", "."+ext)
}
vc, ok := codecByName(req.VCodec)
if !ok || vc.Kind != "video" || !a.tools.HasEncoder(vc.Name) {
return fmt.Errorf("video encoder %q is not available", req.VCodec)
}
if !slices.Contains(ct.Video, vc.Family) {
return fmt.Errorf("%s cannot contain %s video", ct.Label, vc.Label)
}
if req.ACodec == "" || req.ACodec == "none" {
req.NoAudio = true
}
if !req.NoAudio {
ac, ok := codecByName(req.ACodec)
if !ok || ac.Kind != "audio" || !a.tools.HasEncoder(ac.Name) {
return fmt.Errorf("audio encoder %q is not available", req.ACodec)
}
if !slices.Contains(ct.Audio, ac.Family) {
return fmt.Errorf("%s cannot contain %s audio", ct.Label, ac.Label)
}
}
if req.Quality < 0 || req.Quality > vc.MaxQ {
req.Quality = vc.DefQ
}
if req.Height < 0 || req.Height > 8640 {
return fmt.Errorf("invalid output height %d", req.Height)
}
if req.FPS != "" && parseRatio(req.FPS, "/") <= 0 {
return fmt.Errorf("invalid frame rate %q", req.FPS)
}
if req.ABitrate <= 0 || req.ABitrate > 1024 {
req.ABitrate = 192
}
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 {
total += cl.dur()
}
copyJoin := req.Mode == "copy" && req.Join && len(clips) > 1
if copyJoin {
total *= 2 // cutting pass + joining pass
}
done := 0.0
progress := func(base, limit float64) func(float64, string) {
return func(sec float64, speed string) {
c.Progress((base+math.Min(sec, limit))/total, speed)
}
}
// run writes to a temporary name and renames on success, so a failed or
// canceled export never leaves a broken file (or destroys an old one).
run := func(args []string, out string, dur float64) error {
tmp := partialName(out)
args = append(args, tmp)
err := a.tools.runFFmpeg(ctx, args, progress(done, dur))
if err != nil {
os.Remove(tmp)
return err
}
done += dur
if err := os.Rename(tmp, out); err != nil {
os.Remove(tmp)
return err
}
return nil
}
keyframes := map[*Media][]float64{}
if req.Mode == "copy" {
c.Step("Reading keyframes")
for _, cl := range clips {
if _, ok := keyframes[cl.m]; ok {
continue
}
kfs, err := a.media.Keyframes(ctx, cl.m)
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil {
log.Printf("keyframe scan failed for %s, cutting without it: %v", cl.m.Name, err)
}
keyframes[cl.m] = kfs
}
}
switch {
case copyJoin:
out := outputs[0]
tmpDir, err := os.MkdirTemp(filepath.Dir(out), ".vibedit-tmp-")
if err != nil {
return fmt.Errorf("could not create a temporary folder next to the output: %w", err)
}
defer os.RemoveAll(tmpDir)
ext := filepath.Ext(out)
var list strings.Builder
for i, cl := range clips {
c.Step(fmt.Sprintf("Cutting clip %d of %d", i+1, len(clips)))
name := fmt.Sprintf("seg_%04d%s", i+1, ext)
seg := filepath.Join(tmpDir, name)
args := append(copyClipArgs(cl, req, keyframes[cl.m]), copyOutputArgs(ext, false)...)
if err := a.tools.runFFmpeg(ctx, append(args, seg), progress(done, cl.dur())); err != nil {
return fmt.Errorf("clip %d: %w", i+1, err)
}
done += cl.dur()
fmt.Fprintf(&list, "file '%s'\n", name)
}
listPath := filepath.Join(tmpDir, "list.txt")
if err := os.WriteFile(listPath, []byte(list.String()), 0o644); err != nil {
return err
}
c.Step("Joining clips")
args := ffmpegBaseArgs()
args = append(args, "-f", "concat", "-safe", "0", "-i", listPath, "-map", "0", "-c", "copy")
args = append(args, copyOutputArgs(ext, true)...)
if err := run(args, out, total/2); err != nil {
return err
}
c.AddOutput(out)
case req.Mode == "copy":
for i, cl := range clips {
c.Step(stepName("Saving", i, len(clips)))
args := append(copyClipArgs(cl, req, keyframes[cl.m]), copyOutputArgs(filepath.Ext(outputs[i]), true)...)
if err := run(args, outputs[i], cl.dur()); err != nil {
return clipErr(err, i, len(clips))
}
c.AddOutput(outputs[i])
}
case req.Join:
c.Step("Encoding")
args, err := encodeArgs(clips, req, filepath.Ext(outputs[0]))
if err != nil {
return err
}
if err := run(args, outputs[0], total); err != nil {
return err
}
c.AddOutput(outputs[0])
default:
for i, cl := range clips {
c.Step(stepName("Encoding", i, len(clips)))
args, err := encodeArgs([]clipJob{cl}, req, filepath.Ext(outputs[i]))
if err != nil {
return err
}
if err := run(args, outputs[i], cl.dur()); err != nil {
return clipErr(err, i, len(clips))
}
c.AddOutput(outputs[i])
}
}
return nil
}
func stepName(verb string, i, n int) string {
if n == 1 {
return verb
}
return fmt.Sprintf("%s clip %d of %d", verb, i+1, n)
}
func clipErr(err error, i, n int) error {
if n == 1 || err == context.Canceled {
return err
}
return fmt.Errorf("clip %d: %w", i+1, err)
}
// partialName keeps the extension last so ffmpeg still picks the right muxer.
func partialName(out string) string {
ext := filepath.Ext(out)
return strings.TrimSuffix(out, ext) + ".vibedit-part" + ext
}
// copyClipArgs cuts one clip without re-encoding. Stream copy can only start
// on a keyframe, so the clip starts at the keyframe at or before In. When the
// keyframe positions are known we seek exactly to it and drop packets before
// it, so audio does not start ahead of the video.
func copyClipArgs(cl clipJob, req *ExportRequest, keyframes []float64) []string {
start := cl.in
exact := false
if i := sort.SearchFloat64s(keyframes, cl.in+0.001); i > 0 {
start, exact = keyframes[i-1], true
}
args := ffmpegBaseArgs()
if exact && start <= 0.001 {
start = 0
} else if exact {
start -= 0.0005 // stay just before the keyframe despite rounding
args = append(args, "-ss", ffTime(start))
} else {
args = append(args, "-ss", ffTime(start))
}
args = append(args, "-t", ffTime(cl.out-start), "-i", cl.m.Path)
if v := cl.m.Video; v != nil {
args = append(args, "-map", fmt.Sprintf("0:%d", v.Index))
}
if !req.NoAudio {
for i, s := range cl.m.Audio {
if i > 0 && !req.AllAudio {
break
}
args = append(args, "-map", fmt.Sprintf("0:%d", s.Index))
}
}
args = append(args, "-c", "copy", "-avoid_negative_ts", "make_zero", "-map_metadata", "0", "-map_chapters", "-1")
if exact {
args = append(args, "-copypriorss", "0")
}
return args
}
func copyOutputArgs(ext string, final bool) []string {
ext = strings.ToLower(ext)
if final && (ext == ".mp4" || ext == ".mov" || ext == ".m4v") {
return []string{"-movflags", "+faststart"}
}
return nil
}
// encodeArgs builds one ffmpeg command that decodes every clip (each as its
// 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
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"
if anyAudio {
sampleRate, 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]")
if anyAudio {
args = append(args, "-map", "[outa]")
}
args = append(args, venc...)
ext = strings.ToLower(ext)
if vc.Family == "hevc" && (ext == ".mp4" || ext == ".mov") {
args = append(args, "-tag:v", "hvc1") // plays on Apple devices too
}
if anyAudio {
args = append(args, "-c:a", req.ACodec)
if req.ACodec != "flac" {
args = append(args, "-b:a", fmt.Sprintf("%dk", req.ABitrate))
}
}
args = append(args, "-map_chapters", "-1")
if ext == ".mp4" || ext == ".mov" {
args = append(args, "-movflags", "+faststart")
}
return args, nil
}
func even(f float64) int {
n := int(math.Round(f))
if n%2 != 0 {
n++
}
if n < 2 {
n = 2
}
return n
}
// audioFormat picks a common sample rate and channel layout for all clips.
func audioFormat(clips []clipJob, acodec string) (int, string) {
rate, layout := 0, ""
sameRate, sameLayout := true, true
for _, cl := range clips {
s := cl.m.FirstAudio()
if s == nil {
continue
}
l := s.ChannelLayout
if l == "" {
l = map[int]string{1: "mono", 2: "stereo"}[s.Channels]
}
if rate == 0 {
rate, layout = s.SampleRate, l
continue
}
sameRate = sameRate && s.SampleRate == rate
sameLayout = sameLayout && l == layout
}
if !sameRate || (rate != 44100 && rate != 48000) || acodec == "libopus" {
rate = 48000
}
surround := layout == "5.1" || layout == "5.1(side)" || layout == "7.1"
switch {
case sameLayout && layout == "mono":
case sameLayout && surround && (acodec == "aac" || acodec == "flac"):
default:
layout = "stereo"
}
return rate, layout
}
// videoEncoderArgs maps the generic quality (CRF-like, lower is better) and
// speed setting onto each encoder's own options.
func videoEncoderArgs(c Codec, q int, speed string) ([]string, string) {
pick := func(fast, balanced, slow string) string {
switch speed {
case "fast":
return fast
case "slow":
return slow
}
return balanced
}
qs := strconv.Itoa(q)
args := []string{"-c:v", c.Name}
pixFmt := "yuv420p"
switch {
case c.Name == "libx264" || c.Name == "libx265":
args = append(args, "-crf", qs, "-preset", pick("veryfast", "medium", "slow"))
if c.Name == "libx265" {
args = append(args, "-x265-params", "log-level=error")
}
case c.Name == "libsvtav1":
args = append(args, "-crf", qs, "-preset", pick("10", "7", "4"))
case c.Name == "libaom-av1":
args = append(args, "-crf", qs, "-b:v", "0", "-cpu-used", pick("8", "6", "4"), "-row-mt", "1")
case c.Name == "libvpx-vp9":
args = append(args, "-crf", qs, "-b:v", "0", "-deadline", "good", "-cpu-used", pick("5", "2", "1"), "-row-mt", "1")
case strings.HasSuffix(c.Name, "_nvenc"):
args = append(args, "-rc", "vbr", "-cq", qs, "-b:v", "0", "-preset", pick("p2", "p4", "p6"))
case strings.HasSuffix(c.Name, "_qsv"):
args = append(args, "-global_quality", qs, "-preset", pick("veryfast", "medium", "slow"))
pixFmt = "nv12"
case strings.HasSuffix(c.Name, "_amf"):
args = append(args, "-rc", "cqp", "-qp_i", qs, "-qp_p", qs, "-quality", pick("speed", "balanced", "quality"))
if c.Family == "h264" {
args = append(args, "-qp_b", qs)
}
}
return args, pixFmt
}

332
ffmpeg.go Normal file
View File

@@ -0,0 +1,332 @@
package main
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
var exeSuffix = func() string {
if runtime.GOOS == "windows" {
return ".exe"
}
return ""
}()
// Codec describes an encoder the export dialog can offer.
type Codec struct {
Name string `json:"name"` // ffmpeg encoder name
Label string `json:"label"` // shown to the user
Kind string `json:"kind"` // video | audio
Family string `json:"family"` // h264, hevc, av1, vp9, aac, opus, mp3, flac
HW bool `json:"hw,omitempty"`
DefQ int `json:"defQ,omitempty"` // default quality value (CRF-like, lower = better)
MaxQ int `json:"maxQ,omitempty"`
}
var codecCatalog = []Codec{
{Name: "libx264", Label: "H.264 (CPU, most compatible)", Kind: "video", Family: "h264", DefQ: 20, MaxQ: 51},
{Name: "h264_nvenc", Label: "H.264 (NVIDIA GPU)", Kind: "video", Family: "h264", HW: true, DefQ: 23, MaxQ: 51},
{Name: "h264_qsv", Label: "H.264 (Intel GPU)", Kind: "video", Family: "h264", HW: true, DefQ: 23, MaxQ: 51},
{Name: "h264_amf", Label: "H.264 (AMD GPU)", Kind: "video", Family: "h264", HW: true, DefQ: 23, MaxQ: 51},
{Name: "libx265", Label: "H.265 / HEVC (CPU, smaller files)", Kind: "video", Family: "hevc", DefQ: 24, MaxQ: 51},
{Name: "hevc_nvenc", Label: "H.265 / HEVC (NVIDIA GPU)", Kind: "video", Family: "hevc", HW: true, DefQ: 25, MaxQ: 51},
{Name: "hevc_qsv", Label: "H.265 / HEVC (Intel GPU)", Kind: "video", Family: "hevc", HW: true, DefQ: 25, MaxQ: 51},
{Name: "hevc_amf", Label: "H.265 / HEVC (AMD GPU)", Kind: "video", Family: "hevc", HW: true, DefQ: 25, MaxQ: 51},
{Name: "libsvtav1", Label: "AV1 (SVT-AV1, CPU)", Kind: "video", Family: "av1", DefQ: 32, MaxQ: 63},
{Name: "libaom-av1", Label: "AV1 (libaom, CPU, slow)", Kind: "video", Family: "av1", DefQ: 32, MaxQ: 63},
{Name: "av1_nvenc", Label: "AV1 (NVIDIA GPU)", Kind: "video", Family: "av1", HW: true, DefQ: 30, MaxQ: 51},
{Name: "av1_qsv", Label: "AV1 (Intel GPU)", Kind: "video", Family: "av1", HW: true, DefQ: 30, MaxQ: 51},
{Name: "av1_amf", Label: "AV1 (AMD GPU)", Kind: "video", Family: "av1", HW: true, DefQ: 30, MaxQ: 51},
{Name: "libvpx-vp9", Label: "VP9 (CPU)", Kind: "video", Family: "vp9", DefQ: 32, MaxQ: 63},
{Name: "aac", Label: "AAC", Kind: "audio", Family: "aac"},
{Name: "libopus", Label: "Opus", Kind: "audio", Family: "opus"},
{Name: "libmp3lame", Label: "MP3", Kind: "audio", Family: "mp3"},
{Name: "flac", Label: "FLAC (lossless)", Kind: "audio", Family: "flac"},
}
func codecByName(name string) (Codec, bool) {
for _, c := range codecCatalog {
if c.Name == name {
return c, true
}
}
return Codec{}, false
}
// Container lists the codec families a re-encoded output may use.
type Container struct {
Ext string `json:"ext"`
Label string `json:"label"`
Video []string `json:"video"`
Audio []string `json:"audio"`
}
var containers = []Container{
{Ext: "mp4", Label: "MP4", Video: []string{"h264", "hevc", "av1"}, Audio: []string{"aac", "mp3", "opus"}},
{Ext: "mkv", Label: "MKV", Video: []string{"h264", "hevc", "av1", "vp9"}, Audio: []string{"aac", "opus", "mp3", "flac"}},
{Ext: "mov", Label: "MOV", Video: []string{"h264", "hevc"}, Audio: []string{"aac", "mp3"}},
{Ext: "webm", Label: "WebM", Video: []string{"vp9", "av1"}, Audio: []string{"opus"}},
}
// copyExts are the output containers allowed for lossless (stream copy) export.
var copyExts = []string{"mp4", "mkv", "mov", "m4v", "webm", "ts", "mts", "m2ts", "avi", "flv", "wmv", "mpg", "mpeg", "3gp", "mxf", "ogv"}
// mediaExts are shown by the built-in file browser.
var mediaExts = []string{"mp4", "m4v", "mov", "mkv", "webm", "avi", "wmv", "asf", "flv", "mpg", "mpeg", "vob", "ts", "mts", "m2ts", "3gp", "mxf", "ogv", "dv"}
func containerByExt(ext string) (Container, bool) {
for _, c := range containers {
if c.Ext == ext {
return c, true
}
}
return Container{}, false
}
type Tools struct {
FFmpeg string
FFprobe string
Version string
mu sync.Mutex
encoders map[string]bool // usable encoders (hardware ones are test-encoded)
ready bool
}
func findTools(ffmpegFlag, ffprobeFlag string) *Tools {
t := &Tools{FFmpeg: ffmpegFlag, FFprobe: ffprobeFlag}
if t.FFmpeg == "" {
t.FFmpeg = findTool("ffmpeg")
}
if t.FFprobe == "" {
t.FFprobe = findTool("ffprobe")
}
if t.FFmpeg != "" {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if out, err := command(ctx, t.FFmpeg, "-hide_banner", "-version").Output(); err == nil {
line, _, _ := strings.Cut(string(out), "\n")
t.Version = strings.TrimSpace(line)
} else {
t.FFmpeg = ""
}
}
return t
}
// findTool looks next to the executable first, then on PATH.
func findTool(name string) string {
file := name + exeSuffix
if exe, err := os.Executable(); err == nil {
if real, err := filepath.EvalSymlinks(exe); err == nil {
exe = real
}
dir := filepath.Dir(exe)
for _, d := range []string{dir, filepath.Join(dir, "ffmpeg"), filepath.Join(dir, "bin"), filepath.Join(dir, "ffmpeg", "bin")} {
p := filepath.Join(d, file)
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
}
}
if p, err := exec.LookPath(file); err == nil {
if abs, err := filepath.Abs(p); err == nil {
return abs
}
return p
}
return ""
}
func (t *Tools) OK() bool { return t.FFmpeg != "" && t.FFprobe != "" }
func (t *Tools) EncodersReady() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.ready
}
func (t *Tools) HasEncoder(name string) bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.encoders[name]
}
// CodecList returns the catalog entries this ffmpeg build can use.
func (t *Tools) CodecList() []Codec {
t.mu.Lock()
defer t.mu.Unlock()
var out []Codec
for _, c := range codecCatalog {
if t.encoders[c.Name] {
out = append(out, c)
}
}
return out
}
// detectEncoders lists the encoders compiled into ffmpeg and test-encodes a
// few frames with each hardware encoder, since being compiled in does not mean
// the GPU is present.
func (t *Tools) detectEncoders() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
out, err := command(ctx, t.FFmpeg, "-hide_banner", "-encoders").Output()
compiled := map[string]bool{}
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
f := strings.Fields(line)
if len(f) >= 2 && len(f[0]) == 6 && (f[0][0] == 'V' || f[0][0] == 'A') {
compiled[f[1]] = true
}
}
}
usable := map[string]bool{}
var wg sync.WaitGroup
var mu sync.Mutex
for _, c := range codecCatalog {
if !compiled[c.Name] {
continue
}
if !c.HW {
usable[c.Name] = true
continue
}
wg.Add(1)
go func(name string) {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := command(ctx, t.FFmpeg, "-hide_banner", "-loglevel", "error", "-nostdin",
"-f", "lavfi", "-i", "color=c=black:s=640x360:r=25", "-frames:v", "5",
"-pix_fmt", hwPixFmt(name), "-c:v", name, "-f", "null", "-").Run()
if err == nil {
mu.Lock()
usable[name] = true
mu.Unlock()
}
}(c.Name)
}
wg.Wait()
t.mu.Lock()
t.encoders = usable
t.ready = true
t.mu.Unlock()
}
func hwPixFmt(encoder string) string {
if strings.HasSuffix(encoder, "_qsv") {
return "nv12"
}
return "yuv420p"
}
// command builds an exec.Cmd that never pops up a console window.
func command(ctx context.Context, name string, args ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, name, args...)
cmd.WaitDelay = 3 * time.Second
hideConsole(cmd)
return cmd
}
// ffmpegBaseArgs are shared by every ffmpeg run that reports progress.
func ffmpegBaseArgs() []string {
return []string{"-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-progress", "pipe:1", "-nostats"}
}
// runFFmpeg runs ffmpeg and calls onProgress with the output position in
// seconds. It returns ctx.Err() when canceled.
func (t *Tools) runFFmpeg(ctx context.Context, args []string, onProgress func(sec float64, speed string)) error {
cmd := command(ctx, t.FFmpeg, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr := &tailBuffer{max: 16 << 10}
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("could not start ffmpeg: %w", err)
}
sc := bufio.NewScanner(stdout)
var sec float64
var speed string
for sc.Scan() {
k, v, ok := strings.Cut(sc.Text(), "=")
if !ok {
continue
}
v = strings.TrimSpace(v)
switch k {
case "out_time_us":
if us, err := strconv.ParseInt(v, 10, 64); err == nil && us >= 0 {
sec = float64(us) / 1e6
}
case "speed":
if v != "N/A" {
speed = v
}
case "progress":
if onProgress != nil {
onProgress(sec, speed)
}
}
}
err = cmd.Wait()
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil {
msg := lastLines(strings.TrimSpace(stderr.String()), 12)
if msg == "" {
msg = err.Error()
}
return errors.New(msg)
}
return nil
}
// tailBuffer keeps the last max bytes written to it.
type tailBuffer struct {
mu sync.Mutex
max int
buf []byte
}
func (b *tailBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
b.buf = append(b.buf, p...)
if len(b.buf) > b.max {
b.buf = b.buf[len(b.buf)-b.max:]
}
return len(p), nil
}
func (b *tailBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return string(b.buf)
}
func lastLines(s string, n int) string {
lines := strings.Split(s, "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
// ffTime formats seconds for ffmpeg's -ss/-t options.
func ffTime(sec float64) string {
return strconv.FormatFloat(sec, 'f', 6, 64)
}

88
fs.go Normal file
View File

@@ -0,0 +1,88 @@
package main
import (
"net/http"
"os"
"path/filepath"
"slices"
"sort"
"strings"
)
type fsEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Dir bool `json:"dir"`
Size int64 `json:"size"`
Mod int64 `json:"mod"`
}
// handleFSList backs the built-in file browser (used when no native file
// dialog is available).
func (a *App) handleFSList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
dir := strings.TrimSpace(q.Get("path"))
showAll := q.Get("all") == "1"
home, _ := os.UserHomeDir()
if dir == "" {
dir = home
if v := filepath.Join(home, "Videos"); isDir(v) {
dir = v
}
}
dir, _ = filepath.Abs(dir)
if st, err := os.Stat(dir); err == nil && !st.IsDir() {
dir = filepath.Dir(dir)
}
des, err := os.ReadDir(dir)
if err != nil {
writeError(w, http.StatusBadRequest, "cannot open folder: "+err.Error())
return
}
entries := []fsEntry{}
for _, de := range des {
name := de.Name()
if strings.HasPrefix(name, ".") || strings.Contains(name, ".vibedit-part") {
continue
}
info, err := de.Info()
if err != nil {
continue
}
isD := info.IsDir()
if !isD && info.Mode()&os.ModeSymlink != 0 {
if st, err := os.Stat(filepath.Join(dir, name)); err == nil {
isD, info = st.IsDir(), st
}
}
if !isD && !showAll {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
if !slices.Contains(mediaExts, ext) {
continue
}
}
entries = append(entries, fsEntry{Name: name, Path: filepath.Join(dir, name), Dir: isD, Size: info.Size(), Mod: info.ModTime().Unix()})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Dir != entries[j].Dir {
return entries[i].Dir
}
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
})
parent := filepath.Dir(dir)
if parent == dir {
parent = ""
}
writeJSON(w, map[string]any{
"path": dir,
"parent": parent,
"entries": entries,
"roots": listRoots(),
"home": home,
})
}
func isDir(p string) bool {
st, err := os.Stat(p)
return err == nil && st.IsDir()
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module vibedit
go 1.22

265
jobs.go Normal file
View File

@@ -0,0 +1,265 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"sort"
"sync"
"time"
)
// Hub fans server-sent events out to every connected editor window.
type Hub struct {
mu sync.Mutex
subs map[chan []byte]struct{}
}
func newHub() *Hub { return &Hub{subs: map[chan []byte]struct{}{}} }
func (h *Hub) Subscribe() chan []byte {
ch := make(chan []byte, 256)
h.mu.Lock()
h.subs[ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *Hub) Unsubscribe(ch chan []byte) {
h.mu.Lock()
delete(h.subs, ch)
h.mu.Unlock()
}
func (h *Hub) Publish(event string, v any) {
msg := sseMessage(event, v)
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.subs {
select {
case ch <- msg:
default: // slow client; it will catch up on the next update
}
}
}
func sseMessage(event string, v any) []byte {
b, _ := json.Marshal(v)
return []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, b))
}
// Job is a long-running ffmpeg task (an export or a preview proxy).
type Job struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
SourceID string `json:"sourceId,omitempty"`
State string `json:"state"` // running, done, error, canceled
Progress float64 `json:"progress"`
Speed string `json:"speed,omitempty"`
ETA float64 `json:"eta"`
Elapsed float64 `json:"elapsed"`
Step string `json:"step,omitempty"`
Error string `json:"error,omitempty"`
Outputs []string `json:"outputs,omitempty"`
cancel context.CancelFunc
started time.Time
lastSent time.Time
}
type JobManager struct {
mu sync.Mutex
jobs map[string]*Job
hub *Hub
wg sync.WaitGroup
onDone func()
}
func newJobManager(hub *Hub, onDone func()) *JobManager {
return &JobManager{jobs: map[string]*Job{}, hub: hub, onDone: onDone}
}
// JobCtl is handed to a running job so it can report progress.
type JobCtl struct {
m *JobManager
job *Job
}
func (c *JobCtl) Step(s string) {
c.m.mu.Lock()
c.job.Step = s
c.m.mu.Unlock()
c.m.publish(c.job, true)
}
func (c *JobCtl) Progress(p float64, speed string) {
if p < 0 {
p = 0
}
if p > 1 {
p = 1
}
c.m.mu.Lock()
j := c.job
j.Progress = p
j.Speed = speed
j.Elapsed = time.Since(j.started).Seconds()
if p > 0.01 {
j.ETA = j.Elapsed / p * (1 - p)
}
c.m.mu.Unlock()
c.m.publish(j, false)
}
// AddOutput records a finished output file.
func (c *JobCtl) AddOutput(path string) {
c.m.mu.Lock()
c.job.Outputs = append(c.job.Outputs, path)
c.m.mu.Unlock()
}
func (m *JobManager) Start(kind, title, sourceID string, fn func(ctx context.Context, c *JobCtl) error) *Job {
ctx, cancel := context.WithCancel(context.Background())
j := &Job{
ID: randomHex(6),
Kind: kind,
Title: title,
SourceID: sourceID,
State: "running",
cancel: cancel,
started: time.Now(),
}
m.mu.Lock()
m.jobs[j.ID] = j
m.pruneLocked()
m.mu.Unlock()
m.publish(j, true)
m.wg.Add(1)
go func() {
defer m.wg.Done()
defer cancel()
err := fn(ctx, &JobCtl{m: m, job: j})
m.mu.Lock()
j.Elapsed = time.Since(j.started).Seconds()
j.ETA = 0
switch {
case ctx.Err() != nil:
j.State = "canceled"
case err != nil:
j.State = "error"
j.Error = err.Error()
default:
j.State = "done"
j.Progress = 1
}
state, elapsed := j.State, j.Elapsed
m.mu.Unlock()
if kind == "export" {
if err != nil && state == "error" {
log.Printf("export failed after %.1fs: %v", elapsed, err)
} else {
log.Printf("export %s after %.1fs", state, elapsed)
}
}
m.publish(j, true)
if m.onDone != nil {
m.onDone()
}
}()
return j
}
func (m *JobManager) publish(j *Job, force bool) {
m.mu.Lock()
if !force && time.Since(j.lastSent) < 200*time.Millisecond {
m.mu.Unlock()
return
}
j.lastSent = time.Now()
snap := *j
m.mu.Unlock()
m.hub.Publish("job", &snap)
}
// pruneLocked forgets old finished jobs.
func (m *JobManager) pruneLocked() {
var finished []*Job
for _, j := range m.jobs {
if j.State != "running" {
finished = append(finished, j)
}
}
if len(finished) <= 20 {
return
}
sort.Slice(finished, func(a, b int) bool { return finished[a].started.Before(finished[b].started) })
for _, j := range finished[:len(finished)-20] {
delete(m.jobs, j.ID)
}
}
func (m *JobManager) Snapshot() []Job {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Job, 0, len(m.jobs))
for _, j := range m.jobs {
out = append(out, *j)
}
sort.Slice(out, func(a, b int) bool { return out[a].started.Before(out[b].started) })
return out
}
func (m *JobManager) Cancel(id string) bool {
m.mu.Lock()
defer m.mu.Unlock()
if j, ok := m.jobs[id]; ok && j.State == "running" {
j.cancel()
return true
}
return false
}
func (m *JobManager) CancelAll() {
m.mu.Lock()
defer m.mu.Unlock()
for _, j := range m.jobs {
if j.State == "running" {
j.cancel()
}
}
}
// Find returns the running job of the given kind for a source, if any.
func (m *JobManager) Find(kind, sourceID string) *Job {
m.mu.Lock()
defer m.mu.Unlock()
for _, j := range m.jobs {
if j.Kind == kind && j.SourceID == sourceID && j.State == "running" {
return j
}
}
return nil
}
func (m *JobManager) AnyRunning(kind string) bool {
m.mu.Lock()
defer m.mu.Unlock()
for _, j := range m.jobs {
if j.Kind == kind && j.State == "running" {
return true
}
}
return false
}
func (m *JobManager) Wait(timeout time.Duration) {
done := make(chan struct{})
go func() { m.wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(timeout):
}
}

93
main.go Normal file
View File

@@ -0,0 +1,93 @@
// VibEdit is a small video cutter: it splits clips out of source videos and
// joins them back together using ffmpeg. The UI is a local web page served by
// this program and opened in an Edge/Chrome app window.
package main
import (
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
const appName = "VibEdit"
var version = "1.0.0"
func main() {
addr := flag.String("addr", "127.0.0.1:0", "address to listen on")
noBrowser := flag.Bool("no-browser", false, "do not open the editor window automatically")
defaultBrowser := flag.Bool("default-browser", false, "open in the default web browser instead of an Edge/Chrome app window")
ffmpegPath := flag.String("ffmpeg", "", "path to ffmpeg (default: next to this program, then PATH)")
ffprobePath := flag.String("ffprobe", "", "path to ffprobe (default: next to this program, then PATH)")
keepAlive := flag.Bool("keep-alive", false, "keep running after the editor window is closed")
flag.Parse()
log.SetFlags(log.Ltime)
fmt.Printf("%s %s - simple ffmpeg video cutter\n\n", appName, version)
app := newApp(*ffmpegPath, *ffprobePath, *keepAlive)
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/")
fmt.Println()
} else {
fmt.Printf("ffmpeg: %s\nffprobe: %s\n\n", app.tools.FFmpeg, app.tools.FFprobe)
}
ln, err := net.Listen("tcp", *addr)
if err != nil {
fatal("Could not start the local web server: %v", err)
}
tcp := ln.Addr().(*net.TCPAddr)
host := tcp.IP.String()
if tcp.IP.IsUnspecified() {
host = "127.0.0.1"
}
hostPort := net.JoinHostPort(host, fmt.Sprint(tcp.Port))
app.configureHost(tcp)
url := fmt.Sprintf("http://%s/?t=%s", hostPort, app.token)
fmt.Println("Editor address (open this if no window appears):")
fmt.Println(" " + url)
fmt.Println()
fmt.Println("Close the editor window or this console to quit.")
fmt.Println()
if !*noBrowser {
go func() {
if err := openBrowser(url, *defaultBrowser); err != nil {
log.Printf("could not open a browser: %v", err)
}
}()
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() {
<-sig
app.requestQuit("interrupted")
}()
srv := &http.Server{Handler: app, ReadHeaderTimeout: 10 * time.Second}
go func() {
<-app.quit
srv.Close()
}()
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
fatal("Web server stopped: %v", err)
}
app.shutdown()
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "\nERROR: "+format+"\n", args...)
waitBeforeExit()
os.Exit(1)
}

566
media.go Normal file
View File

@@ -0,0 +1,566 @@
package main
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
type Stream struct {
Index int `json:"index"`
Type string `json:"type"`
Codec string `json:"codec"`
Profile string `json:"profile,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
DisplayWidth int `json:"displayWidth,omitempty"` // after pixel aspect ratio and rotation
DisplayHeight int `json:"displayHeight,omitempty"`
SquarePixels bool `json:"squarePixels,omitempty"`
Rotation int `json:"rotation,omitempty"`
PixFmt string `json:"pixFmt,omitempty"`
FPS float64 `json:"fps,omitempty"`
FrameRate string `json:"frameRate,omitempty"` // rational, e.g. 30000/1001
SampleRate int `json:"sampleRate,omitempty"`
Channels int `json:"channels,omitempty"`
ChannelLayout string `json:"channelLayout,omitempty"`
Language string `json:"language,omitempty"`
Title string `json:"title,omitempty"`
}
type Media struct {
ID string `json:"id"`
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
ModTime time.Time `json:"modTime"`
Duration float64 `json:"duration"`
StartTime float64 `json:"startTime"`
Format string `json:"format"`
FormatLong string `json:"formatLong"`
BitRate int64 `json:"bitRate"`
Streams []Stream `json:"streams"`
Video *Stream `json:"video"` // primary video stream, nil if none
Audio []Stream `json:"audio"`
}
func (m *Media) FirstAudio() *Stream {
if len(m.Audio) == 0 {
return nil
}
return &m.Audio[0]
}
func mediaID(path string) string {
h := sha1.Sum([]byte(path))
return hex.EncodeToString(h[:6])
}
type ffprobeOutput struct {
Format struct {
FormatName string `json:"format_name"`
FormatLongName string `json:"format_long_name"`
Duration string `json:"duration"`
StartTime string `json:"start_time"`
BitRate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
Index int `json:"index"`
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Profile string `json:"profile"`
Width int `json:"width"`
Height int `json:"height"`
PixFmt string `json:"pix_fmt"`
SAR string `json:"sample_aspect_ratio"`
AvgFrameRate string `json:"avg_frame_rate"`
RFrameRate string `json:"r_frame_rate"`
SampleRate string `json:"sample_rate"`
Channels int `json:"channels"`
ChannelLayout string `json:"channel_layout"`
Duration string `json:"duration"`
Tags map[string]string `json:"tags"`
Disposition map[string]int `json:"disposition"`
SideData []map[string]any `json:"side_data_list"`
} `json:"streams"`
}
func (t *Tools) Probe(ctx context.Context, path string) (*Media, error) {
abs, err := filepath.Abs(path)
if err != nil {
return nil, err
}
st, err := os.Stat(abs)
if err != nil {
return nil, fmt.Errorf("file not found: %s", abs)
}
if st.IsDir() {
return nil, fmt.Errorf("%s is a folder", abs)
}
out, err := command(ctx, t.FFprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", abs).Output()
if err != nil {
var ee interface{ ExitCode() int }
if errors.As(err, &ee) {
return nil, fmt.Errorf("ffprobe could not read %s - is it a video file?", filepath.Base(abs))
}
return nil, err
}
var p ffprobeOutput
if err := json.Unmarshal(out, &p); err != nil {
return nil, fmt.Errorf("unexpected ffprobe output: %w", err)
}
m := &Media{
ID: mediaID(abs),
Path: abs,
Name: filepath.Base(abs),
Size: st.Size(),
ModTime: st.ModTime(),
Duration: parseFloat(p.Format.Duration),
StartTime: parseFloat(p.Format.StartTime),
Format: p.Format.FormatName,
FormatLong: p.Format.FormatLongName,
BitRate: int64(parseFloat(p.Format.BitRate)),
Streams: []Stream{},
Audio: []Stream{},
}
var maxStreamDur float64
for _, s := range p.Streams {
maxStreamDur = math.Max(maxStreamDur, parseFloat(s.Duration))
ms := Stream{Index: s.Index, Type: s.CodecType, Codec: s.CodecName, Profile: s.Profile,
Language: s.Tags["language"], Title: s.Tags["title"]}
switch s.CodecType {
case "video":
ms.Width, ms.Height, ms.PixFmt = s.Width, s.Height, s.PixFmt
ms.FrameRate, ms.FPS = pickFrameRate(s.AvgFrameRate, s.RFrameRate)
ms.Rotation = streamRotation(s.Tags, s.SideData)
sar := parseRatio(s.SAR, ":")
ms.SquarePixels = sar <= 0 || math.Abs(sar-1) < 0.001
dw, dh := float64(s.Width), float64(s.Height)
if !ms.SquarePixels {
dw *= sar
}
if ms.Rotation%180 != 0 {
dw, dh = dh, dw
}
ms.DisplayWidth, ms.DisplayHeight = int(math.Round(dw)), int(math.Round(dh))
if m.Video == nil && s.Disposition["attached_pic"] == 0 && s.Width > 0 {
v := ms
m.Video = &v
}
case "audio":
ms.SampleRate = int(parseFloat(s.SampleRate))
ms.Channels, ms.ChannelLayout = s.Channels, s.ChannelLayout
m.Audio = append(m.Audio, ms)
}
m.Streams = append(m.Streams, ms)
}
if m.Duration <= 0 {
m.Duration = maxStreamDur
}
if m.Video == nil && len(m.Audio) == 0 {
return nil, fmt.Errorf("%s has no video or audio streams", m.Name)
}
if m.Duration <= 0 {
return nil, fmt.Errorf("could not determine the duration of %s", m.Name)
}
return m, nil
}
func parseFloat(s string) float64 {
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
return 0
}
return f
}
func parseRatio(s, sep string) float64 {
a, b, ok := strings.Cut(s, sep)
if !ok {
return parseFloat(s)
}
num, den := parseFloat(a), parseFloat(b)
if den == 0 {
return 0
}
return num / den
}
// pickFrameRate prefers the nominal rate (r_frame_rate) when it agrees with the
// average, since containers like MKV report odd r_frame_rate values.
func pickFrameRate(avg, r string) (string, float64) {
a, rr := parseRatio(avg, "/"), parseRatio(r, "/")
switch {
case rr >= 1 && rr <= 240 && (a <= 0 || math.Abs(rr-a)/rr < 0.1):
return r, rr
case a >= 1 && a <= 240:
return avg, a
case rr > 0:
return r, rr
}
return "30", 30
}
func streamRotation(tags map[string]string, side []map[string]any) int {
rot := 0.0
if v, ok := tags["rotate"]; ok {
rot = parseFloat(v)
}
for _, sd := range side {
if v, ok := sd["rotation"].(float64); ok {
rot = v
}
}
r := int(math.Round(rot)) % 360
if r < 0 {
r += 360
}
return r
}
// --- MediaStore: probed sources, thumbnails, keyframes and preview proxies ---
type kfEntry struct {
status string // pending, done, error
times []float64
err string
done chan struct{}
}
type MediaStore struct {
tools *Tools
jobs *JobManager
mu sync.Mutex
items map[string]*Media
kf map[string]*kfEntry
kfSem chan struct{}
thumbSem chan struct{}
thumbs *byteCache
}
func newMediaStore(t *Tools, jobs *JobManager) *MediaStore {
return &MediaStore{
tools: t,
jobs: jobs,
items: map[string]*Media{},
kf: map[string]*kfEntry{},
kfSem: make(chan struct{}, 2),
thumbSem: make(chan struct{}, 3),
thumbs: newByteCache(96 << 20),
}
}
func (s *MediaStore) Get(id string) *Media {
s.mu.Lock()
defer s.mu.Unlock()
return s.items[id]
}
func (a *App) handleProbe(w http.ResponseWriter, r *http.Request) {
if !a.tools.OK() {
writeError(w, http.StatusServiceUnavailable, "ffmpeg/ffprobe not found")
return
}
var req struct {
Path string `json:"path"`
}
if err := readJSON(r, &req); err != nil || strings.TrimSpace(req.Path) == "" {
writeError(w, http.StatusBadRequest, "missing path")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
m, err := a.tools.Probe(ctx, strings.TrimSpace(req.Path))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
a.media.mu.Lock()
a.media.items[m.ID] = m
a.media.mu.Unlock()
writeJSON(w, m)
}
func (a *App) handleMedia(w http.ResponseWriter, r *http.Request) {
m := a.media.Get(r.PathValue("id"))
if m == nil {
http.NotFound(w, r)
return
}
path, name := m.Path, m.Name
if r.URL.Query().Get("proxy") == "1" {
path, name = a.media.proxyPath(m), "proxy.mp4"
}
f, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil {
http.NotFound(w, r)
return
}
// Browsers sniff media content; these types just keep them from refusing.
switch strings.ToLower(filepath.Ext(name)) {
case ".mp4", ".m4v", ".mov", ".3gp":
w.Header().Set("Content-Type", "video/mp4")
case ".mkv", ".webm":
w.Header().Set("Content-Type", "video/webm")
case ".ogv":
w.Header().Set("Content-Type", "video/ogg")
default:
w.Header().Set("Content-Type", "application/octet-stream")
}
http.ServeContent(w, r, name, st.ModTime(), f)
}
// handleThumb renders a single frame as JPEG.
func (a *App) handleThumb(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
m := a.media.Get(q.Get("id"))
if m == nil || m.Video == nil {
http.NotFound(w, r)
return
}
t := math.Max(0, math.Min(parseFloat(q.Get("t")), m.Duration-0.05))
h, _ := strconv.Atoi(q.Get("h"))
if h <= 0 || h > 1080 {
h = 90
}
h -= h % 2
key := fmt.Sprintf("%s|%.3f|%d", m.ID, t, h)
img := a.media.thumbs.Get(key)
if img == nil {
select {
case a.media.thumbSem <- struct{}{}:
case <-r.Context().Done():
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
out, err := command(ctx, a.tools.FFmpeg, "-hide_banner", "-loglevel", "error", "-nostdin",
"-ss", ffTime(t), "-i", m.Path, "-map", fmt.Sprintf("0:%d", m.Video.Index), "-frames:v", "1",
"-vf", fmt.Sprintf("scale=-2:%d", h), "-f", "image2pipe", "-c:v", "mjpeg", "-q:v", "5", "-").Output()
cancel()
<-a.media.thumbSem
if err != nil || len(out) == 0 {
http.Error(w, "could not render frame", http.StatusInternalServerError)
return
}
img = out
a.media.thumbs.Put(key, img)
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "private, max-age=3600")
w.Write(img)
}
// handleKeyframes returns the keyframe times of the primary video stream,
// scanning the file in the background on first request.
func (a *App) handleKeyframes(w http.ResponseWriter, r *http.Request) {
m := a.media.Get(r.URL.Query().Get("id"))
if m == nil {
writeError(w, http.StatusNotFound, "unknown source")
return
}
if m.Video == nil {
writeJSON(w, map[string]any{"status": "done", "times": []float64{}})
return
}
s := a.media
e := s.keyframeEntry(m)
s.mu.Lock()
resp := map[string]any{"status": e.status, "error": e.err}
if e.status == "done" {
resp["times"] = e.times
}
s.mu.Unlock()
writeJSON(w, resp)
}
// keyframeEntry returns the keyframe scan for a source, starting it if needed.
func (s *MediaStore) keyframeEntry(m *Media) *kfEntry {
s.mu.Lock()
defer s.mu.Unlock()
e := s.kf[m.ID]
if e == nil {
e = &kfEntry{status: "pending", done: make(chan struct{})}
s.kf[m.ID] = e
go s.scanKeyframes(m, e)
}
return e
}
// Keyframes waits for the keyframe scan of a source to finish.
func (s *MediaStore) Keyframes(ctx context.Context, m *Media) ([]float64, error) {
if m.Video == nil {
return nil, nil
}
e := s.keyframeEntry(m)
select {
case <-e.done:
case <-ctx.Done():
return nil, ctx.Err()
}
s.mu.Lock()
defer s.mu.Unlock()
if e.status != "done" {
return nil, errors.New(e.err)
}
return e.times, nil
}
func (s *MediaStore) scanKeyframes(m *Media, e *kfEntry) {
s.kfSem <- struct{}{}
defer func() { <-s.kfSem }()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
out, err := command(ctx, s.tools.FFprobe, "-v", "error", "-select_streams", strconv.Itoa(m.Video.Index),
"-show_entries", "packet=pts_time,flags", "-of", "csv=p=0", m.Path).Output()
var times []float64
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
pts, flags, ok := strings.Cut(strings.TrimSpace(line), ",")
if !ok || !strings.HasPrefix(flags, "K") || pts == "N/A" {
continue
}
t, perr := strconv.ParseFloat(pts, 64)
if perr != nil {
continue
}
times = append(times, math.Max(0, t-m.StartTime))
}
sort.Float64s(times)
times = dedupe(times)
}
s.mu.Lock()
defer s.mu.Unlock()
defer close(e.done)
if err != nil {
e.status, e.err = "error", err.Error()
return
}
e.status, e.times = "done", times
}
func dedupe(sorted []float64) []float64 {
out := sorted[:0]
for i, t := range sorted {
if i == 0 || t-out[len(out)-1] > 1e-4 {
out = append(out, t)
}
}
return out
}
// proxyPath is where the browser-friendly preview copy of a source lives.
func (s *MediaStore) proxyPath(m *Media) string {
dir, err := os.UserCacheDir()
if err != nil {
dir = os.TempDir()
}
key := fmt.Sprintf("%s|%d|%d", m.Path, m.Size, m.ModTime.UnixNano())
h := sha1.Sum([]byte(key))
return filepath.Join(dir, "vibedit", "proxies", hex.EncodeToString(h[:10])+".mp4")
}
// handleProxy creates a small H.264 preview copy for sources the browser
// cannot play (AVI, WMV, unsupported codecs, ...).
func (a *App) handleProxy(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
}
readJSON(r, &req)
m := a.media.Get(req.ID)
if m == nil {
writeError(w, http.StatusNotFound, "unknown source")
return
}
out := a.media.proxyPath(m)
if _, err := os.Stat(out); err == nil {
writeJSON(w, map[string]any{"ready": true})
return
}
if j := a.jobs.Find("proxy", m.ID); j != nil {
writeJSON(w, map[string]any{"jobId": j.ID})
return
}
if !a.tools.HasEncoder("libx264") && a.tools.EncodersReady() {
writeError(w, http.StatusInternalServerError, "this ffmpeg build has no H.264 encoder (libx264) for previews")
return
}
j := a.jobs.Start("proxy", "Preview of "+m.Name, m.ID, func(ctx context.Context, c *JobCtl) error {
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return err
}
tmp := strings.TrimSuffix(out, ".mp4") + ".part.mp4"
args := ffmpegBaseArgs()
args = append(args, "-i", m.Path)
if m.Video != nil {
args = append(args, "-map", fmt.Sprintf("0:%d", m.Video.Index),
"-vf", "scale=-2:'2*trunc(min(540,ih)/2)',format=yuv420p",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "26", "-g", "12", "-bf", "0")
}
if a := m.FirstAudio(); a != nil {
args = append(args, "-map", fmt.Sprintf("0:%d", a.Index), "-c:a", "aac", "-b:a", "128k", "-ac", "2")
}
args = append(args, "-sn", "-dn", "-map_chapters", "-1", "-movflags", "+faststart", tmp)
err := a.tools.runFFmpeg(ctx, args, func(sec float64, speed string) {
c.Progress(sec/m.Duration, speed)
})
if err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, out)
})
writeJSON(w, map[string]any{"jobId": j.ID})
}
// byteCache is a size-bounded FIFO cache for rendered thumbnails.
type byteCache struct {
mu sync.Mutex
max int
size int
order []string
items map[string][]byte
}
func newByteCache(max int) *byteCache { return &byteCache{max: max, items: map[string][]byte{}} }
func (c *byteCache) Get(k string) []byte {
c.mu.Lock()
defer c.mu.Unlock()
return c.items[k]
}
func (c *byteCache) Put(k string, v []byte) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.items[k]; ok {
return
}
c.items[k] = v
c.order = append(c.order, k)
c.size += len(v)
for c.size > c.max && len(c.order) > 0 {
old := c.order[0]
c.order = c.order[1:]
c.size -= len(c.items[old])
delete(c.items, old)
}
}

44
package-readme.txt Normal file
View File

@@ -0,0 +1,44 @@
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
-----
Double-click vibedit.exe. The editor opens in its own window (Microsoft
Edge app mode). A small console window also opens - it shows the editor's
address and closes the program when you close it. Closing the editor
window also quits VibEdit after a few seconds.
If Windows shows "Windows protected your PC", click "More info" and then
"Run anyway" (the program is not code-signed).
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 available), quality, resolution,
frame rate and audio.
- 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 and restored the next time you start VibEdit.
Options (run from a command prompt)
-----------------------------------
vibedit.exe -default-browser open in the default browser instead
vibedit.exe -no-browser just print the address
vibedit.exe -ffmpeg C:\path\ffmpeg.exe -ffprobe C:\path\ffprobe.exe
ffmpeg is licensed separately, see ffmpeg-LICENSE.txt.

38
platform_other.go Normal file
View File

@@ -0,0 +1,38 @@
//go:build !windows
package main
import (
"errors"
"os/exec"
"path/filepath"
"runtime"
)
const nativeDialogs = false
func hideConsole(cmd *exec.Cmd) {}
func listRoots() []string { return []string{"/"} }
func openBrowser(url string, preferDefault bool) error {
if runtime.GOOS == "darwin" {
return exec.Command("open", url).Start()
}
return exec.Command("xdg-open", url).Start()
}
func revealInFileManager(path string) error {
if runtime.GOOS == "darwin" {
return exec.Command("open", "-R", path).Start()
}
return exec.Command("xdg-open", filepath.Dir(path)).Start()
}
func waitBeforeExit() {}
var errNoDialogs = errors.New("native file dialogs are not supported on this platform")
func nativeOpenDialog(title, dir string) ([]string, error) { return nil, errNoDialogs }
func nativeSaveDialog(title, path string) (string, error) { return "", errNoDialogs }

184
platform_windows.go Normal file
View File

@@ -0,0 +1,184 @@
//go:build windows
package main
import (
"bufio"
"context"
"encoding/base64"
"encoding/binary"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"unicode/utf16"
)
const nativeDialogs = true
const createNoWindow = 0x08000000
// hideConsole stops child console programs (ffmpeg, powershell) from opening
// a console window. HideWindow is deliberately not set: Windows would apply
// SW_HIDE to the first window the child shows, i.e. the file dialog.
func hideConsole(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow}
}
func listRoots() []string {
mask, _, _ := syscall.NewLazyDLL("kernel32.dll").NewProc("GetLogicalDrives").Call()
var roots []string
for i := 0; i < 26; i++ {
if mask&(1<<uint(i)) != 0 {
roots = append(roots, string(rune('A'+i))+`:\`)
}
}
return roots
}
// openBrowser prefers a chromeless Edge (or Chrome) "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 {
var candidates []string
for _, env := range []string{"ProgramFiles(x86)", "ProgramFiles", "LocalAppData"} {
base := os.Getenv(env)
if base == "" {
continue
}
candidates = append(candidates,
filepath.Join(base, `Microsoft\Edge\Application\msedge.exe`),
filepath.Join(base, `Google\Chrome\Application\chrome.exe`))
}
for _, p := range candidates {
if _, err := os.Stat(p); err != nil {
continue
}
cmd := exec.Command(p, "--app="+url, "--window-size=1440,920")
if err := cmd.Start(); err == nil {
go cmd.Wait()
return nil
}
}
}
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
}
func revealInFileManager(path string) error {
cmd := exec.Command("explorer.exe")
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: `explorer.exe /select,"` + path + `"`}
if err := cmd.Start(); err != nil {
return err
}
go cmd.Wait() // explorer exits with status 1 even on success
return nil
}
func waitBeforeExit() {
fmt.Fprint(os.Stderr, "\nPress Enter to close...")
bufio.NewReader(os.Stdin).ReadString('\n')
}
// Native file dialogs are shown through PowerShell + Windows Forms so the
// program needs no cgo. Values are passed via environment variables, never
// spliced into the script.
const psDialogPrelude = `
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.Application]::EnableVisualStyles()
$owner = New-Object System.Windows.Forms.Form
$owner.TopMost = $true
$owner.ShowInTaskbar = $false
`
const psOpenDialog = psDialogPrelude + `
$d = New-Object System.Windows.Forms.OpenFileDialog
$d.Title = $env:VIBEDIT_TITLE
$d.Multiselect = $true
$d.Filter = $env:VIBEDIT_FILTER
if ($env:VIBEDIT_DIR -and (Test-Path -LiteralPath $env:VIBEDIT_DIR -PathType Container)) { $d.InitialDirectory = $env:VIBEDIT_DIR }
if ($d.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) {
foreach ($f in $d.FileNames) { [Console]::Out.WriteLine($f) }
}
$owner.Dispose()
`
const psSaveDialog = psDialogPrelude + `
$d = New-Object System.Windows.Forms.SaveFileDialog
$d.Title = $env:VIBEDIT_TITLE
$d.Filter = $env:VIBEDIT_FILTER
$d.OverwritePrompt = $false
$d.AddExtension = $true
$d.DefaultExt = $env:VIBEDIT_EXT
$d.FileName = $env:VIBEDIT_NAME
if ($env:VIBEDIT_DIR -and (Test-Path -LiteralPath $env:VIBEDIT_DIR -PathType Container)) { $d.InitialDirectory = $env:VIBEDIT_DIR }
if ($d.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.WriteLine($d.FileName) }
$owner.Dispose()
`
func runPowerShell(script string, env map[string]string) ([]string, error) {
u := utf16.Encode([]rune(script))
b := make([]byte, len(u)*2)
for i, c := range u {
binary.LittleEndian.PutUint16(b[i*2:], c)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
cmd := exec.CommandContext(ctx, "powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-STA",
"-EncodedCommand", base64.StdEncoding.EncodeToString(b))
cmd.Env = os.Environ()
for k, v := range env {
cmd.Env = append(cmd.Env, k+"="+v)
}
hideConsole(cmd)
out, err := cmd.Output()
if err != nil {
return nil, err
}
var lines []string
for _, l := range strings.Split(strings.TrimPrefix(string(out), "\uFEFF"), "\n") {
if l = strings.TrimSpace(l); l != "" {
lines = append(lines, l)
}
}
return lines, nil
}
func videoFilter() string {
var pats []string
for _, e := range mediaExts {
pats = append(pats, "*."+e)
}
return "Video files|" + strings.Join(pats, ";") + "|All files|*.*"
}
func nativeOpenDialog(title, dir string) ([]string, error) {
return runPowerShell(psOpenDialog, map[string]string{
"VIBEDIT_TITLE": title,
"VIBEDIT_FILTER": videoFilter(),
"VIBEDIT_DIR": dir,
})
}
func nativeSaveDialog(title, path string) (string, error) {
ext := strings.TrimPrefix(filepath.Ext(path), ".")
filter := "All files|*.*"
if ext != "" {
filter = strings.ToUpper(ext) + " video|*." + ext + "|" + filter
}
lines, err := runPowerShell(psSaveDialog, map[string]string{
"VIBEDIT_TITLE": title,
"VIBEDIT_FILTER": filter,
"VIBEDIT_EXT": ext,
"VIBEDIT_NAME": filepath.Base(path),
"VIBEDIT_DIR": filepath.Dir(path),
})
if err != nil || len(lines) == 0 {
return "", err
}
return lines[0], nil
}

BIN
rsrc_windows_amd64.syso Normal file

Binary file not shown.

BIN
rsrc_windows_arm64.syso Normal file

Binary file not shown.

1887
web/app.js Normal file

File diff suppressed because it is too large Load Diff

338
web/index.html Normal file
View File

@@ -0,0 +1,338 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VibEdit</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%234c8dff'/%3E%3Cpath d='M12 9l11 7-11 7z' fill='white'/%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="i-play" viewBox="0 0 24 24"><path d="M7 4.5v15l12-7.5z" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M6.5 4.5h4v15h-4zM13.5 4.5h4v15h-4z" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-start" viewBox="0 0 24 24"><path d="M6 5v14M19 5l-9 7 9 7z"/></symbol>
<symbol id="i-end" viewBox="0 0 24 24"><path d="M18 5v14M5 5l9 7-9 7z"/></symbol>
<symbol id="i-frame-prev" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6"/></symbol>
<symbol id="i-frame-next" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></symbol>
<symbol id="i-sec-prev" viewBox="0 0 24 24"><path d="M12 6l-6 6 6 6M19 6l-6 6 6 6"/></symbol>
<symbol id="i-sec-next" viewBox="0 0 24 24"><path d="M12 6l6 6-6 6M5 6l6 6-6 6"/></symbol>
<symbol id="i-key-prev" viewBox="0 0 24 24"><path d="M5 5v14"/><path d="M14 7l-5 5 5 5 5-5z"/></symbol>
<symbol id="i-key-next" viewBox="0 0 24 24"><path d="M19 5v14"/><path d="M10 7l-5 5 5 5 5-5z"/></symbol>
<symbol id="i-vol" viewBox="0 0 24 24"><path d="M4 9h4l5-4v14l-5-4H4z"/><path d="M16.5 8.5a5 5 0 010 7M19 6a8.5 8.5 0 010 12"/></symbol>
<symbol id="i-mute" viewBox="0 0 24 24"><path d="M4 9h4l5-4v14l-5-4H4z"/><path d="M17 9l5 6M22 9l-5 6"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></symbol>
<symbol id="i-film" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 002 2h6a2 2 0 002-2l1-12M9 7V4h6v3"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2h2"/></symbol>
<symbol id="i-cut" viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M8.5 7.5L20 18M8.5 16.5L20 6"/></symbol>
<symbol id="i-undo" viewBox="0 0 24 24"><path d="M9 14L4 9l5-5"/><path d="M4 9h10a6 6 0 010 12h-3"/></symbol>
<symbol id="i-redo" viewBox="0 0 24 24"><path d="M15 14l5-5-5-5"/><path d="M20 9H10a6 6 0 000 12h3"/></symbol>
<symbol id="i-export" viewBox="0 0 24 24"><path d="M12 15V3M7 8l5-5 5 5"/><path d="M4 14v5a2 2 0 002 2h12a2 2 0 002-2v-5"/></symbol>
<symbol id="i-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9.5 9.5a2.5 2.5 0 114 2c-1 .6-1.5 1.2-1.5 2.5M12 17.5v.01"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></symbol>
<symbol id="i-left" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6"/></symbol>
<symbol id="i-right" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></symbol>
<symbol id="i-up" viewBox="0 0 24 24"><path d="M12 19V5M6 11l6-6 6 6"/></symbol>
<symbol id="i-new" viewBox="0 0 24 24"><path d="M14 3H6a2 2 0 00-2 2v14a2 2 0 002 2h12a2 2 0 002-2V9z"/><path d="M14 3v6h6M12 12v6M9 15h6"/></symbol>
<symbol id="i-in" viewBox="0 0 24 24"><path d="M9 4H6v16h3M10 12h9M15 8l4 4-4 4"/></symbol>
<symbol id="i-out" viewBox="0 0 24 24"><path d="M15 4h3v16h-3M5 12h9M10 8l4 4-4 4"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5"/></symbol>
<symbol id="i-warn" viewBox="0 0 24 24"><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.01"/></symbol>
<symbol id="i-whole" viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="10" rx="2"/><path d="M12 10v4M10 12h4"/></symbol>
</defs>
</svg>
<header class="topbar">
<div class="brand"><span class="logo"><svg><use href="#i-play"/></svg></span>VibEdit</div>
<div class="tb-group">
<button id="btnAdd" class="btn primary" title="Add video files"><svg class="ic"><use href="#i-plus"/></svg>Add videos</button>
<button id="btnBrowse" class="btn ghost" title="Add videos using the built-in file browser"><svg class="ic"><use href="#i-folder"/></svg></button>
</div>
<div class="tb-group">
<button id="btnUndo" class="btn ghost" title="Undo (Ctrl+Z)"><svg class="ic"><use href="#i-undo"/></svg></button>
<button id="btnRedo" class="btn ghost" title="Redo (Ctrl+Y)"><svg class="ic"><use href="#i-redo"/></svg></button>
<button id="btnNew" class="btn ghost" title="Start a new, empty project"><svg class="ic"><use href="#i-new"/></svg>New</button>
</div>
<span class="grow"></span>
<button id="jobPill" class="job-pill hidden" title="Show export progress"></button>
<button id="btnHelp" class="btn ghost" title="Keyboard shortcuts"><svg class="ic"><use href="#i-help"/></svg></button>
<button id="btnExport" class="btn accent" title="Export the clips"><svg class="ic"><use href="#i-export"/></svg>Export…</button>
</header>
<div id="banner" class="banner hidden"></div>
<main class="layout">
<aside class="panel sources">
<div class="panel-head"><span>Source videos</span><span id="srcCount" class="count"></span></div>
<ul id="sourceList" class="source-list"></ul>
<div id="sourceEmpty" class="empty">
<svg class="big-ic"><use href="#i-film"/></svg>
<p>No videos yet.</p>
<p class="dim">Click <b>Add videos</b> to load one or more source files.</p>
</div>
</aside>
<section class="panel viewer">
<div id="screen" class="screen">
<video id="video" preload="auto" playsinline></video>
<img id="still" class="still hidden" alt="">
<div id="screenMsg" class="screen-msg hidden"></div>
<div id="noSource" class="screen-empty">Add a video, then click it in the list to preview it here.</div>
</div>
<div class="transport">
<div class="tc-box">
<span id="tcCur" class="tc editable" title="Current position — click to type a time">00:00.000</span>
<span class="tc-sep">/</span>
<span id="tcDur" class="tc dim">00:00.000</span>
<span id="tcFrame" class="tc-frame dim"></span>
</div>
<div class="tp-buttons">
<button class="tbtn" data-act="start" title="Go to start (Home)"><svg><use href="#i-start"/></svg></button>
<button class="tbtn" data-act="prevKey" title="Previous keyframe (↑)"><svg><use href="#i-key-prev"/></svg></button>
<button class="tbtn" data-act="back1s" title="Back 1 second (Shift+←)"><svg><use href="#i-sec-prev"/></svg></button>
<button class="tbtn" data-act="prevFrame" title="Previous frame (←)"><svg><use href="#i-frame-prev"/></svg></button>
<button class="tbtn play" data-act="play" id="btnPlay" title="Play / pause (Space)"><svg><use href="#i-play"/></svg></button>
<button class="tbtn" data-act="nextFrame" title="Next frame (→)"><svg><use href="#i-frame-next"/></svg></button>
<button class="tbtn" data-act="fwd1s" title="Forward 1 second (Shift+→)"><svg><use href="#i-sec-next"/></svg></button>
<button class="tbtn" data-act="nextKey" title="Next keyframe (↓)"><svg><use href="#i-key-next"/></svg></button>
<button class="tbtn" data-act="end" title="Go to end (End)"><svg><use href="#i-end"/></svg></button>
</div>
<div class="tp-right">
<button class="tbtn" data-act="mute" id="btnMute" title="Mute (M)"><svg><use href="#i-vol"/></svg></button>
<input id="volume" type="range" min="0" max="1" step="0.05" value="1" title="Volume">
</div>
</div>
<div class="timeline-row">
<div id="timeline" class="timeline">
<canvas id="tlCanvas"></canvas>
<div id="tlHover" class="tl-hover hidden"><img alt=""><span></span></div>
</div>
<div class="zoom">
<button class="zbtn" data-act="zoomIn" title="Zoom in (+ or mouse wheel)">+</button>
<button class="zbtn" data-act="zoomOut" title="Zoom out (−)">−</button>
<button class="zbtn fit" data-act="zoomFit" title="Show the whole video (0)">Fit</button>
</div>
</div>
<div class="marks">
<button class="btn mark-btn" data-act="setIn" title="Set the In point at the playhead (I). Shift+I jumps to it."><svg class="ic"><use href="#i-in"/></svg>Set In <kbd>I</kbd></button>
<div class="mark-val"><label>In</label><span id="markIn" class="tc editable" title="Click to type">–</span></div>
<button class="btn mark-btn" data-act="setOut" title="Set the Out point at the playhead (O). Shift+O jumps to it."><svg class="ic"><use href="#i-out"/></svg>Set Out <kbd>O</kbd></button>
<div class="mark-val"><label>Out</label><span id="markOut" class="tc editable" title="Click to type">–</span></div>
<div class="mark-val"><label>Length</label><span id="markLen" class="tc">–</span></div>
<span class="grow"></span>
<button class="btn ghost" data-act="split" title="Split the clip under the playhead in two (S)"><svg class="ic"><use href="#i-cut"/></svg>Split <kbd>S</kbd></button>
<button class="btn ghost" data-act="playSel" title="Play from In to Out (P)"><svg class="ic"><use href="#i-play"/></svg>Play In→Out <kbd>P</kbd></button>
<button class="btn primary" id="btnAddClip" data-act="addClip" title="Add the In→Out range as a clip (Enter)"><svg class="ic"><use href="#i-plus"/></svg><span>Add clip</span> <kbd>Enter</kbd></button>
</div>
<div id="markHint" class="mark-hint hidden"></div>
</section>
<section class="panel clips">
<div class="panel-head">
<span>Clips</span><span id="clipCount" class="count"></span>
<span id="clipTotal" class="dim small"></span>
<span class="grow"></span>
<span class="dim small">Export method</span>
<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>
</div>
</div>
<div id="clipList" class="clip-list"></div>
<div id="clipEmpty" class="empty row">
<p>No clips yet. Set an <b>In</b> and <b>Out</b> point on the timeline and press <b>Add clip</b>,
or use <b>+ Whole</b> on a source and <b>Split</b> it. Clips are joined in this order — drag them to reorder.</p>
</div>
</section>
</main>
<!-- Export dialog -->
<dialog id="dlgExport" class="dlg">
<div class="dlg-head"><h2>Export</h2><button class="icon-btn" data-close title="Close"><svg class="ic"><use href="#i-x"/></svg></button></div>
<div class="dlg-body" id="exportForm">
<div class="summary" id="exSummary"></div>
<div class="field">
<div class="label">Method</div>
<div class="cards2">
<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>
</div>
</div>
<div class="field">
<div class="label">Output</div>
<div class="radios">
<label><input type="radio" name="join" value="1"> Join all clips into one video</label>
<label><input type="radio" name="join" value="0"> Save each clip as a separate file</label>
</div>
</div>
<div class="field">
<div class="label">Save as</div>
<div class="row">
<input id="exOutput" type="text" spellcheck="false" autocomplete="off">
<select id="exContainer" title="File type"></select>
<button class="btn" id="exBrowse" type="button"><svg class="ic"><use href="#i-folder"/></svg>Browse…</button>
</div>
<div class="hint" id="exOutHint"></div>
</div>
<div id="exCopyOpts" class="field">
<div class="label">Audio</div>
<div class="radios">
<label id="exAllAudioRow"><input type="checkbox" id="exAllAudio"> Keep all audio tracks</label>
<label><input type="checkbox" id="exNoAudioCopy"> Remove audio</label>
</div>
</div>
<div id="exEncOpts">
<div class="grid2">
<div class="field">
<div class="label">Video codec</div>
<select id="exVCodec"></select>
</div>
<div class="field">
<div class="label">Speed</div>
<select id="exSpeed">
<option value="fast">Fast (bigger file)</option>
<option value="balanced">Balanced</option>
<option value="slow">Slow (smaller file)</option>
</select>
</div>
</div>
<div class="field">
<div class="label">Quality <span id="exQLabel" class="dim"></span></div>
<div class="row q-row"><span class="dim small">Best</span><input type="range" id="exQuality" min="0" max="51" step="1"><span class="dim small">Smallest</span></div>
</div>
<div class="grid2">
<div class="field">
<div class="label">Resolution</div>
<select id="exHeight">
<option value="0">Same as source</option>
<option value="2160">2160p (4K)</option>
<option value="1440">1440p</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="480">480p</option>
<option value="360">360p</option>
</select>
</div>
<div class="field">
<div class="label">Frame rate</div>
<select id="exFPS">
<option value="">Same as source</option>
<option value="24000/1001">23.976</option>
<option value="24">24</option>
<option value="25">25</option>
<option value="30000/1001">29.97</option>
<option value="30">30</option>
<option value="50">50</option>
<option value="60000/1001">59.94</option>
<option value="60">60</option>
</select>
</div>
</div>
<div class="grid2">
<div class="field">
<div class="label">Audio codec</div>
<select id="exACodec"></select>
</div>
<div class="field">
<div class="label">Audio bitrate</div>
<select id="exABitrate">
<option value="96">96 kbps</option>
<option value="128">128 kbps</option>
<option value="160">160 kbps</option>
<option value="192">192 kbps</option>
<option value="256">256 kbps</option>
<option value="320">320 kbps</option>
</select>
</div>
</div>
<div class="hint" id="exEncHint"></div>
</div>
<div id="exWarn" class="notice warn hidden"></div>
<div id="exProgress" class="progress hidden">
<div class="bar"><div id="exBar"></div></div>
<div class="pinfo"><span id="exPText"></span><span id="exPEta" class="dim"></span></div>
</div>
<div id="exResult" class="hidden"></div>
</div>
<div class="dlg-foot">
<button class="btn" data-close>Close</button>
<span class="grow"></span>
<button class="btn danger hidden" id="exCancel">Cancel export</button>
<button class="btn accent" id="exGo"><svg class="ic"><use href="#i-export"/></svg>Export</button>
</div>
</dialog>
<!-- Built-in file browser -->
<dialog id="dlgFiles" class="dlg files">
<div class="dlg-head"><h2 id="fbTitle">Add videos</h2><button class="icon-btn" data-close title="Close"><svg class="ic"><use href="#i-x"/></svg></button></div>
<div class="fb-bar">
<select id="fbRoots" title="Drive"></select>
<button class="btn" id="fbUp" title="Parent folder"><svg class="ic"><use href="#i-up"/></svg></button>
<input id="fbPath" type="text" spellcheck="false" autocomplete="off" title="Folder — type a path and press Enter">
</div>
<ul id="fbList" class="fb-list"></ul>
<div class="fb-save hidden" id="fbSaveRow"><label>File name</label><input id="fbName" type="text" spellcheck="false"></div>
<div class="dlg-foot">
<label class="small"><input type="checkbox" id="fbAll"> Show all files</label>
<span class="grow"></span>
<span id="fbSelInfo" class="dim small"></span>
<button class="btn" data-close>Cancel</button>
<button class="btn primary" id="fbOk">Add</button>
</div>
</dialog>
<!-- Confirm -->
<dialog id="dlgConfirm" class="dlg small">
<div class="dlg-body"><p id="cfText"></p></div>
<div class="dlg-foot"><span class="grow"></span><button class="btn" id="cfNo">Cancel</button><button class="btn accent" id="cfYes">OK</button></div>
</dialog>
<!-- Help -->
<dialog id="dlgHelp" class="dlg">
<div class="dlg-head"><h2>How to use VibEdit</h2><button class="icon-btn" data-close title="Close"><svg class="ic"><use href="#i-x"/></svg></button></div>
<div class="dlg-body help">
<ol class="steps">
<li><b>Add videos</b> and click one to preview it.</li>
<li>Move the playhead and press <kbd>I</kbd> / <kbd>O</kbd> to mark the start and end of a part you want, then <kbd>Enter</kbd> to add it as a clip. Repeat for every part (from any source).</li>
<li>Or add a whole video with <b>+ Whole</b>, put the playhead where you want to cut and press <kbd>S</kbd> to split; delete the pieces you don't want.</li>
<li>Drag clips to put them in order, then <b>Export</b> — joined into one video or as separate files.</li>
</ol>
<p class="dim">The In point includes the frame you see; the Out point cuts just before the frame you see. Your project is saved automatically.</p>
<table class="keys">
<tr><td><kbd>Space</kbd></td><td>Play / pause</td><td><kbd>I</kbd> <kbd>O</kbd></td><td>Set In / Out</td></tr>
<tr><td><kbd>←</kbd> <kbd>→</kbd></td><td>Previous / next frame</td><td><kbd>Shift</kbd>+<kbd>I</kbd>/<kbd>O</kbd></td><td>Go to In / Out</td></tr>
<tr><td><kbd>Shift</kbd>+<kbd>←</kbd> <kbd>→</kbd></td><td>Back / forward 1 s</td><td><kbd>Enter</kbd></td><td>Add clip (or finish editing)</td></tr>
<tr><td><kbd>Ctrl</kbd>+<kbd>←</kbd> <kbd>→</kbd></td><td>Back / forward 10 s</td><td><kbd>S</kbd></td><td>Split clip at playhead</td></tr>
<tr><td><kbd>↑</kbd> <kbd>↓</kbd></td><td>Previous / next keyframe</td><td><kbd>P</kbd></td><td>Play In → Out</td></tr>
<tr><td><kbd>Home</kbd> <kbd>End</kbd></td><td>Start / end</td><td><kbd>Delete</kbd></td><td>Delete selected clip</td></tr>
<tr><td><kbd>+</kbd> <kbd>−</kbd> <kbd>0</kbd></td><td>Zoom timeline / fit</td><td><kbd>Esc</kbd></td><td>Stop editing clip</td></tr>
<tr><td><kbd>M</kbd></td><td>Mute</td><td><kbd>Ctrl</kbd>+<kbd>Z</kbd> / <kbd>Y</kbd></td><td>Undo / redo</td></tr>
</table>
<p class="dim small" id="helpVersion"></p>
</div>
</dialog>
<div id="toasts" class="toasts"></div>
<div id="offline" class="offline hidden">
<div class="offline-box">
<h2>VibEdit is not running</h2>
<p>The VibEdit program was closed. Your project is saved — start VibEdit again to continue where you left off.</p>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

338
web/style.css Normal file
View File

@@ -0,0 +1,338 @@
:root {
--bg: #0e1014;
--panel: #161920;
--panel2: #1d2129;
--panel3: #252a34;
--line: #2b313c;
--line2: #3a4250;
--text: #e7e9ee;
--dim: #98a1b1;
--dim2: #6b7383;
--accent: #4c8dff;
--accent-h: #6aa1ff;
--mark: #ffb547;
--ok: #3ccf91;
--warn: #ffb547;
--danger: #ff5d5d;
--radius: 8px;
--mono: "Cascadia Mono", "Cascadia Code", Consolas, "SF Mono", ui-monospace, monospace;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--text);
font: 14px/1.4 "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
}
input, select, textarea, button { font: inherit; color: inherit; }
.hidden { display: none !important; }
.grow { flex: 1; }
.dim { color: var(--dim); }
.small { font-size: 12px; }
kbd {
font: 11px var(--mono);
padding: 1px 5px;
border: 1px solid var(--line2);
border-bottom-width: 2px;
border-radius: 4px;
color: var(--dim);
background: rgba(255,255,255,.03);
}
.btn kbd { margin-left: 2px; border-color: rgba(255,255,255,.18); color: inherit; opacity: .7; }
/* buttons */
.btn {
display: inline-flex; align-items: center; gap: 6px;
height: 32px; padding: 0 12px;
background: var(--panel3); border: 1px solid var(--line2); border-radius: 6px;
cursor: pointer; white-space: nowrap;
transition: background .12s, border-color .12s;
}
.btn:hover { background: #2d3340; border-color: #4a5364; }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .45; cursor: default; transform: none; }
.btn.ghost { background: transparent; border-color: transparent; }
.btn.ghost:hover { background: var(--panel3); border-color: var(--line2); }
.btn.primary { background: #2a4a85; border-color: #3a62ad; }
.btn.primary:hover { background: #335aa0; }
.btn.accent { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.btn.accent:hover { background: var(--accent-h); }
.btn.danger { background: #5a2226; border-color: #8a3036; }
.btn.danger:hover { background: #6c282d; }
.ic { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
.icon-btn { background: none; border: 0; padding: 6px; border-radius: 6px; color: var(--dim); cursor: pointer; display: inline-flex; }
.icon-btn:hover { background: var(--panel3); color: var(--text); }
/* top bar */
.topbar {
display: flex; align-items: center; gap: 14px;
height: 52px; padding: 0 12px;
background: var(--panel); border-bottom: 1px solid var(--line);
flex: none;
}
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 16px; letter-spacing: .2px; margin-right: 6px; }
.logo { width: 26px; height: 26px; border-radius: 7px; background: var(--accent); display: grid; place-items: center; }
.logo svg { width: 15px; height: 15px; color: #fff; margin-left: 2px; }
.tb-group { display: flex; gap: 4px; align-items: center; }
.tb-group + .tb-group { padding-left: 12px; border-left: 1px solid var(--line); }
.job-pill {
height: 30px; padding: 0 12px; border-radius: 15px; cursor: pointer;
border: 1px solid #3a62ad; background: #1c2b48; color: #cfe0ff; font-size: 13px;
background-image: linear-gradient(90deg, #2a4a85 var(--p, 0%), transparent var(--p, 0%));
}
.banner { background: #4a2a12; color: #ffd9b0; padding: 10px 16px; border-bottom: 1px solid #6b3d1b; flex: none; user-select: text; }
.banner code { background: rgba(0,0,0,.3); padding: 1px 5px; border-radius: 4px; }
.banner a { color: #ffc27a; }
/* layout */
.layout {
flex: 1; min-height: 0;
display: grid;
grid-template-columns: 290px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) 232px;
gap: 8px; padding: 8px;
}
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); min-height: 0; display: flex; flex-direction: column; }
.panel-head {
display: flex; align-items: center; gap: 8px;
height: 40px; padding: 0 12px; flex: none;
border-bottom: 1px solid var(--line);
font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: .6px; color: #c5cad4;
}
.panel-head .small, .panel-head .seg { text-transform: none; letter-spacing: 0; font-weight: 400; }
.count { background: var(--panel3); color: var(--dim); border-radius: 10px; padding: 0 7px; font-size: 12px; letter-spacing: 0; }
.count:empty { display: none; }
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 20px; color: var(--dim); }
.empty p { margin: 4px 0; max-width: 560px; }
.empty b { color: var(--text); }
.big-ic { width: 44px; height: 44px; fill: none; stroke: var(--dim2); stroke-width: 1.5; margin-bottom: 8px; }
/* sources */
.sources { grid-row: 1; grid-column: 1; }
.source-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; flex: 1; }
.source-list:empty { display: none; }
.src {
display: grid; grid-template-columns: 96px minmax(0,1fr); gap: 10px;
padding: 7px; border-radius: 7px; cursor: pointer; border: 1px solid transparent;
position: relative;
}
.src:hover { background: var(--panel2); }
.src.cur { background: #1c2740; border-color: #2f4a7d; }
.src-thumb { width: 96px; height: 54px; border-radius: 5px; background: #000 center/cover no-repeat; position: relative; overflow: hidden; }
.src-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.src-thumb::after { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--c); }
.src-thumb .audio-only { position: absolute; inset: 0; display: grid; place-items: center; color: var(--dim); font-size: 22px; }
.src-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 18px; }
.src-meta { color: var(--dim); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.src-actions { display: flex; gap: 4px; margin-top: 3px; align-items: center; }
.chip-btn {
font-size: 11.5px; height: 22px; padding: 0 7px; border-radius: 5px;
border: 1px solid var(--line2); background: var(--panel3); color: var(--dim); cursor: pointer;
display: inline-flex; align-items: center; gap: 4px;
}
.chip-btn:hover { color: var(--text); border-color: #4a5364; }
.chip-btn .ic { width: 13px; height: 13px; }
.src-x { position: absolute; top: 5px; right: 5px; opacity: 0; }
.src:hover .src-x, .src.cur .src-x { opacity: 1; }
.src-status { font-size: 11px; color: var(--dim2); }
/* viewer */
.viewer { grid-row: 1; grid-column: 2; overflow: hidden; }
.screen { flex: 1; min-height: 120px; position: relative; background: #000; border-radius: var(--radius) var(--radius) 0 0; overflow: hidden; }
.screen video, .screen .still { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
.screen-empty, .screen-msg {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: var(--dim); text-align: center; padding: 20px; pointer-events: none;
}
.screen-msg { inset: auto 12px 12px auto; padding: 8px 12px; background: rgba(10,12,16,.85); border: 1px solid var(--line2); border-radius: 6px; font-size: 13px; color: var(--text); pointer-events: auto; max-width: 70%; text-align: left; }
.screen-msg.center { inset: 0; margin: auto; height: fit-content; width: fit-content; }
.transport { display: flex; align-items: center; gap: 12px; padding: 8px 12px 4px; flex: none; }
.tc-box { display: flex; align-items: baseline; gap: 6px; min-width: 250px; }
.tc { font: 15px var(--mono); letter-spacing: .3px; }
#tcCur { font-size: 18px; color: #fff; }
.tc-sep { color: var(--dim2); }
.tc-frame { font: 12px var(--mono); }
.editable { cursor: text; border-radius: 4px; padding: 1px 4px; margin: -1px -4px; }
.editable:hover { background: var(--panel3); }
.tc-input { font: inherit; width: 9.5em; background: #0b0d11; border: 1px solid var(--accent); border-radius: 4px; color: #fff; padding: 0 4px; outline: none; }
.tp-buttons { flex: 1; display: flex; justify-content: center; gap: 2px; }
.tbtn {
width: 36px; height: 34px; border-radius: 6px; border: 0; background: transparent; color: #cdd2db;
display: grid; place-items: center; cursor: pointer;
}
.tbtn:hover { background: var(--panel3); color: #fff; }
.tbtn svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.tbtn.play { width: 46px; height: 38px; background: var(--panel3); border: 1px solid var(--line2); margin: 0 6px; }
.tbtn.play svg { width: 22px; height: 22px; }
.tp-right { display: flex; align-items: center; gap: 4px; min-width: 250px; justify-content: flex-end; }
#volume { width: 90px; accent-color: var(--accent); }
.timeline-row { display: flex; gap: 8px; padding: 4px 12px; flex: none; align-items: stretch; }
.timeline { flex: 1; position: relative; height: 74px; }
#tlCanvas { width: 100%; height: 100%; display: block; border-radius: 6px; cursor: pointer; }
.tl-hover {
position: absolute; bottom: calc(100% + 6px); transform: translateX(-50%);
background: #0b0d11; border: 1px solid var(--line2); border-radius: 6px; padding: 4px;
pointer-events: none; z-index: 5; display: flex; flex-direction: column; align-items: center; gap: 3px;
box-shadow: 0 6px 20px rgba(0,0,0,.5);
}
.tl-hover img { display: block; height: 72px; min-width: 40px; border-radius: 3px; background: #000; }
.tl-hover img:not([src]) { display: none; }
.tl-hover span { font: 12px var(--mono); }
.zoom { display: flex; flex-direction: column; gap: 3px; }
.zbtn { width: 38px; flex: 1; border-radius: 5px; border: 1px solid var(--line2); background: var(--panel3); cursor: pointer; font-size: 15px; line-height: 1; color: var(--dim); }
.zbtn.fit { font-size: 11px; }
.zbtn:hover { color: var(--text); }
.marks { display: flex; align-items: center; gap: 10px; padding: 6px 12px 10px; flex: none; flex-wrap: wrap; }
.mark-btn .ic { color: var(--mark); }
.mark-val { display: flex; flex-direction: column; line-height: 1.15; min-width: 88px; }
.mark-val label { font-size: 10.5px; text-transform: uppercase; letter-spacing: .6px; color: var(--dim2); }
.mark-val .tc { font-size: 14px; }
.mark-hint { margin: -4px 12px 10px; display: flex; flex-direction: column; gap: 6px; flex: none; }
.hint-row { padding: 6px 10px; border-radius: 6px; font-size: 13px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.hint-row.warn { background: #3a2a10; border: 1px solid #6b4a17; color: #ffd9a0; }
.hint-row.edit { background: #1c2740; border: 1px solid #2f4a7d; color: #cfe0ff; }
.hint-row .ic { width: 15px; height: 15px; }
.hint-row .chip-btn { color: inherit; background: rgba(255,255,255,.06); }
/* clips */
.clips { grid-row: 2; grid-column: 1 / span 2; }
.seg { display: inline-flex; background: var(--panel3); border: 1px solid var(--line2); border-radius: 7px; padding: 2px; }
.seg button { border: 0; background: transparent; padding: 4px 12px; border-radius: 5px; cursor: pointer; color: var(--dim); font-size: 13px; }
.seg button.on { background: var(--accent); color: #fff; }
.clip-list { flex: 1; display: flex; gap: 10px; padding: 10px 12px; overflow-x: auto; overflow-y: hidden; align-items: flex-start; }
.clip-list:empty { display: none; }
.empty.row { flex-direction: row; }
.clip {
flex: none; width: 178px; border-radius: 8px; background: var(--panel2); border: 1px solid var(--line);
display: flex; flex-direction: column; overflow: hidden; cursor: pointer; position: relative;
border-top: 3px solid var(--c);
}
.clip:hover { border-color: var(--line2); border-top-color: var(--c); }
.clip.sel { border-color: var(--accent); border-top-color: var(--c); box-shadow: 0 0 0 1px var(--accent); }
.clip.dragging { opacity: .4; }
.clip.drop-before::before, .clip.drop-after::after {
content: ""; position: absolute; top: 0; bottom: 0; width: 3px; background: var(--accent); border-radius: 2px;
}
.clip.drop-before::before { left: 0; }
.clip.drop-after::after { right: 0; }
.clip-thumb { height: 80px; background: #000; position: relative; }
.clip-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.clip-thumb .audio-only { position: absolute; inset: 0; display: grid; place-items: center; color: var(--dim); font-size: 26px; }
.clip-num { position: absolute; left: 6px; top: 6px; background: rgba(0,0,0,.75); border-radius: 4px; padding: 0 6px; font: 600 12px var(--mono); }
.clip-dur { position: absolute; right: 6px; bottom: 6px; background: rgba(0,0,0,.75); border-radius: 4px; padding: 0 6px; font: 12px var(--mono); }
.clip-warn { position: absolute; right: 6px; top: 6px; background: rgba(58,42,16,.92); color: var(--warn); border-radius: 4px; padding: 1px 4px; display: flex; }
.clip-warn .ic { width: 14px; height: 14px; }
.clip-body { padding: 6px 8px 2px; min-width: 0; }
.clip-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.clip-range { font: 11.5px var(--mono); color: var(--dim); white-space: nowrap; }
.clip-actions { display: flex; justify-content: space-between; padding: 2px 4px 4px; opacity: .55; }
.clip:hover .clip-actions, .clip.sel .clip-actions { opacity: 1; }
.clip-actions button { background: none; border: 0; color: var(--dim); padding: 4px; border-radius: 5px; cursor: pointer; display: grid; }
.clip-actions button:hover { background: var(--panel3); color: var(--text); }
.clip-actions button.del:hover { color: var(--danger); }
.clip-actions .ic { width: 15px; height: 15px; }
/* dialogs */
.dlg {
background: var(--panel); color: var(--text); border: 1px solid var(--line2); border-radius: 12px;
padding: 0; width: min(680px, calc(100vw - 32px)); max-height: calc(100vh - 40px);
box-shadow: 0 20px 60px rgba(0,0,0,.6);
}
.dlg[open] { display: flex; flex-direction: column; }
.dlg.small { width: min(460px, calc(100vw - 32px)); }
.dlg::backdrop { background: rgba(5,6,9,.65); }
.dlg-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px 6px; }
.dlg-head h2 { margin: 0; font-size: 18px; }
.dlg-body { padding: 8px 18px 12px; overflow-y: auto; user-select: text; }
.dlg-body p { white-space: pre-line; }
.dlg-foot { display: flex; align-items: center; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--line); }
.field { margin: 12px 0; }
.label { font-size: 12px; text-transform: uppercase; letter-spacing: .6px; color: var(--dim); margin-bottom: 6px; font-weight: 600; }
.label .dim { text-transform: none; letter-spacing: 0; font-weight: 400; }
.row { display: flex; gap: 8px; align-items: center; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
.dlg input[type=text], .dlg select {
height: 34px; background: #0f1217; border: 1px solid var(--line2); border-radius: 6px; padding: 0 9px; outline: none; min-width: 0;
}
.dlg input[type=text]:focus, .dlg select:focus { border-color: var(--accent); }
.dlg select { width: 100%; }
.row input[type=text] { flex: 1; }
.row select { width: auto; }
.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; }
.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; }
.rcard b { display: block; margin-bottom: 2px; }
.rcard small { color: var(--dim); font-size: 12.5px; line-height: 1.35; display: block; }
.q-row input { flex: 1; accent-color: var(--accent); }
.hint { font-size: 12.5px; color: var(--dim); margin-top: 5px; }
.summary { background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 9px 12px; color: #cfd5df; }
.notice { border-radius: 8px; padding: 9px 12px; margin: 12px 0; white-space: pre-line; font-size: 13px; }
.notice.warn { background: #3a2a10; border: 1px solid #6b4a17; color: #ffd9a0; }
.notice.error { background: #3a1416; border: 1px solid #7a2a2f; color: #ffc2c2; font-family: var(--mono); font-size: 12px; max-height: 220px; overflow: auto; }
.notice.ok { background: #10301f; border: 1px solid #1f6a43; color: #b8f0d2; }
.notice.ok .paths { font: 12px var(--mono); margin: 6px 0; word-break: break-all; }
.progress { margin: 14px 0 4px; }
.bar { height: 10px; background: var(--panel3); border-radius: 5px; overflow: hidden; }
.bar > div { height: 100%; width: 0; background: linear-gradient(90deg, var(--accent), #7fb0ff); transition: width .25s; }
.pinfo { display: flex; justify-content: space-between; margin-top: 6px; font-size: 13px; }
/* file browser */
.dlg.files { width: min(760px, calc(100vw - 32px)); height: min(640px, calc(100vh - 40px)); }
.fb-bar { display: flex; gap: 6px; padding: 6px 18px 10px; }
.fb-bar select { width: auto !important; }
.fb-bar input { flex: 1; }
.fb-list { list-style: none; margin: 0 18px; padding: 4px; flex: 1; overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; background: #0f1217; min-height: 0; }
.fb-list li { display: flex; align-items: center; gap: 9px; padding: 5px 8px; border-radius: 5px; cursor: pointer; }
.fb-list li:hover { background: var(--panel2); }
.fb-list li.sel { background: #22375e; }
.fb-list li .ic { color: var(--dim); }
.fb-list li.dir .ic { color: #e2b45b; }
.fb-list .fb-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fb-list .fb-size { color: var(--dim2); font-size: 12px; font-family: var(--mono); }
.fb-list .fb-empty { color: var(--dim); justify-content: center; cursor: default; padding: 30px; }
.fb-save { display: flex; gap: 10px; align-items: center; padding: 10px 18px 0; }
.fb-save input { flex: 1; }
/* help */
.help ol.steps { padding-left: 20px; margin-top: 4px; }
.help ol.steps li { margin: 6px 0; }
.keys { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13px; }
.keys td { padding: 5px 6px; border-bottom: 1px solid var(--line); }
.keys td:nth-child(odd) { white-space: nowrap; }
/* toasts */
.toasts { position: fixed; right: 16px; top: 62px; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; z-index: 100; pointer-events: none; }
.toast {
background: #232834; border: 1px solid var(--line2); border-radius: 8px; padding: 9px 14px; max-width: min(520px, calc(100vw - 32px));
box-shadow: 0 8px 24px rgba(0,0,0,.5); pointer-events: auto; white-space: pre-line; animation: tin .18s ease-out;
}
.toast.error { border-color: #8a3036; background: #3a1a1d; }
.toast.ok { border-color: #1f6a43; background: #12301f; }
@keyframes tin { from { opacity: 0; transform: translateY(-8px); } }
.offline { position: fixed; inset: 0; background: rgba(5,6,9,.85); display: grid; place-items: center; z-index: 200; }
.offline-box { background: var(--panel); border: 1px solid var(--line2); border-radius: 12px; padding: 24px 28px; max-width: 460px; text-align: center; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: #333a47; border-radius: 5px; border: 2px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-track { background: transparent; }
@media (max-width: 980px) {
.layout { grid-template-columns: 1fr; grid-template-rows: 160px minmax(0, 1fr) 232px; }
.sources { grid-row: 1; grid-column: 1; }
.viewer { grid-row: 2; grid-column: 1; }
.clips { grid-row: 3; grid-column: 1; }
.tc-box, .tp-right { min-width: 0; }
}

BIN
winres/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

42
winres/winres.json Normal file
View File

@@ -0,0 +1,42 @@
{
"RT_GROUP_ICON": {
"APP": {
"0000": "icon.png"
}
},
"RT_MANIFEST": {
"#1": {
"0409": {
"identity": {
"name": "VibEdit",
"version": "1.0.0.0"
},
"description": "VibEdit - simple ffmpeg video cutter",
"minimum-os": "win7",
"execution-level": "as invoker",
"dpi-awareness": "system",
"long-path-aware": true
}
}
},
"RT_VERSION": {
"#1": {
"0000": {
"fixed": {
"file_version": "1.0.0.0",
"product_version": "1.0.0.0"
},
"info": {
"0409": {
"FileDescription": "VibEdit - simple ffmpeg video cutter",
"FileVersion": "1.0.0",
"InternalName": "vibedit",
"OriginalFilename": "vibedit.exe",
"ProductName": "VibEdit",
"ProductVersion": "1.0.0"
}
}
}
}
}
}