commit 396a676146c044fa076b323daac692c2850650e8 Author: Sean MTS Date: Thu Oct 15 02:28:47 2026 +0000 Initial commit Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3dc48e4 --- /dev/null +++ b/.gitignore @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..14c58db --- /dev/null +++ b/README.md @@ -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`. diff --git a/app.go b/app.go new file mode 100644 index 0000000..d907381 --- /dev/null +++ b/app.go @@ -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, `VibEdit`+ + `

This VibEdit link has expired

Use the address shown in the VibEdit console window, or restart VibEdit.

`) + 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) +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..db52c22 --- /dev/null +++ b/build.sh @@ -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" diff --git a/export.go b/export.go new file mode 100644 index 0000000..1a7c8ed --- /dev/null +++ b/export.go @@ -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 +} diff --git a/ffmpeg.go b/ffmpeg.go new file mode 100644 index 0000000..f856024 --- /dev/null +++ b/ffmpeg.go @@ -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) +} diff --git a/fs.go b/fs.go new file mode 100644 index 0000000..ae19c6a --- /dev/null +++ b/fs.go @@ -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() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1034e5c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module vibedit + +go 1.22 diff --git a/jobs.go b/jobs.go new file mode 100644 index 0000000..c48b726 --- /dev/null +++ b/jobs.go @@ -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): + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..86e71af --- /dev/null +++ b/main.go @@ -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) +} diff --git a/media.go b/media.go new file mode 100644 index 0000000..7a5e66f --- /dev/null +++ b/media.go @@ -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) + } +} diff --git a/package-readme.txt b/package-readme.txt new file mode 100644 index 0000000..c5e11c2 --- /dev/null +++ b/package-readme.txt @@ -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. diff --git a/platform_other.go b/platform_other.go new file mode 100644 index 0000000..7ed6f0a --- /dev/null +++ b/platform_other.go @@ -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 } diff --git a/platform_windows.go b/platform_windows.go new file mode 100644 index 0000000..78da3d9 --- /dev/null +++ b/platform_windows.go @@ -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< { + +// ---------------------------------------------------------------- helpers +const $ = (s, el = document) => el.querySelector(s); +const $$ = (s, el = document) => [...el.querySelectorAll(s)]; +const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +function h(tag, attrs, ...kids) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v == null || v === false) continue; + if (k === 'class') el.className = v; + else if (k === 'style') el.style.cssText = v; + else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); + else el.setAttribute(k, v === true ? '' : v); + } + for (const kid of kids.flat()) { + if (kid != null && kid !== false) el.append(kid.nodeType ? kid : document.createTextNode(kid)); + } + return el; +} + +function icon(name, cls = 'ic') { + const ns = 'http://www.w3.org/2000/svg'; + const s = document.createElementNS(ns, 'svg'); + s.setAttribute('class', cls); + const u = document.createElementNS(ns, 'use'); + u.setAttribute('href', '#i-' + name); + s.append(u); + return s; +} + +async function api(path, body) { + const opt = body === undefined ? {} : { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + }; + const r = await fetch(path, opt); + let data = null; + try { data = await r.json(); } catch { /* not JSON */ } + if (!r.ok) { + const e = new Error((data && data.error) || `${r.status} ${r.statusText}`); + e.status = r.status; + e.data = data; + throw e; + } + return data; +} + +function fmtTime(t, hours) { + if (t == null || !isFinite(t)) return '–'; + const ms = Math.round(Math.max(0, t) * 1000); + const H = Math.floor(ms / 3600000), M = Math.floor(ms / 60000) % 60, sec = Math.floor(ms / 1000) % 60, f = ms % 1000; + const p = (n, w = 2) => String(n).padStart(w, '0'); + return (H || hours ? H + ':' + p(M) : p(M)) + ':' + p(sec) + '.' + p(f, 3); +} + +// fmtT formats a time on the current source's scale (hours only for long videos). +const fmtT = (t, m = cur()) => fmtTime(t, m && m.duration >= 3600); + +function fmtLen(t) { + if (t == null || !isFinite(t)) return '–'; + if (t < 60) return t.toFixed(t < 10 ? 2 : 1) + ' s'; + t = Math.round(t * 10) / 10; + const H = Math.floor(t / 3600), M = Math.floor(t / 60) % 60, sec = t - Math.floor(t / 60) * 60; + return (H ? H + ':' + String(M).padStart(2, '0') : M) + ':' + sec.toFixed(1).padStart(4, '0'); +} + +function fmtETA(s) { + if (!isFinite(s) || s < 0) return ''; + if (s < 60) return Math.max(1, Math.round(s)) + ' s'; + if (s < 3600) return Math.floor(s / 60) + ' min ' + String(Math.round(s % 60)).padStart(2, '0') + ' s'; + return Math.floor(s / 3600) + ' h ' + Math.round((s % 3600) / 60) + ' min'; +} + +function fmtBytes(n) { + if (n < 1024) return n + ' B'; + const u = ['KB', 'MB', 'GB', 'TB']; + let i = -1; + do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1); + return n.toFixed(n < 10 ? 1 : 0) + ' ' + u[i]; +} + +const fmtFps = f => (Math.round(f * 1000) / 1000) + ' fps'; + +// parseTime accepts "1:02:03.5", "02:03", "83.5" (seconds). +function parseTime(str) { + str = String(str).trim().replace(',', '.'); + if (!str) return null; + const parts = str.split(':'); + if (parts.length > 3 || parts.some(p => !/^\d+(\.\d*)?$/.test(p))) return null; + return parts.reduce((acc, p) => acc * 60 + parseFloat(p), 0); +} + +function splitPath(p) { + const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); + return i < 0 ? ['', p] : [p.slice(0, i) || p.slice(0, 1), p.slice(i + 1)]; +} +function joinPath(dir, name) { + if (!dir) return name; + const sep = (S.info && S.info.sep) || '/'; + return /[\\/]$/.test(dir) ? dir + name : dir + sep + name; +} +function extOf(p) { + const n = splitPath(p || '')[1]; + const i = n.lastIndexOf('.'); + return i > 0 ? n.slice(i + 1).toLowerCase() : ''; +} +const stripExt = n => { const i = n.lastIndexOf('.'); return i > 0 ? n.slice(0, i) : n; }; +function replaceExt(p, ext) { + const [d, n] = splitPath(p); + return joinPath(d, stripExt(n) + '.' + ext); +} + +// Elements that makeEditable temporarily swaps out of the DOM. +const tcCurEl = $('#tcCur'), markInEl = $('#markIn'), markOutEl = $('#markOut'); + +// ---------------------------------------------------------------- state +const DEFAULTS = { + mode: 'copy', join: true, output: '', allAudio: true, noAudio: false, + vcodec: 'libx264', quality: 20, speed: 'balanced', height: 0, fps: '', acodec: 'aac', abitrate: 192, + lastDir: '', volume: 1, muted: false, +}; +const PALETTE = ['#4c8dff', '#3ccf91', '#ff8a4c', '#c77dff', '#ffd23f', '#ff5d8f', '#2ec4d6', '#a3d14b']; + +const S = { + info: null, + sources: [], // probed media (see media.go Media) + clips: [], // {id, src, in, out} + cur: null, // source id shown in the viewer + marks: {}, // source id -> {in, out} (scratch selection) + sel: null, // selected clip id (being edited) + kf: {}, // source id -> {status, times} + play: {}, // source id -> 'direct' | 'proxy' | 'still' + proxyJob: {}, // source id -> running proxy job + jobs: {}, // job id -> last known job state + exportJob: null, + settings: { ...DEFAULTS }, + time: 0, + view: { start: 0, end: 1 }, + playSelEnd: null, + undo: [], + redo: [], + restored: false, +}; + +const srcById = id => S.sources.find(m => m.id === id); +const cur = () => srcById(S.cur); +const fpsOf = m => (m && m.video && m.video.fps) || 30; +const marksOf = id => S.marks[id] || (S.marks[id] = { in: null, out: null }); +const selClip = () => S.clips.find(c => c.id === S.sel); +const editingClip = () => { const c = selClip(); return c && c.src === S.cur ? c : null; }; +let idSeq = 0; +const newId = () => 'c' + Date.now().toString(36) + (idSeq++).toString(36); + +function assignColor(m) { + const used = new Set(S.sources.map(s => s.color)); + m.color = PALETTE.find(c => !used.has(c)) || PALETTE[S.sources.length % PALETTE.length]; +} + +// frameFloor snaps a time to the start of the frame shown at that time. +function frameFloor(t, m = cur()) { + const fps = fpsOf(m); + return Math.floor(t * fps + 1e-3) / fps; +} + +// activeRange is what the In/Out controls edit: the selected clip if it +// belongs to the current source, else the scratch marks. +function activeRange() { + const c = editingClip(); + if (c) return { in: c.in, out: c.out, clip: c }; + const m = cur(); + if (!m) return { in: null, out: null, clip: null }; + const mk = marksOf(m.id); + return { in: mk.in, out: mk.out, clip: null }; +} + +// The timeline canvas is redrawn on the next animation frame when dirty. +let needDraw = true; +const requestDraw = () => { needDraw = true; }; + +const thumbURL = (id, t, hgt) => `/api/thumb?id=${id}&t=${Math.max(0, t).toFixed(3)}&h=${hgt}`; + +// ---------------------------------------------------------------- undo +function snapshot() { + return { clips: S.clips.map(c => ({ ...c })), sources: S.sources.slice(), sel: S.sel }; +} +function commit(fn) { + S.undo.push(snapshot()); + if (S.undo.length > 300) S.undo.shift(); + S.redo.length = 0; + fn(); + changed(); +} +function restoreSnap(s) { + S.clips = s.clips; + S.sources = s.sources; + S.sel = s.sel && S.clips.some(c => c.id === s.sel) ? s.sel : null; + if (!srcById(S.cur)) { + S.cur = null; + if (S.sources[0]) loadSource(S.sources[0].id); else unloadViewer(); + } + changed(); +} +function undo() { if (S.undo.length) { S.redo.push(snapshot()); restoreSnap(S.undo.pop()); } } +function redo() { if (S.redo.length) { S.undo.push(snapshot()); restoreSnap(S.redo.pop()); } } + +function changed() { + renderAll(); + saveSoon(); +} + +// ---------------------------------------------------------------- toasts & confirm +function toast(msg, kind = '') { + const box = $('#toasts'); + const el = h('div', { class: 'toast ' + kind }, msg); + box.append(el); + while (box.children.length > 4) box.firstChild.remove(); + setTimeout(() => el.remove(), kind === 'error' ? 8000 : 3500); +} + +function confirmDlg(text, okLabel = 'OK') { + return new Promise(resolve => { + const d = $('#dlgConfirm'); + $('#cfText').textContent = text; + $('#cfYes').textContent = okLabel; + let result = false; + $('#cfYes').onclick = () => { result = true; d.close(); }; + $('#cfNo').onclick = () => d.close(); + d.onclose = () => resolve(result); + d.showModal(); + $('#cfYes').focus(); + }); +} + +// ---------------------------------------------------------------- sources +async function addFiles(builtin = false) { + if (!S.info.ffmpegOK) return toast('ffmpeg was not found — see the message at the top of the window.', 'error'); + if (!builtin && S.info.nativeDialogs) { + try { + const dir = S.settings.lastDir || (cur() ? splitPath(cur().path)[0] : ''); + const r = await api('/api/dialog/open', { dir }); + if (r.supported) { + if (r.paths && r.paths.length) { + S.settings.lastDir = splitPath(r.paths[0])[0]; + await addPaths(r.paths); + } + return; + } + if (r.error) toast('The Windows file dialog could not be opened, using the built-in browser instead.', 'error'); + } catch (e) { + if (e.status === 409) return; + } + } + const paths = await fileBrowser({ mode: 'open' }); + if (paths && paths.length) await addPaths(paths); +} + +async function addPaths(paths) { + const results = await Promise.all(paths.map(p => + api('/api/probe', { path: p }).catch(e => { toast(e.message, 'error'); return null; }))); + const fresh = []; + for (const m of results) { + if (m && !srcById(m.id) && !fresh.some(f => f.id === m.id)) fresh.push(m); + } + if (fresh.length) { + commit(() => { + for (const m of fresh) { assignColor(m); S.sources.push(m); } + }); + fresh.forEach(m => ensureKeyframes(m.id)); + } + const target = fresh[0] || results.find(Boolean); + if (target && (fresh.length || !S.cur)) loadSource(target.id); + if (fresh.length > 1) toast(`Added ${fresh.length} videos`, 'ok'); +} + +async function removeSource(id) { + const m = srcById(id); + if (!m) return; + const n = S.clips.filter(c => c.src === id).length; + if (n && !await confirmDlg(`Remove "${m.name}" and its ${n} clip${n > 1 ? 's' : ''} from the project?\n(The file itself is not touched.)`, 'Remove')) return; + commit(() => { + S.sources = S.sources.filter(s => s.id !== id); + S.clips = S.clips.filter(c => c.src !== id); + if (S.sel && !selClip()) S.sel = null; + }); + if (S.cur === id) { + S.cur = null; + if (S.sources[0]) loadSource(S.sources[0].id); else unloadViewer(); + } +} + +function addWhole(id) { + const m = srcById(id); + if (!m) return; + commit(() => S.clips.push({ id: newId(), src: id, in: 0, out: m.duration })); + if (S.cur !== id) loadSource(id); + toast(`Added all of "${m.name}" as clip ${S.clips.length}. Press S at the playhead to split it.`, 'ok'); +} + +async function newProject() { + if ((S.sources.length || S.clips.length) && + !await confirmDlg('Start a new, empty project?\nThis removes all sources and clips from the editor (the video files are not touched). You can undo this.', 'New project')) return; + commit(() => { S.sources = []; S.clips = []; S.sel = null; }); + S.marks = {}; + S.settings.output = ''; + S.cur = null; + unloadViewer(); + changed(); +} + +async function ensureKeyframes(id) { + const m = srcById(id); + if (!m) return; + if (!m.video) { S.kf[id] = { status: 'done', times: [] }; return; } + if (S.kf[id] && S.kf[id].status !== 'error') return; + S.kf[id] = { status: 'pending', times: [] }; + renderSources(); + for (let i = 0; ; i++) { + let r; + try { + r = await api('/api/keyframes?id=' + id); + } catch { + S.kf[id] = { status: 'error', times: [] }; + break; + } + if (r.status !== 'pending') { + S.kf[id] = { status: r.status, times: r.times || [] }; + break; + } + await sleep(Math.min(2000, 250 + i * 150)); + } + renderSources(); renderClips(); renderMarks(); renderTime(); requestDraw(); + if ($('#dlgExport').open) updateExportView(); +} + +// kfBefore returns the last keyframe at or before t (with a little tolerance). +function kfBefore(id, t) { + const a = S.kf[id] && S.kf[id].times; + if (!a || !a.length) return null; + let lo = 0, hi = a.length - 1, r = null; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (a[mid] <= t + 1e-3) { r = a[mid]; lo = mid + 1; } else hi = mid - 1; + } + return r; +} +function kfAfter(id, t) { + const a = S.kf[id] && S.kf[id].times; + if (!a || !a.length) return null; + let lo = 0, hi = a.length - 1, r = null; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (a[mid] > t + 1e-3) { r = a[mid]; hi = mid - 1; } else lo = mid + 1; + } + return r; +} +// kfOffset: how much earlier a lossless export starts than the marked In. +function kfOffset(srcId, tIn) { + const k = kfBefore(srcId, tIn); + if (k == null) return 0; + const off = tIn - k; + return off > 0.5 / fpsOf(srcById(srcId)) ? off : 0; +} + +// ---------------------------------------------------------------- viewer +const video = $('#video'); +const still = $('#still'); +let pendingSeek = null; +let loadedFor = null; + +const playMode = m => (m && S.play[m.id]) || 'direct'; +const isVideoMode = () => { const m = cur(); return !!m && playMode(m) !== 'still'; }; + +function loadSource(id, { time = 0, keepView = false } = {}) { + const m = srcById(id); + if (!m) return; + const switching = S.cur !== id; + S.cur = id; + S.playSelEnd = null; + if (S.sel && (!selClip() || selClip().src !== id)) S.sel = null; + S.time = clamp(time, 0, m.duration); + if (switching || !keepView) S.view = { start: 0, end: m.duration }; + ensureKeyframes(id); + attachMedia(); + changed(); +} + +function attachMedia() { + const m = cur(); + if (!m) return; + video.pause(); + const mode = playMode(m); + const key = m.id + ':' + mode; + if (mode === 'still') { + if (video.getAttribute('src')) { video.removeAttribute('src'); video.load(); } + loadedFor = key; + updateStill(true); + } else if (loadedFor !== key) { + loadedFor = key; + pendingSeek = S.time; + video.src = `/api/media/${m.id}${mode === 'proxy' ? '?proxy=1' : ''}`; + } else { + seekVideo(S.time); + } + renderScreen(); +} + +function unloadViewer() { + video.pause(); + if (video.getAttribute('src')) { video.removeAttribute('src'); video.load(); } + loadedFor = null; + S.time = 0; + S.view = { start: 0, end: 1 }; + renderAll(); +} + +video.addEventListener('loadedmetadata', () => { + const m = cur(); + if (!m || !loadedFor || !loadedFor.startsWith(m.id)) return; + if (m.video && !video.videoWidth) return unplayable(); + if (pendingSeek != null) { + video.currentTime = Math.min(pendingSeek + 1e-4, m.duration); + pendingSeek = null; + } + renderScreen(); +}); +video.addEventListener('error', () => { + if (video.getAttribute('src')) unplayable(); +}); +for (const ev of ['play', 'pause', 'ended']) video.addEventListener(ev, renderPlay); +video.addEventListener('seeked', requestDraw); + +// unplayable switches to still frames and asks the server for an H.264 +// preview copy the browser can play. +function unplayable() { + const m = cur(); + if (!m) return; + const mode = playMode(m); + S.play[m.id] = 'still'; + video.removeAttribute('src'); + video.load(); + loadedFor = m.id + ':still'; + if (mode === 'direct') startProxy(m.id); + else toast('The preview copy could not be played either — showing still frames.', 'error'); + updateStill(true); + renderScreen(); +} + +async function startProxy(id) { + try { + const r = await api('/api/proxy', { id }); + if (r.ready) return proxyReady(id); + if (!S.proxyJob[id]) S.proxyJob[id] = { id: r.jobId, state: 'running', progress: 0 }; + } catch (e) { + toast('Could not create a preview: ' + e.message, 'error'); + } + renderScreen(); +} + +function proxyReady(id) { + S.play[id] = 'proxy'; + delete S.proxyJob[id]; + if (S.cur === id) { loadedFor = null; attachMedia(); } + renderScreen(); +} + +let stillTimer = 0; +let stillWant = ''; +function updateStill(now) { + const m = cur(); + if (!m || playMode(m) !== 'still' || !m.video) return; + clearTimeout(stillTimer); + const go = () => { + const url = thumbURL(m.id, S.time, Math.min(720, m.video.displayHeight || 720)); + if (stillWant === url) return; + stillWant = url; + const img = new Image(); + img.onload = () => { if (stillWant === url) still.src = url; }; + img.src = url; + }; + if (now) go(); else stillTimer = setTimeout(go, 60); +} + +function seekVideo(t) { + if (!isVideoMode()) return; + if (video.readyState >= 1) video.currentTime = Math.min(t + 1e-4, cur().duration); + else pendingSeek = t; +} + +function seek(t, { quantize = true } = {}) { + const m = cur(); + if (!m) return; + t = clamp(t, 0, m.duration); + if (quantize) t = frameFloor(t, m); + S.time = t; + seekVideo(t); + updateStill(); + followPlayhead(true); + renderTime(); + requestDraw(); +} + +function syncTime() { + if (isVideoMode() && video.readyState >= 1 && !video.seeking) S.time = video.currentTime; +} + +const seekBy = d => { syncTime(); seek(S.time + d); }; + +function stepFrames(n) { + const m = cur(); + if (!m) return; + video.pause(); + syncTime(); + const fps = fpsOf(m); + seek((Math.floor(S.time * fps + 1e-3) + n) / fps); +} + +function gotoKey(dir) { + const m = cur(); + if (!m) return; + const k = S.kf[m.id]; + if (!k || k.status !== 'done') { + return toast(k && k.status === 'error' ? 'Keyframe positions are not available for this video.' : 'Still reading keyframe positions…'); + } + syncTime(); + const t = dir < 0 ? kfBefore(m.id, S.time - 0.5 / fpsOf(m) - 1e-3) : kfAfter(m.id, S.time); + if (t != null) seek(t, { quantize: false }); +} + +function togglePlay() { + const m = cur(); + if (!m || !isVideoMode()) return; + if (video.paused) { + S.playSelEnd = null; + if (S.time >= m.duration - 0.05) seek(0); + video.play().catch(() => {}); + } else { + video.pause(); + } +} + +function playSelection() { + const m = cur(); + if (!m || !isVideoMode()) return; + const r = activeRange(); + const a = r.in ?? 0, b = r.out ?? m.duration; + if (b <= a) return; + seek(a, { quantize: false }); + S.playSelEnd = b; + video.play().catch(() => {}); +} + +function gotoMark(which) { + const m = cur(); + const r = activeRange(); + if (!m || r[which] == null) return; + // Out is exclusive, so show the last frame that is included. + seek(which === 'in' ? r.in : Math.max(r.in ?? 0, r.out - 1 / fpsOf(m)), { quantize: which === 'out' }); +} + +function toggleMute() { + video.muted = !video.muted; + S.settings.muted = video.muted; + renderMute(); + saveSoon(); +} + +// ---------------------------------------------------------------- marks & clips +function outAt(t) { + const m = cur(); + return t >= m.duration - 1 / fpsOf(m) ? m.duration : frameFloor(t, m); +} + +function setIn(t) { + const m = cur(); + if (!m) return toast('Add a video first.'); + syncTime(); + t = clamp(t ?? frameFloor(S.time, m), 0, m.duration); + const c = editingClip(); + if (c) { + if (t >= c.out - 1e-6) return toast('The In point must be before the Out point.'); + commit(() => { c.in = t; }); + return; + } + const mk = marksOf(m.id); + mk.in = t; + if (mk.out != null && mk.out <= t) mk.out = null; + changed(); +} + +function setOut(t) { + const m = cur(); + if (!m) return toast('Add a video first.'); + syncTime(); + t = clamp(t ?? outAt(S.time), 0, m.duration); + const c = editingClip(); + if (c) { + if (t <= c.in + 1e-6) return toast('The Out point must be after the In point.'); + commit(() => { c.out = t; }); + return; + } + const mk = marksOf(m.id); + mk.out = t; + if (mk.in != null && mk.in >= t) mk.in = null; + changed(); +} + +function addClipOrDone() { + if (editingClip()) { S.sel = null; changed(); return; } + const m = cur(); + if (!m) return toast('Add a video first.'); + const mk = marksOf(m.id); + if (mk.in == null && mk.out == null) return toast('Set an In and/or Out point first (I and O keys).'); + const a = mk.in ?? 0, b = mk.out ?? m.duration; + if (b - a < 0.01) return toast('The clip is empty — the Out point must be after the In point.'); + commit(() => { + S.clips.push({ id: newId(), src: m.id, in: a, out: b }); + S.marks[m.id] = { in: null, out: null }; + }); + toast(`Clip ${S.clips.length} added (${fmtLen(b - a)})`, 'ok'); + const list = $('#clipList'); + requestAnimationFrame(() => list.scrollTo({ left: list.scrollWidth, behavior: 'smooth' })); +} + +function selectClip(id, { seekTo = true } = {}) { + const c = S.clips.find(x => x.id === id); + if (!c) return; + S.sel = id; + if (S.cur !== c.src) loadSource(c.src, { time: c.in }); + else if (seekTo) seek(c.in, { quantize: false }); + const v = S.view; + if (c.in < v.start || c.out > v.end) { + const span = Math.max(v.end - v.start, (c.out - c.in) * 1.2); + setView(c.in - (span - (c.out - c.in)) / 2, c.in - (span - (c.out - c.in)) / 2 + span); + } + changed(); +} + +function split() { + const m = cur(); + if (!m) return; + syncTime(); + const t = frameFloor(S.time, m); + const inside = x => x.src === m.id && x.in + 1e-3 < t && t < x.out - 1e-3; + let c = editingClip(); + if (!c || !inside(c)) c = S.clips.find(inside); + if (!c) return toast('Put the playhead inside one of this video\'s clips to split it.\nTip: "+ Whole" adds the entire video as one clip.'); + commit(() => { + const i = S.clips.indexOf(c); + S.clips.splice(i + 1, 0, { id: newId(), src: c.src, in: t, out: c.out }); + c.out = t; + S.sel = null; + }); + toast(`Split at ${fmtT(t)}`, 'ok'); +} + +function deleteClip(id) { + commit(() => { + S.clips = S.clips.filter(c => c.id !== id); + if (S.sel === id) S.sel = null; + }); +} + +function duplicateClip(id) { + const i = S.clips.findIndex(c => c.id === id); + if (i < 0) return; + commit(() => S.clips.splice(i + 1, 0, { ...S.clips[i], id: newId() })); +} + +function moveClipTo(id, idx) { + const i = S.clips.findIndex(c => c.id === id); + if (i < 0 || idx === i || idx === i + 1) return; + commit(() => { + const [c] = S.clips.splice(i, 1); + S.clips.splice(idx > i ? idx - 1 : idx, 0, c); + }); +} + +function moveClipBy(id, d) { + const i = S.clips.findIndex(c => c.id === id); + const j = i + d; + if (i < 0 || j < 0 || j >= S.clips.length) return; + commit(() => { + const [c] = S.clips.splice(i, 1); + S.clips.splice(j, 0, c); + }); +} + +// ---------------------------------------------------------------- timeline +const cv = $('#tlCanvas'); +const ctx = cv.getContext('2d'); +const RULER = 18, KFH = 9; + +function setView(s, e) { + const m = cur(); + if (!m) return; + const D = m.duration, span = e - s; + if (span >= D) S.view = { start: 0, end: D }; + else { s = clamp(s, 0, D - span); S.view = { start: s, end: s + span }; } + requestDraw(); +} + +function zoom(factor, anchor) { + const m = cur(); + if (!m) return; + const v = S.view, span = v.end - v.start; + const minSpan = Math.min(m.duration, Math.max(0.25, 10 / fpsOf(m))); + const ns = clamp(span * factor, minSpan, m.duration); + const a = anchor ?? S.time; + const s = a - (a - v.start) * ns / span; + setView(s, s + ns); +} +const zoomFit = () => { const m = cur(); if (m) setView(0, m.duration); }; + +function followPlayhead(jump) { + const m = cur(); + if (!m) return; + const v = S.view, span = v.end - v.start; + if (span >= m.duration - 1e-6) return; + if (S.time < v.start || S.time > v.end) { + const s = jump ? S.time - span / 2 : S.time - span * 0.1; + setView(s, s + span); + } else if (!jump && S.time > v.end - span * 0.02) { + setView(S.time - span * 0.1, S.time + span * 0.9); + } +} + +function niceStep(raw) { + const steps = [0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 14400]; + return steps.find(s => s >= raw) || steps[steps.length - 1]; +} + +function rulerLabel(t, step) { + const H = Math.floor(t / 3600), M = Math.floor(t / 60) % 60, sec = t % 60; + const ss = step < 1 ? sec.toFixed(1).padStart(4, '0') : String(Math.round(sec) % 60).padStart(2, '0'); + return (H ? H + ':' + String(M).padStart(2, '0') : M) + ':' + ss; +} + +function hexA(hex, a) { + const n = parseInt(hex.slice(1), 16); + return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})`; +} + +function drawTimeline() { + needDraw = false; + const dpr = window.devicePixelRatio || 1; + const W = cv.clientWidth, H = cv.clientHeight; + if (!W || !H) return; + if (cv.width !== Math.round(W * dpr) || cv.height !== Math.round(H * dpr)) { + cv.width = Math.round(W * dpr); + cv.height = Math.round(H * dpr); + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, W, H); + ctx.fillStyle = '#0f1217'; + ctx.fillRect(0, 0, W, H); + const m = cur(); + if (!m) return; + + const { start, end } = S.view; + const span = end - start || 1; + const X = t => (t - start) / span * W; + const top = RULER, bot = H - KFH; + const mono = getComputedStyle(document.documentElement).getPropertyValue('--mono'); + + // track + ctx.fillStyle = '#151922'; + ctx.fillRect(0, top, W, bot - top); + if (X(m.duration) < W) { + ctx.fillStyle = '#0b0d11'; + ctx.fillRect(X(m.duration), 0, W - X(m.duration), H); + } + + // ruler + const step = niceStep(span / W * 95); + const minor = step / 5; + ctx.font = '10.5px ' + mono; + ctx.textBaseline = 'top'; + ctx.fillStyle = '#7d8696'; + for (let i = Math.ceil(start / minor); i * minor <= end; i++) { + const t = i * minor; + const x = Math.round(X(t)) + 0.5; + const major = i % 5 === 0; + ctx.fillStyle = major ? '#4a5262' : '#2e3440'; + ctx.fillRect(x - 0.5, major ? 10 : 13, 1, major ? 8 : 5); + if (major) { + ctx.fillStyle = '#8a93a3'; + ctx.fillText(rulerLabel(t, step), x + 3, 1); + } + } + + // keyframe pre-roll (lossless mode) under each clip + const copy = S.settings.mode === 'copy'; + + // clips of this source + ctx.textBaseline = 'top'; + S.clips.forEach((c, i) => { + if (c.src !== m.id) return; + const x1 = X(c.in), x2 = X(c.out); + if (x2 < 0 || x1 > W) return; + const sel = c.id === S.sel; + ctx.fillStyle = hexA(m.color, sel ? 0.42 : 0.22); + ctx.fillRect(x1, top + 3, x2 - x1, bot - top - 6); + ctx.strokeStyle = hexA(m.color, sel ? 1 : 0.75); + ctx.lineWidth = sel ? 2 : 1; + ctx.strokeRect(x1 + 0.5, top + 3.5, Math.max(1, x2 - x1 - 1), bot - top - 7); + if (copy) { + const off = kfOffset(c.src, c.in); + if (off > 0) { + ctx.fillStyle = 'rgba(255,181,71,.55)'; + ctx.fillRect(X(c.in - off), bot - 7, x1 - X(c.in - off), 3); + } + } + if (x2 - x1 > 22) { + ctx.fillStyle = '#fff'; + ctx.font = '600 11px ' + mono; + ctx.fillText('#' + (i + 1), Math.max(x1, 0) + 5, top + 7); + } + }); + + // In/Out selection + const r = activeRange(); + if (!r.clip && (r.in != null || r.out != null)) { + const a = r.in ?? 0, b = r.out ?? m.duration; + ctx.fillStyle = 'rgba(255,181,71,.15)'; + ctx.fillRect(X(a), top, X(b) - X(a), bot - top); + } + const flag = (t, dir) => { + const x = Math.round(X(t)) + 0.5; + if (x < -8 || x > W + 8) return; + ctx.strokeStyle = '#ffb547'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, bot); ctx.stroke(); + ctx.fillStyle = '#ffb547'; + ctx.beginPath(); + ctx.moveTo(x, top); ctx.lineTo(x + 9 * dir, top); ctx.lineTo(x, top + 9); + ctx.closePath(); ctx.fill(); + ctx.beginPath(); + ctx.moveTo(x, bot); ctx.lineTo(x + 9 * dir, bot); ctx.lineTo(x, bot - 9); + ctx.closePath(); ctx.fill(); + }; + if (r.in != null) flag(r.in, 1); + if (r.out != null) flag(r.out, -1); + + // keyframes + const k = S.kf[m.id]; + if (k && k.times.length) { + const a = k.times; + let lo = 0, hi = a.length; + while (lo < hi) { const mid = (lo + hi) >> 1; if (a[mid] < start) lo = mid + 1; else hi = mid; } + ctx.fillStyle = '#6f7a8c'; + let lastX = -2; + for (let i = lo; i < a.length && a[i] <= end; i++) { + const x = Math.round(X(a[i])); + if (x === lastX) continue; + lastX = x; + ctx.fillRect(x, H - KFH + 2, 1, KFH - 3); + } + } + + // playhead + const px = Math.round(X(S.time)) + 0.5; + if (px >= -6 && px <= W + 6) { + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke(); + ctx.fillStyle = '#fff'; + ctx.beginPath(); ctx.moveTo(px - 6, 0); ctx.lineTo(px + 6, 0); ctx.lineTo(px, 8); ctx.closePath(); ctx.fill(); + } +} + +const tAt = e => { const m = cur(); const v = S.view; return clamp(v.start + e.offsetX / cv.clientWidth * (v.end - v.start), 0, m.duration); }; +const xOf = t => (t - S.view.start) / (S.view.end - S.view.start) * cv.clientWidth; + +function handleAt(e) { + if (e.offsetY < RULER - 2) return null; + const r = activeRange(); + const near = t => t != null && Math.abs(xOf(t) - e.offsetX) <= 6; + if (near(r.out)) return 'out'; + if (near(r.in)) return 'in'; + return null; +} + +let drag = null; +cv.addEventListener('pointerdown', e => { + const m = cur(); + if (!m || e.button !== 0) return; + cv.setPointerCapture(e.pointerId); + hideHover(); + const handle = handleAt(e); + if (handle) { + drag = { type: handle }; + const c = editingClip(); + if (c) { S.undo.push(snapshot()); S.redo.length = 0; drag.clip = c; } + } else { + drag = { type: 'scrub', wasPlaying: !video.paused }; + video.pause(); + seek(tAt(e)); + } +}); +cv.addEventListener('pointermove', e => { + const m = cur(); + if (!m) return; + const t = tAt(e); + if (!drag) { + cv.style.cursor = handleAt(e) ? 'ew-resize' : 'pointer'; + return showHover(e, t); + } + if (drag.type === 'scrub') return seek(t); + const fps = fpsOf(m); + const tq = drag.type === 'out' ? outAt(t) : frameFloor(t, m); + if (drag.clip) { + const c = drag.clip; + if (drag.type === 'in') c.in = clamp(tq, 0, c.out - 1 / fps); + else c.out = clamp(tq, c.in + 1 / fps, m.duration); + } else { + const mk = marksOf(m.id); + if (drag.type === 'in') mk.in = clamp(tq, 0, (mk.out ?? m.duration) - 1 / fps); + else mk.out = clamp(tq, (mk.in ?? 0) + 1 / fps, m.duration); + } + seek(drag.type === 'in' ? tq : Math.max(0, tq - 1 / fps)); + renderMarks(); +}); +const endDrag = () => { + if (!drag) return; + if (drag.type === 'scrub' && drag.wasPlaying) video.play().catch(() => {}); + if (drag.type !== 'scrub') changed(); + drag = null; +}; +cv.addEventListener('pointerup', endDrag); +cv.addEventListener('pointercancel', endDrag); +cv.addEventListener('pointerleave', hideHover); +cv.addEventListener('dblclick', e => { + const m = cur(); + if (!m) return; + const t = tAt(e); + const c = [...S.clips].reverse().find(x => x.src === m.id && x.in <= t && t <= x.out); + if (c) selectClip(c.id, { seekTo: false }); +}); +cv.addEventListener('wheel', e => { + if (!cur()) return; + e.preventDefault(); + const v = S.view, span = v.end - v.start; + if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + const d = (e.deltaX || e.deltaY) / cv.clientWidth * span; + setView(v.start + d, v.end + d); + } else { + zoom(Math.exp(e.deltaY * 0.0015), tAt(e)); + } +}, { passive: false }); + +let hoverTimer = 0; +function showHover(e, t) { + const m = cur(); + const tip = $('#tlHover'); + tip.classList.remove('hidden'); + tip.style.left = clamp(e.offsetX, 60, cv.clientWidth - 60) + 'px'; + tip.querySelector('span').textContent = fmtT(t); + const img = tip.querySelector('img'); + if (!m.video) { img.removeAttribute('src'); return; } + clearTimeout(hoverTimer); + hoverTimer = setTimeout(() => { + const q = Math.max((S.view.end - S.view.start) / 150, 1 / fpsOf(m)); + const url = thumbURL(m.id, clamp(Math.round(t / q) * q, 0, m.duration), 72); + if (img.dataset.url !== url) { img.dataset.url = url; img.src = url; } + }, 90); +} +function hideHover() { + clearTimeout(hoverTimer); + $('#tlHover').classList.add('hidden'); +} + +new ResizeObserver(requestDraw).observe($('#timeline')); + +function frame() { + if (isVideoMode() && !video.paused && !video.seeking) { + S.time = video.currentTime; + if (S.playSelEnd != null && S.time >= S.playSelEnd) { + const end = S.playSelEnd; + S.playSelEnd = null; + video.pause(); + seek(Math.max(0, end - 1 / fpsOf(cur()))); + } + followPlayhead(false); + renderTime(); + drawTimeline(); + } else if (needDraw) { + drawTimeline(); + } + requestAnimationFrame(frame); +} + +// ---------------------------------------------------------------- rendering +function renderAll() { + renderSources(); + renderClips(); + renderMarks(); + renderTime(); + renderPlay(); + renderScreen(); + renderTopbar(); + requestDraw(); +} + +function renderSources() { + const ul = $('#sourceList'); + ul.replaceChildren(); + for (const m of S.sources) { + const v = m.video; + const n = S.clips.filter(c => c.src === m.id).length; + const meta = [fmtLen(m.duration), v ? `${v.displayWidth}×${v.displayHeight}` : 'audio only', + v ? v.codec : (m.audio[0] && m.audio[0].codec), v ? fmtFps(v.fps) : ''].filter(Boolean).join(' · '); + const k = S.kf[m.id]; + ul.append(h('li', { class: 'src' + (m.id === S.cur ? ' cur' : ''), 'data-id': m.id, title: m.path, style: `--c:${m.color}` }, + h('div', { class: 'src-thumb' }, + v ? h('img', { src: thumbURL(m.id, Math.min(m.duration * 0.1, 30), 108), alt: '', loading: 'lazy', draggable: 'false' }) + : h('div', { class: 'audio-only' }, '♪')), + h('div', { class: 'src-info' }, + h('div', { class: 'src-name' }, m.name), + h('div', { class: 'src-meta' }, meta), + h('div', { class: 'src-actions' }, + h('button', { class: 'chip-btn', 'data-sact': 'whole', title: 'Add the whole video as one clip' }, icon('plus'), 'Whole'), + n ? h('span', { class: 'src-status' }, `${n} clip${n > 1 ? 's' : ''}`) : null, + k && k.status === 'pending' ? h('span', { class: 'src-status' }, 'reading keyframes…') : null)), + h('button', { class: 'icon-btn src-x', 'data-sact': 'remove', title: 'Remove from project' }, icon('x')))); + } + $('#sourceEmpty').classList.toggle('hidden', S.sources.length > 0); + $('#srcCount').textContent = S.sources.length || ''; +} + +function renderClips() { + const list = $('#clipList'); + const scroll = list.scrollLeft; + list.replaceChildren(); + const copy = S.settings.mode === 'copy'; + let total = 0; + S.clips.forEach((c, i) => { + const m = srcById(c.src); + if (!m) return; + total += c.out - c.in; + const off = copy ? kfOffset(c.src, c.in) : 0; + list.append(h('div', { class: 'clip' + (c.id === S.sel ? ' sel' : ''), draggable: 'true', 'data-id': c.id, style: `--c:${m.color}`, title: m.path }, + h('div', { class: 'clip-thumb' }, + m.video ? h('img', { src: thumbURL(m.id, c.in, 108), alt: '', loading: 'lazy', draggable: 'false' }) : h('div', { class: 'audio-only' }, '♪'), + h('span', { class: 'clip-num' }, String(i + 1)), + h('span', { class: 'clip-dur' }, fmtLen(c.out - c.in)), + off ? h('span', { class: 'clip-warn', title: `Lossless export starts this clip ${off.toFixed(2)} s earlier, at the keyframe at ${fmtT(c.in - off, m)}` }, icon('warn')) : null), + h('div', { class: 'clip-body' }, + h('div', { class: 'clip-name' }, m.name), + h('div', { class: 'clip-range' }, `${fmtT(c.in, m)} → ${fmtT(c.out, m)}`)), + h('div', { class: 'clip-actions' }, + h('button', { 'data-cact': 'left', title: 'Move left' }, icon('left')), + h('button', { 'data-cact': 'dup', title: 'Duplicate' }, icon('copy')), + h('button', { 'data-cact': 'del', class: 'del', title: 'Delete (Del)' }, icon('trash')), + h('button', { 'data-cact': 'right', title: 'Move right' }, icon('right'))))); + }); + list.scrollLeft = scroll; + $('#clipEmpty').classList.toggle('hidden', S.clips.length > 0); + $('#clipCount').textContent = S.clips.length || ''; + $('#clipTotal').textContent = S.clips.length ? 'total ' + fmtLen(total) : ''; + $$('#modeSeg button').forEach(b => b.classList.toggle('on', b.dataset.mode === S.settings.mode)); +} + +function renderMarks() { + const m = cur(); + const r = activeRange(); + markInEl.textContent = m && r.in != null ? fmtT(r.in) : '–'; + markOutEl.textContent = m && r.out != null ? fmtT(r.out) : '–'; + $('#markLen').textContent = m && (r.in != null || r.out != null) ? fmtLen((r.out ?? m.duration) - (r.in ?? 0)) : '–'; + const btn = $('#btnAddClip'); + btn.querySelector('span').textContent = r.clip ? 'Done' : 'Add clip'; + btn.querySelector('use').setAttribute('href', r.clip ? '#i-check' : '#i-plus'); + btn.title = r.clip ? 'Finish editing this clip (Enter)' : 'Add the In→Out range as a clip (Enter)'; + + const rows = []; + if (r.clip) { + rows.push(h('div', { class: 'hint-row edit' }, icon('cut'), + `Editing clip ${S.clips.indexOf(r.clip) + 1}: Set In / Set Out or drag the orange handles to trim it.`, + h('button', { class: 'chip-btn', 'data-hact': 'done' }, 'Done (Enter)'))); + } + if (m && S.settings.mode === 'copy' && r.in != null) { + const off = kfOffset(m.id, r.in); + if (off > 0) { + const next = kfAfter(m.id, r.in); + rows.push(h('div', { class: 'hint-row warn' }, icon('warn'), + `Lossless: In is not on a keyframe, so the export starts ${off.toFixed(2)} s earlier (at ${fmtT(r.in - off)}).`, + h('button', { class: 'chip-btn', 'data-hact': 'snapPrev' }, 'Snap In to previous keyframe'), + next != null && next < (r.out ?? m.duration) ? h('button', { class: 'chip-btn', 'data-hact': 'snapNext' }, 'Snap to next keyframe') : null)); + } + } + const box = $('#markHint'); + box.replaceChildren(...rows); + box.classList.toggle('hidden', rows.length === 0); +} + +function renderTime() { + const m = cur(); + tcCurEl.textContent = fmtT(m ? S.time : 0); + $('#tcDur').textContent = fmtT(m ? m.duration : 0); + let info = ''; + if (m && m.video) { + const fps = fpsOf(m); + info = 'frame ' + Math.floor(S.time * fps + 1e-3); + const k = kfBefore(m.id, S.time); + if (k != null && S.time - k < 0.5 / fps) info += ' · keyframe'; + } + $('#tcFrame').textContent = info; +} + +function renderPlay() { + $('#btnPlay use').setAttribute('href', video.paused ? '#i-play' : '#i-pause'); +} + +function renderMute() { + $('#btnMute use').setAttribute('href', video.muted ? '#i-mute' : '#i-vol'); +} + +function renderScreen() { + const m = cur(); + $('#noSource').classList.toggle('hidden', !!m); + const mode = playMode(m); + video.classList.toggle('hidden', !m || mode === 'still'); + still.classList.toggle('hidden', !m || mode !== 'still'); + let msg = ''; + let center = false; + if (m && !m.video) { msg = '♪ Audio only'; center = true; } + if (m && mode === 'still') { + const j = S.proxyJob[m.id]; + msg = j ? `This format can't be played by the browser directly.\nCreating a preview copy… ${Math.round((j.progress || 0) * 100)}% (you can already scrub and cut)` + : 'Preview not available — showing still frames.'; + } + const el = $('#screenMsg'); + el.textContent = msg; + el.style.whiteSpace = 'pre-line'; + el.classList.toggle('center', center); + el.classList.toggle('hidden', !msg); +} + +function renderTopbar() { + $('#btnUndo').disabled = !S.undo.length; + $('#btnRedo').disabled = !S.redo.length; + $('#btnExport').disabled = !S.clips.length && !(S.exportJob && S.exportJob.state === 'running'); + const j = S.exportJob; + const pill = $('#jobPill'); + const running = j && j.state === 'running'; + pill.classList.toggle('hidden', !running); + if (running) { + const p = Math.floor((j.progress || 0) * 100); + pill.textContent = `Exporting… ${p}%`; + pill.style.setProperty('--p', p + '%'); + } +} + +// ---------------------------------------------------------------- editable time fields +function makeEditable(el, getValue, onCommit) { + el.addEventListener('click', () => { + if (!cur()) return; + const input = h('input', { class: 'tc-input', value: getValue(), spellcheck: 'false' }); + el.replaceWith(input); + input.focus(); + input.select(); + let done = false; + const finish = ok => { + if (done) return; + done = true; + const v = parseTime(input.value); + input.replaceWith(el); + if (ok) { + if (v == null) toast('Type a time like 1:23.5 or 83.5'); + else onCommit(v); + } + renderAll(); + }; + input.addEventListener('keydown', e => { + e.stopPropagation(); + if (e.key === 'Enter') finish(true); + if (e.key === 'Escape') finish(false); + }); + input.addEventListener('blur', () => finish(true)); + }); +} +makeEditable(tcCurEl, () => fmtT(S.time), v => { video.pause(); seek(v); }); +makeEditable(markInEl, () => fmtT(activeRange().in ?? S.time), v => setIn(frameFloor(clamp(v, 0, cur().duration)))); +makeEditable(markOutEl, () => fmtT(activeRange().out ?? S.time), v => setOut(outAt(clamp(v, 0, cur().duration)))); + +// ---------------------------------------------------------------- export dialog +const dlgEx = $('#dlgExport'); + +function copyContainerChoices() { + const exts = ['mp4', 'mkv', 'mov', 'webm']; + for (const c of S.clips) { + const e = extOf(srcById(c.src).path); + if (S.info.copyExts.includes(e) && !exts.includes(e)) exts.push(e); + } + return exts; +} + +function firstClipSource() { return srcById(S.clips[0] && S.clips[0].src) || cur(); } + +function defaultOutput() { + const first = firstClipSource(); + if (!first) return ''; + const [dir, name] = splitPath(first.path); + return joinPath(dir, stripExt(name) + '_edit.' + defaultExt()); +} + +function defaultExt() { + if (S.settings.mode !== 'copy') return 'mp4'; + const first = firstClipSource(); + const e = first ? extOf(first.path) : ''; + return S.info.copyExts.includes(e) ? e : 'mkv'; +} + +function openExport() { + if (!S.clips.length && !(S.exportJob && S.exportJob.state === 'running')) return toast('Add some clips first.'); + if (!S.settings.output) S.settings.output = defaultOutput(); + fillExport(); + dlgEx.showModal(); + refreshCodecs(); +} + +async function refreshCodecs() { + for (let i = 0; i < 60 && !S.info.codecsReady; i++) { + await sleep(500); + try { S.info = await api('/api/info'); } catch { /* ignore */ } + } + if (dlgEx.open) fillExport(); +} + +function setOptions(sel, items, value) { + sel.replaceChildren(...items.map(([v, label]) => h('option', { value: v }, label))); + sel.value = value; +} + +function fillExport() { + const st = S.settings; + const info = S.info; + $$('input[name=mode]').forEach(r => { r.checked = r.value === st.mode; }); + $$('input[name=join]').forEach(r => { r.checked = (r.value === '1') === st.join; }); + + const exts = st.mode === 'copy' ? copyContainerChoices() : info.containers.map(c => c.ext); + if (st.output && !exts.includes(extOf(st.output))) st.output = replaceExt(st.output, exts.includes(defaultExt()) ? defaultExt() : exts[0]); + setOptions($('#exContainer'), exts.map(e => [e, '.' + e]), extOf(st.output)); + if (document.activeElement !== $('#exOutput')) $('#exOutput').value = st.output; + + $('#exAllAudio').checked = st.allAudio; + $('#exNoAudioCopy').checked = st.noAudio; + $('#exAllAudioRow').classList.toggle('hidden', !S.clips.some(c => srcById(c.src).audio.length > 1)); + + const ct = info.containers.find(c => c.ext === extOf(st.output)); + const vcs = (info.codecs || []).filter(c => c.kind === 'video' && (!ct || ct.video.includes(c.family))); + if (vcs.length && !vcs.some(c => c.name === st.vcodec)) { + st.vcodec = vcs[0].name; + st.quality = vcs[0].defQ; + } + setOptions($('#exVCodec'), vcs.length ? vcs.map(c => [c.name, c.label]) : [['', info.codecsReady ? 'No encoder available' : 'Checking encoders…']], st.vcodec); + const vc = vcs.find(c => c.name === st.vcodec); + const q = $('#exQuality'); + q.max = vc ? vc.maxQ : 51; + q.value = st.quality; + $('#exSpeed').value = st.speed; + $('#exHeight').value = String(st.height); + $('#exFPS').value = st.fps; + + const acs = (info.codecs || []).filter(c => c.kind === 'audio' && (!ct || ct.audio.includes(c.family))); + if (st.acodec !== 'none' && acs.length && !acs.some(c => c.name === st.acodec)) st.acodec = acs[0].name; + setOptions($('#exACodec'), [...acs.map(c => [c.name, c.label]), ['none', 'No audio']], st.acodec); + $('#exABitrate').value = String(st.abitrate); + $('#exABitrate').disabled = st.acodec === 'none' || st.acodec === 'flac'; + updateExportView(); +} + +function qualityText(q, vc) { + if (!vc) return ''; + const rel = q - vc.defQ; + const t = rel <= -6 ? 'near lossless, very large file' : rel <= -3 ? 'very high quality' : rel < 0 ? 'high quality' + : rel === 0 ? 'recommended balance' : rel <= 4 ? 'smaller file' : rel <= 9 ? 'small file, visible loss' : 'very small, low quality'; + return `${q} — ${t}`; +} + +function outSize(m, height) { + let w = m.video.displayWidth, hh = m.video.displayHeight; + if (height > 0 && height !== hh) { w = w * height / hh; hh = height; } + const even = n => { n = Math.round(n); return n % 2 ? n + 1 : n; }; + return [even(w), even(hh)]; +} + +function formatSig(m, allAudio) { + const v = m.video; + const parts = [v ? `${v.codec} ${v.width}×${v.height} ${v.pixFmt || ''}`.trim() : 'no video']; + const audio = allAudio ? m.audio : m.audio.slice(0, 1); + for (const a of audio) parts.push(`${a.codec} ${a.sampleRate} Hz ${a.channels}ch`); + return parts.join(', '); +} + +function updateExportView() { + const st = S.settings; + const copy = st.mode === 'copy'; + const n = S.clips.length; + const total = S.clips.reduce((s, c) => s + c.out - c.in, 0); + $('#exCopyOpts').classList.toggle('hidden', !copy); + $('#exEncOpts').classList.toggle('hidden', copy); + $('#exSummary').textContent = `${n} clip${n !== 1 ? 's' : ''} · ${fmtLen(total)} total → ${st.join || n === 1 ? 'one video file' : n + ' separate files'}`; + + let outHint = ''; + if (!st.join && n > 1 && st.output) { + const [, name] = splitPath(st.output); + const w = Math.max(2, String(n).length); + const num = i => String(i).padStart(w, '0'); + outHint = `Files are named ${stripExt(name)}_${num(1)}.${extOf(name)} … ${stripExt(name)}_${num(n)}.${extOf(name)}`; + } + $('#exOutHint').textContent = outHint; + + const vc = (S.info.codecs || []).find(c => c.name === st.vcodec); + $('#exQLabel').textContent = qualityText(+st.quality, vc); + + let encHint = ''; + const firstV = S.clips.map(c => srcById(c.src)).find(m => m && m.video); + if (!copy && firstV) { + const [w, hh] = outSize(firstV, +st.height); + encHint = `Output: ${w}×${hh}, ${st.fps ? $('#exFPS').selectedOptions[0].textContent + ' fps' : fmtFps(firstV.video.fps)}`; + if (st.join) { + const shapes = new Set(S.clips.map(c => srcById(c.src)).filter(m => m.video).map(m => (m.video.displayWidth / m.video.displayHeight).toFixed(3))); + if (shapes.size > 1) encHint += ' · clips with a different shape get black bars'; + } + } + $('#exEncHint').textContent = encHint; + + const warns = []; + if (copy) { + const offs = S.clips.map(c => kfOffset(c.src, c.in)).filter(o => o > 0); + if (offs.length) { + warns.push(`${offs.length} clip${offs.length > 1 ? 's don\'t' : ' doesn\'t'} start on a keyframe and will begin up to ${Math.max(...offs).toFixed(2)} s earlier than marked. Choose Re-encode for frame-exact cuts.`); + } + if (st.join && n > 1) { + const seen = new Map(); + for (const c of S.clips) { + const m = srcById(c.src); + seen.set(formatSig(m, st.allAudio && !st.noAudio), m.name); + } + if (seen.size > 1) { + warns.push('The source videos have different formats, so they can\'t be joined losslessly:\n' + + [...seen].map(([sig, name]) => `• ${name}: ${sig}`).join('\n') + '\nChoose Re-encode, or save each clip separately.'); + } + } + if (S.clips.some(c => S.kf[c.src] && S.kf[c.src].status === 'pending')) warns.push('Still reading keyframe positions…'); + } else if (!S.info.codecsReady) { + warns.push('Checking which encoders (including GPU encoders) are available…'); + } + $('#exWarn').textContent = warns.join('\n\n'); + $('#exWarn').classList.toggle('hidden', !warns.length); + + const running = !!(S.exportJob && S.exportJob.state === 'running'); + $('#exGo').disabled = running || !n; + $('#exCancel').classList.toggle('hidden', !running); + $$('#exportForm input, #exportForm select, #exBrowse').forEach(el => { el.disabled = running; }); + if (!running) $('#exABitrate').disabled = st.acodec === 'none' || st.acodec === 'flac'; +} + +function renderExportProgress() { + const j = S.exportJob; + const prog = $('#exProgress'), res = $('#exResult'); + const running = !!(j && j.state === 'running'); + prog.classList.toggle('hidden', !running); + res.classList.toggle('hidden', !j || running); + if (running) { + $('#exBar').style.width = ((j.progress || 0) * 100).toFixed(1) + '%'; + $('#exPText').textContent = `${j.step || 'Working'}… ${Math.floor((j.progress || 0) * 100)}%${j.speed ? ' · ' + j.speed : ''}`; + $('#exPEta').textContent = j.eta > 0 && j.progress > 0.02 ? `about ${fmtETA(j.eta)} left` : ''; + } else if (j) { + res.replaceChildren(); + if (j.state === 'done') { + const outs = j.outputs || []; + res.append(h('div', { class: 'notice ok' }, + h('b', {}, `Export finished in ${fmtETA(j.elapsed)} — ${outs.length} file${outs.length !== 1 ? 's' : ''} saved`), + h('div', { class: 'paths' }, ...outs.map(o => h('div', {}, o))), + outs.length ? h('button', { class: 'btn', onclick: () => api('/api/reveal', { path: outs[0] }).catch(e => toast(e.message, 'error')) }, icon('folder'), 'Show in folder') : null)); + } else if (j.state === 'error') { + res.append(h('div', { class: 'notice error' }, 'Export failed:\n' + j.error)); + } else if (j.state === 'canceled') { + res.append(h('div', { class: 'notice warn' }, 'Export canceled.')); + } + requestAnimationFrame(() => res.scrollIntoView({ block: 'nearest', behavior: 'smooth' })); + } + updateExportView(); +} + +async function startExport(overwrite = false) { + const st = S.settings; + st.output = $('#exOutput').value.trim(); + if (!st.output) return toast('Choose where to save the export.', 'error'); + const body = { + clips: S.clips.map(c => ({ src: c.src, in: c.in, out: c.out })), + mode: st.mode, join: st.join, output: st.output, overwrite, + allAudio: st.allAudio, noAudio: st.mode === 'copy' ? st.noAudio : st.acodec === 'none', + vcodec: st.vcodec, quality: +st.quality, speed: st.speed, height: +st.height, fps: st.fps, + acodec: st.acodec, abitrate: +st.abitrate, + }; + saveSoon(); + try { + const r = await api('/api/export', body); + const known = S.jobs[r.jobId]; + S.exportJob = known || { id: r.jobId, state: 'running', progress: 0, step: 'Starting', outputs: [] }; + if (known && known.state !== 'running') exportFinished(known); + } catch (e) { + if (e.status === 409 && e.data && e.data.exists) { + const ex = e.data.exists; + const names = ex.slice(0, 4).map(p => splitPath(p)[1]).join('\n') + (ex.length > 4 ? '\n…' : ''); + if (await confirmDlg(`${ex.length === 1 ? 'This file already exists' : ex.length + ' files already exist'}:\n${names}\n\nReplace ${ex.length === 1 ? 'it' : 'them'}?`, 'Replace')) { + return startExport(true); + } + return; + } + S.exportJob = { id: 'local', state: 'error', error: e.message, outputs: [] }; + } + renderExportProgress(); + renderTopbar(); +} + +function exportFinished(j) { + if (dlgEx.open) return; // the dialog shows the result + if (j.state === 'done') toast(`Export finished — ${(j.outputs || []).length} file(s) saved`, 'ok'); + else if (j.state === 'error') toast('Export failed — open Export for details.', 'error'); +} + +async function browseOutput() { + const st = S.settings; + st.output = $('#exOutput').value.trim() || defaultOutput(); + if (S.info.nativeDialogs) { + try { + const r = await api('/api/dialog/save', { path: st.output }); + if (r.supported) { + if (r.path) { st.output = r.path; fillExport(); saveSoon(); } + return; + } + } catch (e) { + if (e.status === 409) return; + } + } + const p = await fileBrowser({ mode: 'save', path: st.output }); + if (p) { st.output = p; fillExport(); saveSoon(); } +} + +function bindExport() { + const st = () => S.settings; + const refill = () => { fillExport(); saveSoon(); }; + $$('input[name=mode]').forEach(r => r.addEventListener('change', () => { setMode(r.value); })); + $$('input[name=join]').forEach(r => r.addEventListener('change', () => { st().join = r.value === '1'; refill(); })); + $('#exOutput').addEventListener('input', e => { st().output = e.target.value; updateExportView(); saveSoon(); }); + $('#exOutput').addEventListener('change', e => { st().output = e.target.value.trim(); refill(); }); + $('#exContainer').addEventListener('change', e => { st().output = replaceExt(st().output || defaultOutput(), e.target.value); refill(); }); + $('#exBrowse').addEventListener('click', browseOutput); + $('#exAllAudio').addEventListener('change', e => { st().allAudio = e.target.checked; refill(); }); + $('#exNoAudioCopy').addEventListener('change', e => { st().noAudio = e.target.checked; refill(); }); + $('#exVCodec').addEventListener('change', e => { + st().vcodec = e.target.value; + const vc = (S.info.codecs || []).find(c => c.name === e.target.value); + if (vc) st().quality = vc.defQ; + refill(); + }); + $('#exQuality').addEventListener('input', e => { st().quality = +e.target.value; updateExportView(); saveSoon(); }); + $('#exSpeed').addEventListener('change', e => { st().speed = e.target.value; refill(); }); + $('#exHeight').addEventListener('change', e => { st().height = +e.target.value; refill(); }); + $('#exFPS').addEventListener('change', e => { st().fps = e.target.value; refill(); }); + $('#exACodec').addEventListener('change', e => { st().acodec = e.target.value; refill(); }); + $('#exABitrate').addEventListener('change', e => { st().abitrate = +e.target.value; refill(); }); + $('#exGo').addEventListener('click', () => startExport(false)); + $('#exCancel').addEventListener('click', () => { + if (S.exportJob) api('/api/job/cancel', { id: S.exportJob.id }).catch(() => {}); + }); + dlgEx.addEventListener('close', () => { + // A finished result has been seen once the dialog is closed. + if (S.exportJob && S.exportJob.state !== 'running') { S.exportJob = null; renderExportProgress(); } + }); +} + +function setMode(mode) { + const st = S.settings; + if (st.mode === mode) return; + st.mode = mode; + const exts = mode === 'copy' ? copyContainerChoices() : S.info.containers.map(c => c.ext); + if (st.output && !exts.includes(extOf(st.output))) st.output = replaceExt(st.output, defaultExt()); + if (dlgEx.open) fillExport(); + changed(); +} + +// ---------------------------------------------------------------- built-in file browser +const dlgF = $('#dlgFiles'); +let fb = null; + +function fileBrowser({ mode = 'open', path = '' } = {}) { + return new Promise(resolve => { + fb = { mode, resolve, sel: new Set(), path: '', entries: [], parent: '' }; + $('#fbTitle').textContent = mode === 'open' ? 'Add videos' : 'Export as'; + $('#fbOk').textContent = mode === 'open' ? 'Add' : 'Save here'; + $('#fbSaveRow').classList.toggle('hidden', mode !== 'save'); + let dir = S.settings.lastDir || ''; + if (mode === 'save' && path) { + const [d, n] = splitPath(path); + dir = d; + $('#fbName').value = n; + } + $('#fbList').replaceChildren(h('li', { class: 'fb-empty' }, 'Loading…')); + $('#fbOk').disabled = mode === 'open'; + dlgF.showModal(); + fbLoad(dir); + }); +} + +let fbSeq = 0; +async function fbLoad(path) { + if (!fb) return; + const seq = ++fbSeq; + try { + const r = await api('/api/fs/list?path=' + encodeURIComponent(path) + ($('#fbAll').checked ? '&all=1' : '')); + if (!fb || seq !== fbSeq) return; // closed, or a newer listing was requested + Object.assign(fb, { path: r.path, entries: r.entries, parent: r.parent }); + fb.sel.clear(); + $('#fbPath').value = r.path; + const root = r.roots.find(x => r.path.toUpperCase().startsWith(x.toUpperCase())) || r.roots[0]; + setOptions($('#fbRoots'), r.roots.map(x => [x, x]), root); + $('#fbUp').disabled = !r.parent; + fbRender(); + } catch (e) { + if (fb && seq === fbSeq) toast(e.message, 'error'); + } +} + +function fbRender() { + const ul = $('#fbList'); + ul.replaceChildren(); + if (!fb.entries.length) ul.append(h('li', { class: 'fb-empty' }, fb.mode === 'open' ? 'No videos in this folder' : 'This folder is empty')); + for (const en of fb.entries) { + ul.append(h('li', { class: (en.dir ? 'dir' : 'file') + (fb.sel.has(en.path) ? ' sel' : ''), 'data-path': en.path, 'data-dir': en.dir ? '1' : null }, + icon(en.dir ? 'folder' : 'film'), h('span', { class: 'fb-name' }, en.name), + en.dir ? null : h('span', { class: 'fb-size' }, fmtBytes(en.size)))); + } + const n = fb.sel.size; + $('#fbSelInfo').textContent = fb.mode === 'open' && n ? `${n} selected` : ''; + $('#fbOk').disabled = fb.mode === 'open' && !n; +} + +function fbFinish() { + if (!fb) return; + let result; + if (fb.mode === 'open') { + result = [...fb.sel]; + } else { + const name = $('#fbName').value.trim(); + if (!name) return toast('Type a file name.'); + result = joinPath(fb.path, name); + } + S.settings.lastDir = fb.path; + const f = fb; + fb = null; + dlgF.close(); + f.resolve(result); +} + +function bindFileBrowser() { + $('#fbList').addEventListener('click', e => { + const li = e.target.closest('li[data-path]'); + if (!li || !fb) return; + const p = li.dataset.path; + if (li.dataset.dir) return fbLoad(p); + if (fb.mode === 'open') { + if (fb.sel.has(p)) fb.sel.delete(p); else fb.sel.add(p); + } else { + $('#fbName').value = splitPath(p)[1]; + } + fbRender(); + }); + $('#fbList').addEventListener('dblclick', e => { + const li = e.target.closest('li[data-path]'); + if (!li || li.dataset.dir || !fb) return; + if (fb.mode === 'open') fb.sel.add(li.dataset.path); + else $('#fbName').value = splitPath(li.dataset.path)[1]; + fbFinish(); + }); + $('#fbOk').addEventListener('click', fbFinish); + $('#fbUp').addEventListener('click', () => fb && fb.parent && fbLoad(fb.parent)); + $('#fbRoots').addEventListener('change', e => fbLoad(e.target.value)); + $('#fbAll').addEventListener('change', () => fb && fbLoad(fb.path)); + $('#fbPath').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fbLoad(e.target.value); } }); + $('#fbName').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fbFinish(); } }); + dlgF.addEventListener('close', () => { + if (fb) { const f = fb; fb = null; f.resolve(null); } + }); +} + +// ---------------------------------------------------------------- events from the server +let offlineTimer = 0; +function connectEvents() { + const es = new EventSource('/api/events'); + es.addEventListener('job', e => onJob(JSON.parse(e.data))); + es.onopen = () => { clearTimeout(offlineTimer); $('#offline').classList.add('hidden'); }; + es.onerror = () => { + clearTimeout(offlineTimer); + offlineTimer = setTimeout(() => { + if (es.readyState !== EventSource.OPEN) $('#offline').classList.remove('hidden'); + }, 2500); + }; +} + +function onJob(j) { + S.jobs[j.id] = j; + if (j.kind === 'proxy') { + if (j.state === 'running') S.proxyJob[j.sourceId] = j; + else if (j.state === 'done') proxyReady(j.sourceId); + else { + delete S.proxyJob[j.sourceId]; + if (j.state === 'error' && j.sourceId === S.cur) toast('Could not create a preview copy:\n' + j.error, 'error'); + } + if (j.sourceId === S.cur) renderScreen(); + return; + } + if (j.kind === 'export') { + const prev = S.exportJob; + if (j.state === 'running' || (prev && prev.id === j.id)) { + const finishedNow = prev && prev.id === j.id && prev.state === 'running' && j.state !== 'running'; + S.exportJob = j; + if (finishedNow) exportFinished(j); + } + if (dlgEx.open) renderExportProgress(); + renderTopbar(); + } +} + +// ---------------------------------------------------------------- session autosave +let saveTimer = 0; +function saveSoon() { + if (!S.restored) return; + clearTimeout(saveTimer); + saveTimer = setTimeout(saveNow, 700); +} + +function saveNow() { + if (!S.restored) return; + clearTimeout(saveTimer); + const marks = {}; + for (const [id, mk] of Object.entries(S.marks)) { + const m = srcById(id); + if (m && (mk.in != null || mk.out != null)) marks[m.path] = mk; + } + const data = { + version: 1, + sources: S.sources.map(m => m.path), + clips: S.clips.map(c => ({ path: srcById(c.src).path, in: c.in, out: c.out })), + current: cur() ? cur().path : null, + time: S.time, + marks, + settings: S.settings, + }; + fetch('/api/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), keepalive: true }).catch(() => {}); +} + +async function restoreSession() { + let s = {}; + try { s = await api('/api/session'); } catch { /* fresh start */ } + S.settings = { ...DEFAULTS, ...(s.settings || {}) }; + if (Array.isArray(s.sources) && s.sources.length && S.info.ffmpegOK) { + const results = await Promise.all(s.sources.map(p => api('/api/probe', { path: p }).catch(() => null))); + const byPath = {}; + const missing = []; + results.forEach((m, i) => { + if (!m) { missing.push(s.sources[i]); return; } + if (!srcById(m.id)) { assignColor(m); S.sources.push(m); } + byPath[s.sources[i]] = m.id; + }); + S.clips = (s.clips || []).filter(c => byPath[c.path] && c.out > c.in) + .map(c => ({ id: newId(), src: byPath[c.path], in: +c.in, out: +c.out })); + for (const [p, mk] of Object.entries(s.marks || {})) { + if (byPath[p]) S.marks[byPath[p]] = { in: mk.in ?? null, out: mk.out ?? null }; + } + if (missing.length) { + toast(`${missing.length} video${missing.length > 1 ? 's' : ''} from your last session could not be found:\n` + + missing.map(p => splitPath(p)[1]).join('\n'), 'error'); + } + S.sources.forEach(m => ensureKeyframes(m.id)); + const curId = byPath[s.current] || (S.sources[0] && S.sources[0].id); + if (curId) loadSource(curId, { time: s.time || 0 }); + } + S.restored = true; +} + +// ---------------------------------------------------------------- input bindings +const ACTIONS = { + start: () => seek(0), + end: () => { const m = cur(); if (m) seek(m.duration); }, + prevKey: () => gotoKey(-1), + nextKey: () => gotoKey(1), + back1s: () => seekBy(-1), + fwd1s: () => seekBy(1), + prevFrame: () => stepFrames(-1), + nextFrame: () => stepFrames(1), + play: togglePlay, + mute: toggleMute, + zoomIn: () => zoom(0.5), + zoomOut: () => zoom(2), + zoomFit, + setIn: () => setIn(), + setOut: () => setOut(), + split, + playSel: playSelection, + addClip: addClipOrDone, +}; + +function bindUI() { + document.addEventListener('click', e => { + const b = e.target.closest('[data-act]'); + if (b && ACTIONS[b.dataset.act]) { ACTIONS[b.dataset.act](); b.blur(); } + }); + $$('[data-close]').forEach(b => b.addEventListener('click', () => b.closest('dialog').close())); + + $('#btnAdd').addEventListener('click', () => addFiles(false)); + $('#btnBrowse').addEventListener('click', () => addFiles(true)); + $('#btnUndo').addEventListener('click', undo); + $('#btnRedo').addEventListener('click', redo); + $('#btnNew').addEventListener('click', newProject); + $('#btnHelp').addEventListener('click', () => $('#dlgHelp').showModal()); + $('#btnExport').addEventListener('click', openExport); + $('#jobPill').addEventListener('click', openExport); + $('#volume').addEventListener('input', e => { + video.volume = +e.target.value; + video.muted = false; + S.settings.volume = video.volume; + S.settings.muted = false; + renderMute(); + saveSoon(); + }); + $$('#modeSeg button').forEach(b => b.addEventListener('click', () => { setMode(b.dataset.mode); b.blur(); })); + + $('#sourceList').addEventListener('click', e => { + const li = e.target.closest('.src'); + if (!li) return; + const act = e.target.closest('[data-sact]'); + if (act && act.dataset.sact === 'remove') return removeSource(li.dataset.id); + if (act && act.dataset.sact === 'whole') return addWhole(li.dataset.id); + if (li.dataset.id !== S.cur) loadSource(li.dataset.id); + }); + + $('#markHint').addEventListener('click', e => { + const a = e.target.closest('[data-hact]'); + const m = cur(); + if (!a || !m) return; + const r = activeRange(); + if (a.dataset.hact === 'done') { S.sel = null; changed(); } + if (a.dataset.hact === 'snapPrev') { const k = kfBefore(m.id, r.in); if (k != null) { setIn(k); seek(k, { quantize: false }); } } + if (a.dataset.hact === 'snapNext') { const k = kfAfter(m.id, r.in); if (k != null) { setIn(k); seek(k, { quantize: false }); } } + }); + + // clip strip: click / actions / drag to reorder + const list = $('#clipList'); + let dragId = null, dropIndex = null; + const clearDrop = () => $$('.drop-before, .drop-after', list).forEach(el => el.classList.remove('drop-before', 'drop-after')); + list.addEventListener('click', e => { + const card = e.target.closest('.clip'); + if (!card) return; + const id = card.dataset.id; + const act = e.target.closest('[data-cact]'); + if (act) { + ({ del: deleteClip, dup: duplicateClip, left: x => moveClipBy(x, -1), right: x => moveClipBy(x, 1) })[act.dataset.cact](id); + return; + } + selectClip(id); + }); + list.addEventListener('dragstart', e => { + const card = e.target.closest('.clip'); + if (!card) return; + dragId = card.dataset.id; + card.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', dragId); + }); + list.addEventListener('dragover', e => { + if (!dragId) return; + e.preventDefault(); + clearDrop(); + const card = e.target.closest('.clip'); + if (!card) { + const last = list.lastElementChild; + if (last) last.classList.add('drop-after'); + dropIndex = S.clips.length; + return; + } + const r = card.getBoundingClientRect(); + const after = e.clientX > r.left + r.width / 2; + card.classList.add(after ? 'drop-after' : 'drop-before'); + const i = S.clips.findIndex(c => c.id === card.dataset.id); + dropIndex = after ? i + 1 : i; + }); + list.addEventListener('drop', e => { + e.preventDefault(); + if (dragId != null && dropIndex != null) moveClipTo(dragId, dropIndex); + }); + list.addEventListener('dragend', () => { + dragId = null; + dropIndex = null; + clearDrop(); + $$('.dragging', list).forEach(el => el.classList.remove('dragging')); + }); + + // Files dropped from Explorer have no usable path in a browser window. + window.addEventListener('dragover', e => { if (!dragId) e.preventDefault(); }); + window.addEventListener('drop', e => { + if (dragId) return; + e.preventDefault(); + if (e.dataTransfer && e.dataTransfer.files.length) toast('Dropping files isn\'t supported here — use "Add videos" instead.'); + }); + + document.addEventListener('keydown', onKey); + window.addEventListener('beforeunload', saveNow); +} + +function onKey(e) { + if (e.target.closest('input, select, textarea') || document.querySelector('dialog[open]')) return; + const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; + const ctrl = e.ctrlKey || e.metaKey; + let handled = true; + if (ctrl && k === 'z') { if (e.shiftKey) redo(); else undo(); } + else if (ctrl && k === 'y') redo(); + else if (ctrl && (k === 'ArrowLeft' || k === 'ArrowRight')) seekBy(k === 'ArrowLeft' ? -10 : 10); + else if (ctrl || e.altKey) handled = false; + else { + switch (k) { + case ' ': case 'k': togglePlay(); break; + case 'ArrowLeft': if (e.shiftKey) seekBy(-1); else stepFrames(-1); break; + case 'ArrowRight': if (e.shiftKey) seekBy(1); else stepFrames(1); break; + case 'ArrowUp': gotoKey(-1); break; + case 'ArrowDown': gotoKey(1); break; + case 'Home': seek(0); break; + case 'End': ACTIONS.end(); break; + case 'i': if (e.shiftKey) gotoMark('in'); else setIn(); break; + case 'o': if (e.shiftKey) gotoMark('out'); else setOut(); break; + case 'Enter': addClipOrDone(); break; + case 'p': playSelection(); break; + case 's': split(); break; + case 'm': toggleMute(); break; + case 'Delete': case 'Backspace': if (S.sel) deleteClip(S.sel); break; + case 'Escape': if (S.sel) { S.sel = null; changed(); } break; + case '+': case '=': zoom(0.5); break; + case '-': case '_': zoom(2); break; + case '0': zoomFit(); break; + default: handled = false; + } + } + if (handled) e.preventDefault(); +} + +// ---------------------------------------------------------------- start +async function init() { + try { + S.info = await api('/api/info'); + } catch { + $('#offline').classList.remove('hidden'); + return; + } + if (!S.info.ffmpegOK) { + const win = S.info.os === 'windows'; + const b = $('#banner'); + b.replaceChildren(h('b', {}, 'ffmpeg was not found. '), + win ? 'Download a Windows build (for example "ffmpeg-release-essentials.zip" from ' : 'Install ffmpeg (for example from ', + h('a', { href: 'https://www.gyan.dev/ffmpeg/builds/', target: '_blank', rel: 'noopener' }, 'gyan.dev/ffmpeg/builds'), + win ? '), copy ffmpeg.exe and ffprobe.exe from its bin folder next to vibedit.exe, then restart VibEdit.' + : ' or your package manager) and restart VibEdit.'); + b.classList.remove('hidden'); + } + $('#btnBrowse').classList.toggle('hidden', !S.info.nativeDialogs); + $('#helpVersion').textContent = `VibEdit ${S.info.version} · ${S.info.ffmpegVersion || 'ffmpeg not found'}`; + bindUI(); + bindExport(); + bindFileBrowser(); + connectEvents(); + await restoreSession(); + video.volume = clamp(+S.settings.volume || 0, 0, 1); + video.muted = !!S.settings.muted; + $('#volume').value = video.volume; + renderMute(); + renderAll(); + requestAnimationFrame(frame); +} + +window.vibedit = { S, api }; // handy for debugging from the console +init(); +})(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..d713d21 --- /dev/null +++ b/web/index.html @@ -0,0 +1,338 @@ + + + + + +VibEdit + + + + + + +
+
VibEdit
+
+ + +
+
+ + + +
+ + + + +
+ + +
+ + +
+
+ + + +
Add a video, then click it in the list to preview it here.
+
+ +
+
+ 00:00.000 + / + 00:00.000 + +
+
+ + + + + + + + + +
+
+ + +
+
+ +
+
+ + +
+
+ + + +
+
+ +
+ +
–
+ +
–
+
–
+ + + + +
+ +
+ +
+
+ Clips + + + Export method +
+ + +
+
+
+
+

No clips yet. Set an In and Out point on the timeline and press Add clip, + or use + Whole on a source and Split it. Clips are joined in this order — drag them to reorder.

+
+
+
+ + + +

Export

+
+
+ +
+
Method
+
+ + +
+
+ +
+
Output
+
+ + +
+
+ +
+
Save as
+
+ + + +
+
+
+ +
+
Audio
+
+ + +
+
+ +
+
+
+
Video codec
+ +
+
+
Speed
+ +
+
+
+
Quality
+
BestSmallest
+
+
+
+
Resolution
+ +
+
+
Frame rate
+ +
+
+
+
+
Audio codec
+ +
+
+
Audio bitrate
+ +
+
+
+
+ + + + +
+
+ + + + +
+
+ + + +

Add videos

+
+ + + +
+
    + +
    + + + + + +
    +
    + + + +

    +
    +
    + + + +

    How to use VibEdit

    +
    +
      +
    1. Add videos and click one to preview it.
    2. +
    3. Move the playhead and press I / O to mark the start and end of a part you want, then Enter to add it as a clip. Repeat for every part (from any source).
    4. +
    5. Or add a whole video with + Whole, put the playhead where you want to cut and press S to split; delete the pieces you don't want.
    6. +
    7. Drag clips to put them in order, then Export — joined into one video or as separate files.
    8. +
    +

    The In point includes the frame you see; the Out point cuts just before the frame you see. Your project is saved automatically.

    + + + + + + + + + +
    SpacePlay / pauseI OSet In / Out
    ← →Previous / next frameShift+I/OGo to In / Out
    Shift+← →Back / forward 1 sEnterAdd clip (or finish editing)
    Ctrl+← →Back / forward 10 sSSplit clip at playhead
    ↑ ↓Previous / next keyframePPlay In → Out
    Home EndStart / endDeleteDelete selected clip
    + − 0Zoom timeline / fitEscStop editing clip
    MMuteCtrl+Z / YUndo / redo
    +

    +
    +
    + +
    + + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..ffcaf12 --- /dev/null +++ b/web/style.css @@ -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; } +} diff --git a/winres/icon.png b/winres/icon.png new file mode 100644 index 0000000..e856821 Binary files /dev/null and b/winres/icon.png differ diff --git a/winres/winres.json b/winres/winres.json new file mode 100644 index 0000000..8c57fbd --- /dev/null +++ b/winres/winres.json @@ -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" + } + } + } + } + } +}