Files
vibedit/ffmpeg.go
Sean MTS c6ac7d2222 Initial commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:28:47 +00:00

333 lines
9.5 KiB
Go

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