feat: ConvertGUI v2.0 (Wails + preview/trim + NSIS + Gitea CI)
Build Windows Installer / build (push) Successful in 4m11s
Build Windows Installer / build (push) Successful in 4m11s
This commit is contained in:
40 files changed
+6084
-266
No files matched your search
@@ -0,0 +1,300 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"convert/internal/media"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
addr string
|
||||
httpSrv *http.Server
|
||||
ffmpegBin string
|
||||
mu sync.RWMutex
|
||||
files map[string]registeredFile
|
||||
proxyLocks map[string]*sync.Mutex
|
||||
proxyCache map[string]string
|
||||
thumbCache map[string][]byte
|
||||
tempFiles []string
|
||||
}
|
||||
|
||||
type registeredFile struct {
|
||||
info *media.VideoInfo
|
||||
}
|
||||
|
||||
func Start(ffmpegBin string) (*Server, error) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
addr: ln.Addr().String(),
|
||||
ffmpegBin: ffmpegBin,
|
||||
files: make(map[string]registeredFile),
|
||||
proxyLocks: make(map[string]*sync.Mutex),
|
||||
proxyCache: make(map[string]string),
|
||||
thumbCache: make(map[string][]byte),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/video", s.handleVideo)
|
||||
mux.HandleFunc("/proxy", s.handleProxy)
|
||||
mux.HandleFunc("/thumb", s.handleThumb)
|
||||
|
||||
s.httpSrv = &http.Server{
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = s.httpSrv.Serve(ln)
|
||||
}()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) SetFFmpeg(bin string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.ffmpegBin = bin
|
||||
}
|
||||
|
||||
func (s *Server) BaseURL() string {
|
||||
return "http://" + s.addr
|
||||
}
|
||||
|
||||
func (s *Server) Register(info *media.VideoInfo) string {
|
||||
sum := sha1.Sum([]byte(info.Path))
|
||||
id := hex.EncodeToString(sum[:8])
|
||||
|
||||
s.mu.Lock()
|
||||
s.files[id] = registeredFile{info: info}
|
||||
s.mu.Unlock()
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *Server) Close() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if s.httpSrv != nil {
|
||||
_ = s.httpSrv.Shutdown(ctx)
|
||||
}
|
||||
s.mu.Lock()
|
||||
for _, p := range s.tempFiles {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
s.tempFiles = nil
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) getFile(id string) (registeredFile, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rf, ok := s.files[id]
|
||||
return rf, ok
|
||||
}
|
||||
|
||||
func (s *Server) handleVideo(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
rf, ok := s.getFile(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(rf.info.Path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
http.ServeContent(w, r, rf.info.FileName, st.ModTime(), f)
|
||||
}
|
||||
|
||||
func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
merge := r.URL.Query().Get("merge") == "1" || r.URL.Query().Get("merge") == "true"
|
||||
rf, ok := s.getFile(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
ffmpegBin := s.ffmpegBin
|
||||
s.mu.RUnlock()
|
||||
if ffmpegBin == "" {
|
||||
http.Error(w, "ffmpeg not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s_m%v", id, merge)
|
||||
s.mu.Lock()
|
||||
lk, exists := s.proxyLocks[cacheKey]
|
||||
if !exists {
|
||||
lk = &sync.Mutex{}
|
||||
s.proxyLocks[cacheKey] = lk
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
|
||||
s.mu.RLock()
|
||||
proxyPath := s.proxyCache[cacheKey]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if proxyPath == "" {
|
||||
proxyPath = filepath.Join(os.TempDir(), fmt.Sprintf("convert_preview_%s.mp4", cacheKey))
|
||||
if err := s.buildProxy(r.Context(), ffmpegBin, rf.info, merge, proxyPath); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.proxyCache[cacheKey] = proxyPath
|
||||
s.tempFiles = append(s.tempFiles, proxyPath)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
f, err := os.Open(proxyPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
http.ServeContent(w, r, "preview.mp4", st.ModTime(), f)
|
||||
}
|
||||
|
||||
func (s *Server) buildProxy(ctx context.Context, ffmpegBin string, info *media.VideoInfo, merge bool, outPath string) error {
|
||||
args := []string{"-hide_banner", "-loglevel", "error", "-y", "-i", info.Path}
|
||||
|
||||
numAudio := len(info.AudioStreams)
|
||||
if numAudio == 0 {
|
||||
args = append(args, "-map", "0:v:0", "-an")
|
||||
} else if merge && numAudio > 1 {
|
||||
var in strings.Builder
|
||||
for i := 0; i < numAudio; i++ {
|
||||
in.WriteString(fmt.Sprintf("[0:a:%d]", i))
|
||||
}
|
||||
args = append(args,
|
||||
"-filter_complex", fmt.Sprintf("%samix=inputs=%d:normalize=0[a]", in.String(), numAudio),
|
||||
"-map", "0:v:0", "-map", "[a]",
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
)
|
||||
} else {
|
||||
args = append(args, "-map", "0:v:0", "-map", "0:a:0", "-c:a", "aac", "-b:a", "128k")
|
||||
}
|
||||
|
||||
if info.VideoCodec == "h264" {
|
||||
// Remux video stream instantaneously when codec is already H.264
|
||||
args = append(args, "-c:v", "copy")
|
||||
} else {
|
||||
args = append(args,
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast",
|
||||
"-crf", "26",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-vf", "scale='min(1280,iw)':-2",
|
||||
)
|
||||
}
|
||||
args = append(args, "-movflags", "+faststart", outPath)
|
||||
|
||||
cmd := exec.CommandContext(ctx, ffmpegBin, args...)
|
||||
media.HideCmdWindow(cmd)
|
||||
var errBuf bytes.Buffer
|
||||
cmd.Stderr = &errBuf
|
||||
if err := cmd.Run(); err != nil {
|
||||
_ = os.Remove(outPath)
|
||||
return fmt.Errorf("preview proxy error: %v (%s)", err, strings.TrimSpace(errBuf.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
tStr := r.URL.Query().Get("t")
|
||||
rf, ok := s.getFile(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
tSec, err := strconv.ParseFloat(tStr, 64)
|
||||
if err != nil || tSec < 0 {
|
||||
tSec = 0
|
||||
}
|
||||
if rf.info.Duration > 0 && tSec >= rf.info.Duration {
|
||||
tSec = rf.info.Duration - 0.1
|
||||
if tSec < 0 {
|
||||
tSec = 0
|
||||
}
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s_%.2f", id, tSec)
|
||||
s.mu.RLock()
|
||||
cached, found := s.thumbCache[key]
|
||||
ffmpegBin := s.ffmpegBin
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !found {
|
||||
if ffmpegBin == "" {
|
||||
http.Error(w, "ffmpeg not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
cmd := exec.CommandContext(r.Context(), ffmpegBin,
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-ss", strconv.FormatFloat(tSec, 'f', 3, 64),
|
||||
"-i", rf.info.Path,
|
||||
"-frames:v", "1",
|
||||
"-vf", "scale=200:-1",
|
||||
"-q:v", "6",
|
||||
"-f", "image2pipe",
|
||||
"-vcodec", "mjpeg",
|
||||
"pipe:1",
|
||||
)
|
||||
media.HideCmdWindow(cmd)
|
||||
data, err := cmd.Output()
|
||||
if err != nil || len(data) == 0 {
|
||||
http.Error(w, "thumbnail generation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if len(s.thumbCache) > 200 {
|
||||
// simple cap reset
|
||||
s.thumbCache = make(map[string][]byte)
|
||||
}
|
||||
s.thumbCache[key] = data
|
||||
s.mu.Unlock()
|
||||
cached = data
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
_, _ = w.Write(cached)
|
||||
}
|
||||
Reference in new issue
Block a user