feat: ConvertGUI v2.0 (Wails + preview/trim + NSIS + Gitea CI)
Build Windows Installer / build (push) Successful in 4m11s

This commit is contained in:
m4xx1m committed 2026-11-12 17:30:44 +03:00
1 parent 02fd1dc817
commit 65387c5dcd
40 files changed
+6084 -266

No files matched your search

+300
View File
@@ -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)
}
+87
View File
@@ -0,0 +1,87 @@
package preview
import (
"io"
"net/http"
"os/exec"
"path/filepath"
"testing"
"convert/internal/media"
)
func TestPreviewServer_RangeThumbAndProxy(t *testing.T) {
bins := media.FindBinaries("", "")
if bins.FFmpeg == "" || bins.FFprobe == "" {
t.Skip("ffmpeg/ffprobe not installed")
}
tmpDir := t.TempDir()
src := filepath.Join(tmpDir, "clip.mkv")
gen := exec.Command(bins.FFmpeg,
"-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=duration=2:size=320x180:rate=30",
"-f", "lavfi", "-i", "sine=frequency=440:duration=2",
"-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-y", src,
)
if out, err := gen.CombinedOutput(); err != nil {
t.Fatalf("failed generating sample mkv: %v (%s)", err, string(out))
}
info, err := media.Probe(bins.FFprobe, src)
if err != nil {
t.Fatalf("probe failed: %v", err)
}
srv, err := Start(bins.FFmpeg)
if err != nil {
t.Fatalf("preview server start failed: %v", err)
}
defer srv.Close()
id := srv.Register(info)
// 1. Test Range request on /video
req, _ := http.NewRequest("GET", srv.BaseURL()+"/video?id="+id, nil)
req.Header.Set("Range", "bytes=0-127")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET /video failed: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
t.Fatalf("expected 206 Partial Content, got %d", resp.StatusCode)
}
// 2. Test /thumb JPEG generation
tResp, err := http.Get(srv.BaseURL() + "/thumb?id=" + id + "&t=0.5")
if err != nil {
t.Fatalf("GET /thumb failed: %v", err)
}
tBody, _ := io.ReadAll(tResp.Body)
tResp.Body.Close()
if tResp.StatusCode != http.StatusOK || len(tBody) < 100 || tBody[0] != 0xFF || tBody[1] != 0xD8 {
t.Fatalf("expected valid JPEG thumbnail, status=%d len=%d", tResp.StatusCode, len(tBody))
}
// 3. Test /proxy MKV -> MP4 remux
pResp, err := http.Get(srv.BaseURL() + "/proxy?id=" + id + "&merge=0")
if err != nil {
t.Fatalf("GET /proxy failed: %v", err)
}
pBody, _ := io.ReadAll(pResp.Body)
pResp.Body.Close()
if pResp.StatusCode != http.StatusOK || len(pBody) < 500 {
t.Fatalf("expected valid MP4 proxy, status=%d len=%d", pResp.StatusCode, len(pBody))
}
// 4. Test unknown ID returns 404
nfResp, err := http.Get(srv.BaseURL() + "/video?id=nonexistent")
if err != nil {
t.Fatalf("GET /video 404 failed: %v", err)
}
nfResp.Body.Close()
if nfResp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", nfResp.StatusCode)
}
}